cangku/backend/src/api/pickingBinSalesAnalysis.js
youruo 99fa402092 feat: 拣选库位动销分析固定只统计拣选位,移除仓位类型切换
- 取数固定只取拣选位:bin CTE 直接过滤 bin_group 为空(更早过滤,存货位不再进入后续 CTE)
  移除可变的 binType IN 过滤与 resolveBinTypes,binType 字段恒为拣选
- 接口 payload 的 binTypes / binTypeOptions 固定为 ['拣选'];缓存键不再含类型
- 列表与导出移除「类型」列(全为拣选,冗余);页面移除类型多选器、底部文案改为「统计范围:仅拣选位」
- 口径说明补充统计范围;Tooltip 同步

验证(基准日 2026-10-08、30 天):
- 列表 11871 行 / 6821 个去重商品 / 库存 499141 / 去重商品销量 96847,明细类型集合仅为 ['拣选']
- 导出 11871 行,表头无「类型」列;分摊销量合计 96847 = 去重商品销量合计 96847(差异 -0.0017 浮点舍入)
- 旧参数 binType=存货 被忽略(仍返回拣选且命中同一缓存);7 / 60 天窗口与单仓过滤均正常
- 单测 9/9 通过;后端语法检查 182 文件通过;前端 eslint 无报错、vite build 成功
2026-10-08 13:09:49 +08:00

282 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.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;