Merge pull request 'feat: 拣选库位动销分析导出增加商品编码,按仓位×商品编码拆行' (#59) from codex/picking-bin-export-sku-1008 into main
This commit is contained in:
commit
195351deae
@ -9,7 +9,7 @@ const {
|
||||
BIN_TYPES,
|
||||
PICKING_BIN_DEFAULT_TYPES,
|
||||
} = require('../config/pickingBinSalesAnalysis');
|
||||
const { fetchPickingBinRows } = require('../services/pickingBinSalesAnalysis');
|
||||
const { fetchPickingBinRows, fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
@ -212,7 +212,8 @@ router.get('/export', async (req, res) => {
|
||||
}
|
||||
|
||||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||||
const rows = await fetchPickingBinRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes });
|
||||
// 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行
|
||||
const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes });
|
||||
|
||||
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
|
||||
const filtered = grades.length > 0
|
||||
@ -223,6 +224,7 @@ router.get('/export', async (req, res) => {
|
||||
whMap.get(r.wmsCoId) || r.wmsCoId,
|
||||
r.binName,
|
||||
r.binType,
|
||||
r.skuId,
|
||||
r.stockQty,
|
||||
r.skuCount,
|
||||
round2(r.salesQty),
|
||||
@ -239,7 +241,7 @@ router.get('/export', async (req, res) => {
|
||||
sheets: [{
|
||||
name: '库位动销明细',
|
||||
header: [
|
||||
'仓库', '仓位', '类型', '库存数量', 'SKU数',
|
||||
'仓库', '仓位', '类型', '商品编码', '库存数量', 'SKU数',
|
||||
`销量(近${days}天)`, '日销', '动销等级', '统计天数', '统计基准日',
|
||||
],
|
||||
rows: sheetRows,
|
||||
|
||||
@ -174,8 +174,160 @@ async function fetchPickingBinRows(cloudDb, wmsCoIds, options = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取「仓位 × 商品编码」粒度明细(导出用)。
|
||||
*
|
||||
* 与 fetchPickingBinRows 的差别:每个仓位有 N 个 SKU 就输出 N 行。
|
||||
* - 库存数量:该 SKU 在这个仓位的实际库存
|
||||
* - 销量:该 SKU 的销量 ÷ 它占用的同类型仓位数(即这个组合对仓位的贡献),
|
||||
* 同一 SKU 出现在多个仓位时各行数值相同;全部行求和仍等于「有库位 SKU 销量之和」
|
||||
* - skuCount / binSalesQty / gradeKey:仍是仓位粒度,同一仓位的多行重复显示
|
||||
*
|
||||
* @param {object} cloudDb 业务库实例
|
||||
* @param {string[]} wmsCoIds 仓库编码
|
||||
* @param {{shipFrom:string, shipTo:string, days:number, binTypes?:string[]}} options
|
||||
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string, skuId:string,
|
||||
* stockQty:number, skuCount:number, salesQty:number, dailySales:number,
|
||||
* binSalesQty:number, gradeKey:string}>>}
|
||||
*/
|
||||
async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||||
throw new TypeError('fetchPickingBinSkuRows 的第一个参数必须是业务库实例');
|
||||
}
|
||||
const { shipFrom, shipTo, days, binTypes } = options;
|
||||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||||
if (!shipFrom || !shipTo || !days) return [];
|
||||
|
||||
const binIn = buildInClause(wmsCoIds, 'w');
|
||||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||||
|
||||
let typeFilter = '';
|
||||
const typeParams = {};
|
||||
if (Array.isArray(binTypes) && binTypes.length > 0) {
|
||||
const t = buildInClause(binTypes, 't');
|
||||
Object.assign(typeParams, t.params);
|
||||
typeFilter = `AND a.binType IN (${t.clause})`;
|
||||
}
|
||||
|
||||
const sqlText = `
|
||||
WITH bin AS (
|
||||
SELECT p.wms_co_id AS wmsCoId,
|
||||
LTRIM(RTRIM(p.bin)) AS binName,
|
||||
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
|
||||
ELSE LTRIM(RTRIM(p.bin_group)) END AS binType,
|
||||
p.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binSkuQty
|
||||
FROM rpt.jst_packInventoryInfo p
|
||||
WHERE p.wms_co_id IN (${binIn.clause})
|
||||
AND ISNULL(p.bin, '') <> ''
|
||||
AND ISNULL(p.qty, 0) <> 0
|
||||
GROUP BY p.wms_co_id,
|
||||
LTRIM(RTRIM(p.bin)),
|
||||
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
|
||||
ELSE LTRIM(RTRIM(p.bin_group)) END,
|
||||
p.sku_id
|
||||
),
|
||||
binSpan AS (
|
||||
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
|
||||
FROM bin
|
||||
GROUP BY wmsCoId, skuId, binType
|
||||
),
|
||||
ship AS (
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
oi.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||||
FROM rpt.jst_OrderItem oi
|
||||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||||
AND o.send_date >= @shipFrom
|
||||
AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
GROUP BY o.wms_co_id, oi.sku_id
|
||||
),
|
||||
binSales AS (
|
||||
SELECT b.wmsCoId,
|
||||
b.binName,
|
||||
b.binType,
|
||||
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
|
||||
FROM bin b
|
||||
JOIN binSpan sp
|
||||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||||
LEFT JOIN ship s
|
||||
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
||||
GROUP BY b.wmsCoId, b.binName, b.binType
|
||||
),
|
||||
binAgg AS (
|
||||
SELECT wmsCoId,
|
||||
binName,
|
||||
binType,
|
||||
SUM(binSkuQty) AS stockQty,
|
||||
COUNT(DISTINCT skuId) AS skuCount
|
||||
FROM bin
|
||||
GROUP BY wmsCoId, binName, binType
|
||||
),
|
||||
skuRows AS (
|
||||
SELECT b.wmsCoId,
|
||||
b.binName,
|
||||
b.binType,
|
||||
b.skuId,
|
||||
SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS stockQty,
|
||||
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
|
||||
FROM bin b
|
||||
JOIN binSpan sp
|
||||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||||
LEFT JOIN ship s
|
||||
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
||||
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId
|
||||
)
|
||||
SELECT sk.wmsCoId,
|
||||
sk.binName,
|
||||
sk.binType,
|
||||
sk.skuId,
|
||||
CAST(sk.stockQty AS DECIMAL(18, 2)) AS stockQty,
|
||||
CAST(a.skuCount AS INT) AS skuCount,
|
||||
CAST(ISNULL(sk.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
|
||||
CAST(ISNULL(sk.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
|
||||
CAST(ISNULL(x.salesQty, 0) AS DECIMAL(18, 2)) AS binSalesQty,
|
||||
${buildGradeCase('ISNULL(x.salesQty, 0)')} AS gradeKey
|
||||
FROM skuRows sk
|
||||
JOIN binAgg a
|
||||
ON a.wmsCoId = sk.wmsCoId AND a.binName = sk.binName AND a.binType = sk.binType
|
||||
LEFT JOIN binSales x
|
||||
ON x.wmsCoId = sk.wmsCoId AND x.binName = sk.binName AND x.binType = sk.binType
|
||||
WHERE ABS(ISNULL(sk.stockQty, 0)) > 0.0001
|
||||
${typeFilter}
|
||||
ORDER BY sk.wmsCoId, sk.binType, sk.binName, sk.skuId`;
|
||||
|
||||
const rows = await cloudDb.query(
|
||||
sqlText,
|
||||
{
|
||||
...binIn.params,
|
||||
...shipIn.params,
|
||||
...typeParams,
|
||||
shipFrom,
|
||||
shipTo,
|
||||
days,
|
||||
},
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
wmsCoId: String(r.wmsCoId),
|
||||
binName: r.binName || '',
|
||||
binType: r.binType || '',
|
||||
skuId: r.skuId == null ? '' : String(r.skuId).trim(),
|
||||
stockQty: Number(r.stockQty) || 0,
|
||||
skuCount: Number(r.skuCount) || 0,
|
||||
salesQty: Number(r.salesQty) || 0,
|
||||
dailySales: Number(r.dailySales) || 0,
|
||||
binSalesQty: Number(r.binSalesQty) || 0,
|
||||
gradeKey: r.gradeKey || 'zero',
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildGradeCase,
|
||||
buildInClause,
|
||||
fetchPickingBinRows,
|
||||
fetchPickingBinSkuRows,
|
||||
};
|
||||
|
||||
@ -7,6 +7,7 @@ const {
|
||||
buildGradeCase,
|
||||
buildInClause,
|
||||
fetchPickingBinRows,
|
||||
fetchPickingBinSkuRows,
|
||||
} = require('../src/services/pickingBinSalesAnalysis');
|
||||
const {
|
||||
PICKING_BIN_GRADE_RULES,
|
||||
@ -119,3 +120,52 @@ test('gradeKey 缺失时兜底为 zero', async () => {
|
||||
assert.equal(rows[0].salesQty, 0);
|
||||
assert.equal(rows[0].dailySales, 0);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchPickingBinSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组', async () => {
|
||||
const db = { query: async () => [{ skuId: 'S1' }] };
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 0 }), []);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 按仓位×商品编码输出并保留仓位口径字段', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => {
|
||||
captured = { sqlText, params };
|
||||
return [
|
||||
{
|
||||
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU1',
|
||||
stockQty: 12, skuCount: 2, salesQty: 30, dailySales: 1,
|
||||
binSalesQty: 45, gradeKey: 'slow1',
|
||||
},
|
||||
{
|
||||
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU2',
|
||||
stockQty: 8, skuCount: 2, salesQty: 15, dailySales: 0.5,
|
||||
binSalesQty: 45, gradeKey: 'slow1',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 });
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows.map((r) => r.skuId), ['SKU1', 'SKU2']);
|
||||
// 一个仓位两个 SKU 拆成两行,库存为各商品在该仓位的实际库存
|
||||
assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]);
|
||||
// 动销等级仍属仓位,同一仓位多行相同
|
||||
assert.equal(rows[0].gradeKey, 'slow1');
|
||||
assert.equal(rows[1].gradeKey, 'slow1');
|
||||
assert.equal(rows[0].binSalesQty, 45);
|
||||
assert.equal(rows[0].skuCount, 2);
|
||||
// SQL 含商品编码列与倍数分摊
|
||||
assert.ok(captured.sqlText.includes('b.skuId'));
|
||||
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
|
||||
assert.equal(captured.params.days, 30);
|
||||
});
|
||||
|
||||
@ -202,7 +202,7 @@ const PickingBinSalesAnalysis = () => {
|
||||
<Tooltip title="跳过 5 分钟缓存,重新扫描订单与仓位明细重算(约 5~20 秒)">
|
||||
<Button onClick={() => load({ force: true })} loading={loading}>强制重算</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="导出当前筛选下的库位明细(仓库 / 仓位 / 类型 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级),每个仓位独立一行">
|
||||
<Tooltip title="导出当前筛选下的库位明细(仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级)。按「仓位 × 商品编码」拆行:同一仓位有多个商品时拆成多行,库存数量为该商品在此仓位的实际库存;销量为该商品按其占用仓位数分摊后的贡献,动销等级仍属仓位(同一仓位多行相同)">
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出明细</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user