feat: 库存动销分析「销售=0」档支持导出 SKU 明细
- 后端新增 GET /api/stock-sales-analysis/zero-sales-export: 复用与页面完全一致的分档口径(同一段 gradeCase 表达式 + 「有库存或有销量」过滤), 输出仓库/SKU编码/SKU名称/库存数量/销量/统计天数/统计基准日的 SKU 级明细; 支持.days 统计窗口、wmsCoId 逗号分隔过滤(仅允许仓库清单内的 ID,防任意拼参)、 format=xlsx|csv,复用通用导出服务 exportWorkbook - services 新增 fetchZeroSaleSkuRows:SKU 级「销售=0」查询,LEFT JOIN rpt.jst_sku 取名称, 按仓库+库存倒序排列 - 前端工具栏新增「导出销售=0」按钮:跟随当前统计天数与仓库筛选, 文件名含业务日期与仓库范围,失败时解出错误体给出可读提示 - 测试补充 3 条:防呆抛错、空参短路、SQL 口径与参数传递断言 验证:后端 syntax 通过、单测 8/8(全量 389 条仅既有 Windows 环境项失败); 前端 eslint/build/test 21/21 通过; 本地实测:全仓导出 3641 行、26 仓、销量列全为 0; 单仓(崔桥半成品包装 694 行)与页面「销售=0」SKU 数一致; 浏览器实测点击下载真实文件名「库存动销分析-销售0明细-20260926.xlsx」
This commit is contained in:
parent
151d420831
commit
2c8ed86c26
@ -7,7 +7,8 @@ const {
|
||||
STOCK_SALES_WINDOW_OPTIONS,
|
||||
STOCK_SALES_DEFAULT_DAYS,
|
||||
} = require('../config/stockSalesAnalysis');
|
||||
const { fetchGradeRows } = require('../services/stockSalesAnalysis');
|
||||
const { fetchGradeRows, fetchZeroSaleSkuRows } = require('../services/stockSalesAnalysis');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -202,4 +203,78 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/stock-sales-analysis/zero-sales-export
|
||||
* 导出「销售=0」档的 SKU 级明细。
|
||||
* query: days(统计窗口)、wmsCoId(可选,逗号分隔,不传=全部仓库)、format(xlsx|csv,默认 xlsx)
|
||||
*/
|
||||
router.get('/zero-sales-export', async (req, res) => {
|
||||
try {
|
||||
const days = resolveDays(req.query?.days);
|
||||
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||||
|
||||
const asOf = nextDayAsOf(latestBizDate);
|
||||
if (!asOf) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: '业务库暂无库存监控数据,无法定位统计基准日',
|
||||
});
|
||||
}
|
||||
|
||||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||||
|
||||
// 仓库过滤:只允许出现在仓库清单里的 wmsCoId,防止任意拼参
|
||||
const known = new Map(
|
||||
DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null)
|
||||
.map((b) => [String(b.wmsCoId), b])
|
||||
);
|
||||
const requested = String(req.query?.wmsCoId || '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => known.has(v));
|
||||
const wmsCoIds = requested.length > 0 ? requested : [...known.keys()];
|
||||
|
||||
const rows = await fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
|
||||
|
||||
const sheetRows = rows.map((r) => {
|
||||
const base = known.get(r.wmsCoId);
|
||||
return [
|
||||
base?.warehouse || r.wmsCoId,
|
||||
r.skuId,
|
||||
r.skuName,
|
||||
r.stockQty,
|
||||
r.salesQty,
|
||||
days,
|
||||
latestBizDate || '',
|
||||
];
|
||||
});
|
||||
|
||||
const scopeLabel = requested.length === 1 ? `-${known.get(requested[0]).warehouse}` : '';
|
||||
const fileName = `库存动销分析-销售0明细${scopeLabel}-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
|
||||
const { totalRows } = sendWorkbookDownload(res, {
|
||||
fileName,
|
||||
format,
|
||||
sheets: [{
|
||||
name: '销售=0明细',
|
||||
header: ['仓库', 'SKU编码', 'SKU名称', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'],
|
||||
rows: sheetRows,
|
||||
}],
|
||||
});
|
||||
console.log(`[stock-sales-analysis] 销售=0明细导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 窗口 ${days} 天`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[stock-sales-analysis] 销售=0明细导出失败:', error.message);
|
||||
// 响应可能已部分写出(sendWorkbookDownload 之后一般不会抛),仅在未发送时回 JSON
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '销售=0明细导出失败',
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -135,8 +135,96 @@ async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 「销售=0」档的 SKU 级明细(导出用)。
|
||||
* 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero',
|
||||
* 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。
|
||||
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, stockQty:number, salesQty:number}>>}
|
||||
*/
|
||||
async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
|
||||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||||
throw new TypeError('fetchZeroSaleSkuRows 的第一个参数必须是业务库实例');
|
||||
}
|
||||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||||
if (!shipFrom || !shipTo || !days) return [];
|
||||
|
||||
const invIn = buildInClause(wmsCoIds, 'w');
|
||||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||||
const gradeCase = buildGradeCase();
|
||||
|
||||
const sqlText = `
|
||||
WITH inv AS (
|
||||
SELECT i.wms_co_id AS wmsCoId,
|
||||
i.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
|
||||
FROM rpt.jst_inventoryInfo i
|
||||
WHERE i.wms_co_id IN (${invIn.clause})
|
||||
GROUP BY i.wms_co_id, i.sku_id
|
||||
),
|
||||
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
|
||||
),
|
||||
base AS (
|
||||
SELECT u.wmsCoId,
|
||||
u.skuId,
|
||||
SUM(u.stockQty) AS stockQty,
|
||||
SUM(u.salesQty) AS salesQty
|
||||
FROM (
|
||||
SELECT wmsCoId, skuId, stockQty, CAST(0 AS DECIMAL(18, 2)) AS salesQty FROM inv
|
||||
UNION ALL
|
||||
SELECT wmsCoId, skuId, CAST(0 AS DECIMAL(18, 2)) AS stockQty, salesQty FROM ship
|
||||
) u
|
||||
GROUP BY u.wmsCoId, u.skuId
|
||||
),
|
||||
graded AS (
|
||||
SELECT b.wmsCoId,
|
||||
b.skuId,
|
||||
b.stockQty,
|
||||
b.salesQty,
|
||||
${gradeCase} AS gradeKey
|
||||
FROM base b
|
||||
WHERE ABS(ISNULL(b.stockQty, 0)) > 0.0001
|
||||
OR ABS(ISNULL(b.salesQty, 0)) > 0.0001
|
||||
)
|
||||
SELECT g.wmsCoId,
|
||||
g.skuId,
|
||||
ISNULL(s.name, '') AS skuName,
|
||||
CAST(g.stockQty AS DECIMAL(18, 2)) AS stockQty,
|
||||
CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty
|
||||
FROM graded g
|
||||
LEFT JOIN rpt.jst_sku s ON s.sku_id = g.skuId
|
||||
WHERE g.gradeKey = 'zero'
|
||||
ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId`;
|
||||
|
||||
const rows = await cloudDb.query(sqlText, {
|
||||
...invIn.params,
|
||||
...shipIn.params,
|
||||
shipFrom,
|
||||
shipTo,
|
||||
days,
|
||||
}, { requestTimeout: 180000 });
|
||||
|
||||
return rows.map((r) => ({
|
||||
wmsCoId: String(r.wmsCoId),
|
||||
skuId: String(r.skuId ?? ''),
|
||||
skuName: r.skuName == null ? '' : String(r.skuName),
|
||||
stockQty: Number(r.stockQty) || 0,
|
||||
salesQty: Number(r.salesQty) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInClause,
|
||||
buildGradeCase,
|
||||
fetchGradeRows,
|
||||
fetchZeroSaleSkuRows,
|
||||
};
|
||||
|
||||
@ -7,6 +7,7 @@ const {
|
||||
buildGradeCase,
|
||||
buildInClause,
|
||||
fetchGradeRows,
|
||||
fetchZeroSaleSkuRows,
|
||||
} = require('../src/services/stockSalesAnalysis');
|
||||
const { SALES_GRADE_RULES } = require('../src/config/stockSalesAnalysis');
|
||||
|
||||
@ -71,3 +72,49 @@ test('fetchGradeRows 把仓库参数与窗口参数一并传给业务库', async
|
||||
assert.ok(captured.sqlText.includes('rpt.jst_inventoryInfo'));
|
||||
assert.ok(captured.sqlText.includes('rpt.jst_OrderItem'));
|
||||
});
|
||||
|
||||
test('fetchZeroSaleSkuRows 首参不是数据库实例时抛错,避免静默返回空数组', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchZeroSaleSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchZeroSaleSkuRows 无仓库或缺失窗口时直接返回空结果', async () => {
|
||||
let called = 0;
|
||||
const db = { query: async () => { called += 1; return []; } };
|
||||
assert.deepEqual(await fetchZeroSaleSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchZeroSaleSkuRows(db, [101], { shipFrom: 'a', shipTo: null, days: 30 }), []);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => {
|
||||
captured = { sqlText, params };
|
||||
return [
|
||||
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', stockQty: 12, salesQty: 0 },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const rows = await fetchZeroSaleSkuRows(db, [101, 102], {
|
||||
shipFrom: '2026-08-28 01:00:00',
|
||||
shipTo: '2026-09-27 01:00:00',
|
||||
days: 30,
|
||||
});
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].wmsCoId, '101');
|
||||
assert.equal(rows[0].skuName, '款式A');
|
||||
assert.equal(rows[0].stockQty, 12);
|
||||
|
||||
// 口径必须与 fetchGradeRows 一致:同一段 gradeCase 表达式 + 同样的「有库存或有销量」过滤
|
||||
assert.ok(captured.sqlText.includes("ELSE 'zero'"));
|
||||
assert.ok(captured.sqlText.includes("g.gradeKey = 'zero'"));
|
||||
assert.ok(captured.sqlText.includes('rpt.jst_sku'));
|
||||
assert.ok(captured.sqlText.includes("NOT IN ('Delete', 'Cancelled')"));
|
||||
assert.equal(captured.params.w0, 101);
|
||||
assert.equal(captured.params.days, 30);
|
||||
});
|
||||
|
||||
@ -8,3 +8,9 @@ export const getStockSalesAnalysis = (params = {}) => request.get(
|
||||
'/api/stock-sales-analysis',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出「销售=0」档的 SKU 级明细(xlsx/csv,服务端直接返回文件流)
|
||||
export const exportStockSalesZeroDetail = (params = {}) => request.get(
|
||||
'/api/stock-sales-analysis/zero-sales-export',
|
||||
{ params, responseType: 'blob', timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Modal, Select, Space, Spin, Table, Tooltip, Typography,
|
||||
Alert, Button, Modal, Select, Space, Spin, Table, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import { getStockSalesAnalysis } from '../../api/stockSalesAnalysis';
|
||||
import { exportStockSalesZeroDetail, getStockSalesAnalysis } from '../../api/stockSalesAnalysis';
|
||||
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
|
||||
import {
|
||||
STOCK_SALES_COLUMNS,
|
||||
STOCK_SALES_WINDOW_OPTIONS,
|
||||
@ -43,6 +44,7 @@ const StockSalesAnalysis = () => {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [days, setDays] = useState(STOCK_SALES_DEFAULT_DAYS);
|
||||
const [warehouse, setWarehouse] = useState(ALL_WAREHOUSES);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const load = useCallback(async (options = {}) => {
|
||||
setLoading(true);
|
||||
@ -71,6 +73,27 @@ const StockSalesAnalysis = () => {
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
const grand = data?.grand ?? null;
|
||||
|
||||
/** 导出「销售=0」档 SKU 明细:跟随当前统计天数与仓库筛选 */
|
||||
const handleExportZero = useCallback(async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const wmsCoId = warehouse === ALL_WAREHOUSES
|
||||
? undefined
|
||||
: items.find((it) => it.warehouse === warehouse)?.wmsCoId;
|
||||
const params = { days, format: 'xlsx', ...(wmsCoId != null ? { wmsCoId: String(wmsCoId) } : {}) };
|
||||
const blob = await exportStockSalesZeroDetail(params);
|
||||
const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期';
|
||||
const scope = warehouse === ALL_WAREHOUSES ? '' : `-${warehouse}`;
|
||||
const fileName = `库存动销分析-销售0明细${scope}-${dateKey}.xlsx`;
|
||||
downloadBlobAsFile(blob, fileName);
|
||||
message.success(`已开始下载:${fileName}`);
|
||||
} catch (exportError) {
|
||||
message.error(await readBlobErrorMessage(exportError, '导出「销售=0」明细失败'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [days, warehouse, items, data?.latestBizDate]);
|
||||
|
||||
const warehouseOptions = useMemo(() => ([
|
||||
{ label: '全部仓库', value: ALL_WAREHOUSES },
|
||||
...items.map((it) => ({ label: it.warehouse, value: it.warehouse })),
|
||||
@ -186,6 +209,9 @@ const StockSalesAnalysis = () => {
|
||||
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细与库存明细重算(约 5~20 秒)">
|
||||
<Button onClick={() => load({ days, force: true })} loading={loading}>强制重算</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="导出当前筛选下「销售=0」档的 SKU 明细(仓库 / SKU编码 / SKU名称 / 库存数量),跟随统计天数与仓库筛选">
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportZero} loading={exporting}>导出销售=0</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user