forked from lunanting/cangku
上一版为去掉冗余把「类型」整列删了,用户要求的报表列结构中该列仍在, 现恢复:导出与列表均保留「类型」列,值恒为「拣选」(存货位不参与统计,报表中不会出现) 验证(基准日 2026-10-08、30 天): - 导出表头为 仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / 仓位SKU数 / 商品销量 / 分摊销量 / 商品日销 / 动销等级 / 统计天数 / 统计基准日 - 11897 行,类型列取值集合仅 ['拣选'],空值 0 行;分摊销量合计 98087 = 去重商品销量合计 98087 - 列表接口 binTypes=['拣选'],明细类型集合仅 ['拣选'] - 单测 9/9 通过;语法检查 182 文件通过;eslint 无报错、vite build 成功
283 lines
10 KiB
JavaScript
283 lines
10 KiB
JavaScript
const express = require('express');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
|
||
const {
|
||
PICKING_BIN_GRADE_RULES,
|
||
PICKING_BIN_WINDOW_OPTIONS,
|
||
PICKING_BIN_DEFAULT_DAYS,
|
||
PICKING_BIN_TOTAL_LABEL,
|
||
PICKING_BIN_BIN_TYPES,
|
||
} = require('../config/pickingBinSalesAnalysis');
|
||
const { fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
|
||
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 cacheByKey = new Map();
|
||
|
||
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
|
||
const round4 = (v) => Math.round((Number(v) || 0) * 10000) / 10000;
|
||
|
||
/** 快照日 + 次日 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 fetchBinTakenAt() {
|
||
const rows = await cloudDb.query(
|
||
`SELECT MAX(updateTime) AS takenAt FROM rpt.jst_packInventoryInfo`
|
||
);
|
||
return rows[0]?.takenAt || null;
|
||
}
|
||
|
||
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
|
||
function resolveDays(raw) {
|
||
const n = Number.parseInt(String(raw ?? ''), 10);
|
||
return PICKING_BIN_WINDOW_OPTIONS.includes(n) ? n : PICKING_BIN_DEFAULT_DAYS;
|
||
}
|
||
|
||
/** 解析仓库过滤,只允许出现在仓库清单里的 wmsCoId,防止任意拼参 */
|
||
function resolveWarehouses(raw) {
|
||
const known = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
|
||
const requested = String(raw || '')
|
||
.split(',')
|
||
.map((v) => v.trim())
|
||
.filter(Boolean);
|
||
const picked = requested.length > 0
|
||
? known.filter((b) => requested.includes(String(b.wmsCoId)))
|
||
: known;
|
||
return picked;
|
||
}
|
||
|
||
const GRADE_MAP = new Map(PICKING_BIN_GRADE_RULES.map((g) => [g.key, g]));
|
||
|
||
/** 明细行按「仓库 × 商品编码」去重后的商品个数 */
|
||
function dedupeCount(list) {
|
||
return new Set(list.map((it) => `${it.wmsCoId}::${it.skuId}`)).size;
|
||
}
|
||
|
||
/**
|
||
* GET /api/picking-bin-sales-analysis
|
||
* 拣选库位动销分析:仓库 × 仓位 × 商品编码 → 库存数量 / 商品销量 / 商品日销 / 动销等级
|
||
* 动销等级按「商品日销」判定(商品整档销量 ÷ 天数),不做仓位分摊。
|
||
*/
|
||
router.get('/', async (req, res) => {
|
||
try {
|
||
const force = String(req.query?.refresh || '') === '1';
|
||
const days = resolveDays(req.query?.days);
|
||
const warehouses = resolveWarehouses(req.query?.wmsCoId);
|
||
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
|
||
|
||
const cacheKey = `${days}::${wmsCoIds.join('|')}`;
|
||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||
const cached = cacheByKey.get(cacheKey);
|
||
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 [rows, takenAt] = await Promise.all([
|
||
fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
|
||
fetchBinTakenAt().catch(() => null),
|
||
]);
|
||
|
||
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
|
||
const items = rows.map((r) => ({
|
||
wmsCoId: r.wmsCoId,
|
||
binName: r.binName,
|
||
binType: r.binType,
|
||
skuId: r.skuId,
|
||
warehouse: whMap.get(r.wmsCoId) || r.wmsCoId,
|
||
stockQty: r.stockQty,
|
||
skuCount: r.skuCount,
|
||
salesQty: round2(r.salesQty),
|
||
dailySales: round4(r.dailySales),
|
||
gradeKey: r.gradeKey,
|
||
gradeLabel: GRADE_MAP.get(r.gradeKey)?.label || '',
|
||
}));
|
||
|
||
// 每个「仓库 × 商品」只计一次销量,避免同一商品跨多个仓位被重复累加
|
||
const skuSalesMap = new Map();
|
||
rows.forEach((r) => {
|
||
const key = `${r.wmsCoId}::${r.skuId}`;
|
||
if (!skuSalesMap.has(key)) skuSalesMap.set(key, r.salesQty);
|
||
});
|
||
const dedupeSales = (list) => {
|
||
const seen = new Set();
|
||
return list.reduce((acc, it) => {
|
||
const key = `${it.wmsCoId}::${it.skuId}`;
|
||
if (seen.has(key)) return acc;
|
||
seen.add(key);
|
||
return acc + (skuSalesMap.get(key) || 0);
|
||
}, 0);
|
||
};
|
||
|
||
// 各动销等级汇总:商品数按「仓库 × 商品」去重,库存按明细行求和(无重复)
|
||
const summary = PICKING_BIN_GRADE_RULES.map((grade) => {
|
||
const hit = items.filter((it) => it.gradeKey === grade.key);
|
||
const salesQty = round2(dedupeSales(hit));
|
||
return {
|
||
key: grade.key,
|
||
label: grade.label,
|
||
tip: grade.tip,
|
||
rowCount: hit.length,
|
||
skuCount: dedupeCount(hit),
|
||
stockQty: round2(hit.reduce((acc, it) => acc + it.stockQty, 0)),
|
||
salesQty,
|
||
dailySales: round4(salesQty / days),
|
||
};
|
||
});
|
||
|
||
const dedupeAll = new Set(items.map((it) => `${it.wmsCoId}::${it.skuId}`));
|
||
const totalSales = round2(dedupeSales(items));
|
||
const total = {
|
||
key: '__total__',
|
||
label: PICKING_BIN_TOTAL_LABEL,
|
||
rowCount: items.length,
|
||
skuCount: dedupeAll.size,
|
||
stockQty: round2(summary.reduce((acc, s) => acc + s.stockQty, 0)),
|
||
salesQty: totalSales,
|
||
dailySales: round4(totalSales / days),
|
||
};
|
||
|
||
const payload = {
|
||
days,
|
||
windowOptions: PICKING_BIN_WINDOW_OPTIONS,
|
||
binTypes: [...PICKING_BIN_BIN_TYPES],
|
||
binTypeOptions: [...PICKING_BIN_BIN_TYPES],
|
||
latestBizDate,
|
||
asOf,
|
||
shipWindow: { shipFrom, shipTo, days },
|
||
takenAt,
|
||
grades: PICKING_BIN_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
|
||
totalLabel: PICKING_BIN_TOTAL_LABEL,
|
||
warehouses: warehouses.map((b) => ({ wmsCoId: String(b.wmsCoId), warehouse: b.warehouse })),
|
||
items,
|
||
summary,
|
||
total,
|
||
};
|
||
|
||
cacheByKey.set(cacheKey, { latestBizDate, at: Date.now(), payload });
|
||
return res.json({ success: true, data: payload });
|
||
} catch (error) {
|
||
console.error('[picking-bin-sales-analysis] 查询失败:', error.message);
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: '拣选库位动销分析数据查询失败',
|
||
detail: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
/**
|
||
* GET /api/picking-bin-sales-analysis/export
|
||
* 导出库位明细(只含拣选位)。query: days、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv)
|
||
*/
|
||
router.get('/export', async (req, res) => {
|
||
try {
|
||
const days = resolveDays(req.query?.days);
|
||
const warehouses = resolveWarehouses(req.query?.wmsCoId);
|
||
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
|
||
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||
|
||
const grades = String(req.query?.grade || '')
|
||
.split(',')
|
||
.map((v) => v.trim())
|
||
.filter((v) => GRADE_MAP.has(v));
|
||
|
||
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);
|
||
// 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行
|
||
const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
|
||
|
||
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
|
||
const filtered = grades.length > 0
|
||
? rows.filter((r) => grades.includes(r.gradeKey))
|
||
: rows;
|
||
|
||
const sheetRows = filtered.map((r) => [
|
||
whMap.get(r.wmsCoId) || r.wmsCoId,
|
||
r.binName,
|
||
r.binType,
|
||
r.skuId,
|
||
r.stockQty,
|
||
r.skuCount,
|
||
round2(r.salesQty),
|
||
round4(r.allocatedSalesQty),
|
||
round4(r.dailySales),
|
||
GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey,
|
||
days,
|
||
latestBizDate || '',
|
||
]);
|
||
|
||
const fileName = `拣选库位动销分析-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
|
||
const { totalRows } = sendWorkbookDownload(res, {
|
||
fileName,
|
||
format,
|
||
sheets: [{
|
||
name: '库位动销明细',
|
||
header: [
|
||
'仓库', '仓位', '类型', '商品编码', '库存数量', '仓位SKU数',
|
||
`商品销量(近${days}天)`, `分摊销量(近${days}天)`, '商品日销',
|
||
'动销等级', '统计天数', '统计基准日',
|
||
],
|
||
rows: sheetRows,
|
||
}],
|
||
});
|
||
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 仅拣选位, 窗口 ${days} 天`);
|
||
return null;
|
||
} catch (error) {
|
||
console.error('[picking-bin-sales-analysis] 导出失败:', error.message);
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: '拣选库位动销分析导出失败',
|
||
detail: error.message,
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|