- 后端新增 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」
281 lines
9.9 KiB
JavaScript
281 lines
9.9 KiB
JavaScript
const express = require('express');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
|
||
const {
|
||
SALES_GRADE_RULES,
|
||
SALES_TOTAL_LABEL,
|
||
STOCK_SALES_WINDOW_OPTIONS,
|
||
STOCK_SALES_DEFAULT_DAYS,
|
||
} = require('../config/stockSalesAnalysis');
|
||
const { fetchGradeRows, fetchZeroSaleSkuRows } = require('../services/stockSalesAnalysis');
|
||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||
|
||
const router = express.Router();
|
||
|
||
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */
|
||
const AS_OF_TIME = '01:00';
|
||
|
||
/** 结果缓存:一次要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
|
||
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||
const cacheByDays = new Map();
|
||
|
||
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
|
||
|
||
/** 快照日 + 次日 01:00 作为统一的统计基准时刻 */
|
||
function nextDayAsOf(bizDate) {
|
||
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
|
||
const d = new Date(`${bizDate}T00:00:00Z`);
|
||
d.setUTCDate(d.getUTCDate() + 1);
|
||
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
|
||
}
|
||
|
||
/** 发货窗口:[asOf - N 天, asOf) */
|
||
function buildShipWindow(asOf, days) {
|
||
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
|
||
const from = new Date(to.getTime() - days * 86400000);
|
||
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
|
||
return { shipFrom: fmt(from), shipTo: fmt(to) };
|
||
}
|
||
|
||
async function fetchLatestBizDate() {
|
||
const rows = await cloudDb.query(
|
||
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
|
||
);
|
||
return rows[0]?.d || null;
|
||
}
|
||
|
||
async function fetchInventoryTakenAt() {
|
||
const rows = await cloudDb.query(
|
||
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
|
||
);
|
||
return rows[0]?.takenAt || null;
|
||
}
|
||
|
||
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
|
||
function resolveDays(raw) {
|
||
const n = Number.parseInt(String(raw ?? ''), 10);
|
||
return STOCK_SALES_WINDOW_OPTIONS.includes(n) ? n : STOCK_SALES_DEFAULT_DAYS;
|
||
}
|
||
|
||
/**
|
||
* GET /api/stock-sales-analysis
|
||
* 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量
|
||
*/
|
||
router.get('/', async (req, res) => {
|
||
try {
|
||
const force = String(req.query?.refresh || '') === '1';
|
||
const days = resolveDays(req.query?.days);
|
||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||
|
||
const cached = cacheByDays.get(days);
|
||
if (!force && cached && cached.latestBizDate === latestBizDate
|
||
&& Date.now() - cached.at < RESULT_CACHE_TTL_MS) {
|
||
return res.json({ success: true, data: cached.payload, cached: true });
|
||
}
|
||
|
||
const asOf = nextDayAsOf(latestBizDate);
|
||
if (!asOf) {
|
||
return res.status(503).json({
|
||
success: false,
|
||
error: '业务库暂无库存监控数据,无法定位统计基准日',
|
||
});
|
||
}
|
||
|
||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||
const warehouses = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
|
||
const wmsCoIds = warehouses.map((b) => b.wmsCoId);
|
||
|
||
const [rows, takenAt] = await Promise.all([
|
||
fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
|
||
fetchInventoryTakenAt().catch(() => null),
|
||
]);
|
||
|
||
// (仓库, 等级) → 指标
|
||
const matrix = new Map();
|
||
rows.forEach((r) => {
|
||
matrix.set(`${r.wmsCoId}::${r.gradeKey}`, r);
|
||
});
|
||
|
||
const buildBucket = (grade, cell) => ({
|
||
key: grade.key,
|
||
label: grade.label,
|
||
tip: grade.tip,
|
||
skuCount: cell?.skuCount ?? 0,
|
||
stockQty: round2(cell?.stockQty ?? 0),
|
||
salesQty: round2(cell?.salesQty ?? 0),
|
||
dailySales: Number((cell?.dailySales ?? 0).toFixed(2)),
|
||
});
|
||
|
||
const items = warehouses.map((base) => {
|
||
const id = String(base.wmsCoId);
|
||
const buckets = SALES_GRADE_RULES.map((grade) => (
|
||
buildBucket(grade, matrix.get(`${id}::${grade.key}`))
|
||
));
|
||
|
||
const totalSku = buckets.reduce((acc, b) => acc + b.skuCount, 0);
|
||
const totalStock = buckets.reduce((acc, b) => acc + b.stockQty, 0);
|
||
const totalSales = round2(buckets.reduce((acc, b) => acc + b.salesQty, 0));
|
||
|
||
return {
|
||
warehouse: base.warehouse,
|
||
dbWarehouse: base.dbWarehouse,
|
||
wmsCoId: base.wmsCoId,
|
||
hasData: totalSku > 0 || totalStock !== 0 || totalSales !== 0,
|
||
buckets: buckets.map((b) => ({
|
||
...b,
|
||
skuRatio: totalSku > 0 ? b.skuCount / totalSku : null,
|
||
stockRatio: totalStock !== 0 ? b.stockQty / totalStock : null,
|
||
})),
|
||
total: {
|
||
key: '__total__',
|
||
label: SALES_TOTAL_LABEL,
|
||
skuCount: totalSku,
|
||
skuRatio: totalSku > 0 ? 1 : null,
|
||
stockQty: totalStock,
|
||
stockRatio: totalStock !== 0 ? 1 : null,
|
||
salesQty: totalSales,
|
||
dailySales: Number((totalSales / days).toFixed(2)),
|
||
},
|
||
};
|
||
});
|
||
|
||
// 全仓合计:各等级跨仓累加 + 汇总
|
||
const grandBuckets = SALES_GRADE_RULES.map((grade) => {
|
||
const skuCount = items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.skuCount || 0), 0);
|
||
const stockQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.stockQty || 0), 0));
|
||
const salesQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.salesQty || 0), 0));
|
||
return {
|
||
key: grade.key,
|
||
label: grade.label,
|
||
tip: grade.tip,
|
||
skuCount,
|
||
stockQty,
|
||
salesQty,
|
||
dailySales: Number((salesQty / days).toFixed(2)),
|
||
};
|
||
});
|
||
const grandSku = grandBuckets.reduce((acc, b) => acc + b.skuCount, 0);
|
||
const grandStock = round2(grandBuckets.reduce((acc, b) => acc + b.stockQty, 0));
|
||
const grandSales = round2(grandBuckets.reduce((acc, b) => acc + b.salesQty, 0));
|
||
const grandTotal = {
|
||
key: '__total__',
|
||
label: SALES_TOTAL_LABEL,
|
||
skuCount: grandSku,
|
||
skuRatio: grandSku > 0 ? 1 : null,
|
||
stockQty: grandStock,
|
||
stockRatio: grandStock !== 0 ? 1 : null,
|
||
salesQty: grandSales,
|
||
dailySales: Number((grandSales / days).toFixed(2)),
|
||
};
|
||
|
||
const payload = {
|
||
days,
|
||
windowOptions: STOCK_SALES_WINDOW_OPTIONS,
|
||
latestBizDate,
|
||
asOf,
|
||
shipWindow: { shipFrom, shipTo, days },
|
||
takenAt,
|
||
grades: SALES_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
|
||
totalLabel: SALES_TOTAL_LABEL,
|
||
items,
|
||
grand: {
|
||
warehouse: '全部仓库合计',
|
||
hasData: grandSku > 0,
|
||
buckets: grandBuckets.map((b) => ({
|
||
...b,
|
||
skuRatio: grandSku > 0 ? b.skuCount / grandSku : null,
|
||
stockRatio: grandStock !== 0 ? b.stockQty / grandStock : null,
|
||
})),
|
||
total: grandTotal,
|
||
},
|
||
emptyWarehouseCount: items.filter((it) => !it.hasData).length,
|
||
};
|
||
|
||
cacheByDays.set(days, { latestBizDate, at: Date.now(), payload });
|
||
return res.json({ success: true, data: payload });
|
||
} catch (error) {
|
||
console.error('[stock-sales-analysis] 查询失败:', error.message);
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: '仓库库存动销分析数据查询失败',
|
||
detail: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 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;
|