From 2c8ed86c26ec26213113c464d807ac8ae860c0ae Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Sun, 27 Sep 2026 17:47:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=93=E5=AD=98=E5=8A=A8=E9=94=80?= =?UTF-8?q?=E5=88=86=E6=9E=90=E3=80=8C=E9=94=80=E5=94=AE=3D0=E3=80=8D?= =?UTF-8?q?=E6=A1=A3=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=20SKU=20=E6=98=8E?= =?UTF-8?q?=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 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」 --- backend/src/api/stockSalesAnalysis.js | 77 +++++++++++++++- backend/src/services/stockSalesAnalysis.js | 88 +++++++++++++++++++ backend/test/stockSalesAnalysis.test.js | 47 ++++++++++ frontend/src/api/stockSalesAnalysis.js | 6 ++ .../src/pages/StockSalesAnalysis/index.jsx | 32 ++++++- 5 files changed, 246 insertions(+), 4 deletions(-) diff --git a/backend/src/api/stockSalesAnalysis.js b/backend/src/api/stockSalesAnalysis.js index 8eb4ed8..988b09e 100644 --- a/backend/src/api/stockSalesAnalysis.js +++ b/backend/src/api/stockSalesAnalysis.js @@ -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; diff --git a/backend/src/services/stockSalesAnalysis.js b/backend/src/services/stockSalesAnalysis.js index 0f188ac..55074e8 100644 --- a/backend/src/services/stockSalesAnalysis.js +++ b/backend/src/services/stockSalesAnalysis.js @@ -135,8 +135,96 @@ async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) { })); } +/** + * 「销售=0」档的 SKU 级明细(导出用)。 + * 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero', + * 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。 + * @returns {Promise>} + */ +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, }; diff --git a/backend/test/stockSalesAnalysis.test.js b/backend/test/stockSalesAnalysis.test.js index ab56347..e9b97e1 100644 --- a/backend/test/stockSalesAnalysis.test.js +++ b/backend/test/stockSalesAnalysis.test.js @@ -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); +}); diff --git a/frontend/src/api/stockSalesAnalysis.js b/frontend/src/api/stockSalesAnalysis.js index af3fdec..1d148a5 100644 --- a/frontend/src/api/stockSalesAnalysis.js +++ b/frontend/src/api/stockSalesAnalysis.js @@ -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 } +); diff --git a/frontend/src/pages/StockSalesAnalysis/index.jsx b/frontend/src/pages/StockSalesAnalysis/index.jsx index 0551105..6d5064f 100644 --- a/frontend/src/pages/StockSalesAnalysis/index.jsx +++ b/frontend/src/pages/StockSalesAnalysis/index.jsx @@ -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 = () => { + + + -- 2.47.2