数据补充(全部来自数据库,无人工填数): - 流水列接入 4/12:采购进仓(jst_purchase×Item,po_date,Confirmed/Finished)、 采购退货(jst_purchaseout×Item,io_date)、销售出仓(jst_Orders×OrderItem,send_date, 与周转率发货口径一致)、销售退货(jst_returnAndRefund×Item,as_date,实收 r_qty)。 其余 8 列业务库确无单据表(已核实全库表清单),保持「—」待 DBA 建表 - 期初快照缺失时自动兜底:优先用 rpt.pm_stockMonitor(报表库 01:00 跑批 T-1 库存, 按 dbWarehouse 聚合 jsStock,精确);监控表没有的虚拟仓(运营专用/京东仓)用 实时库存扣自期初时刻以来的带符号流水回推(近似),推导结果补写快照逐日滚动 缺陷修复: - 合计行仓库名显示 NaN:isTotal 渲染分支拦截了所有列,'合计' 被强转数字; 现仓库名原样显示,无数据来源的流水列合计也显示「—」而非误导性 0 - 合计行:某列全部仓库均无数据时合计置空,避免 0 误读 新增 4 项单元测试(流水符号约定、参数展开、期初双路径推导)。
364 lines
15 KiB
JavaScript
364 lines
15 KiB
JavaScript
const express = require('express');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const snapshotDb = require('../config/dailyInventorySnapshotDb');
|
||
const {
|
||
DAILY_INVENTORY_BASELINE,
|
||
FLOW_COLUMNS,
|
||
} = require('../config/dailyInventoryBaseline');
|
||
const { fetchFlows, AVAILABLE_FLOW_FIELDS } = require('../services/dailyInventoryFlows');
|
||
const { deriveAndStoreOpeningSnapshot } = require('../services/dailyInventoryOpening');
|
||
|
||
const router = express.Router();
|
||
|
||
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */
|
||
const AS_OF_TIME = '01:00';
|
||
|
||
/**
|
||
* 「月度」发货窗口天数:滚动 30 天。
|
||
* 库龄阈值:3 个月 = 90 天、12 个月 = 365 天(按日历天折算)。
|
||
*/
|
||
const SHIP_WINDOW_DAYS = 30;
|
||
const AGE_3M_DAYS = 90;
|
||
const AGE_12M_DAYS = 365;
|
||
|
||
/** 结果缓存:单次查询要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
|
||
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||
let cachedResult = null;
|
||
let cachedResultAt = 0;
|
||
|
||
const toNumber = (v) => {
|
||
if (v == null) return null;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
};
|
||
|
||
/** 快照日 + 次日 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 - 30 天, asOf),滚动一个月 */
|
||
function shipWindow(asOf) {
|
||
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
|
||
const from = new Date(to.getTime() - SHIP_WINDOW_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;
|
||
}
|
||
|
||
/** 上一自然日(日历天,用于取「期初 = 昨日结存」) */
|
||
function prevDay(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);
|
||
}
|
||
|
||
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
|
||
function buildInClause(values, prefix) {
|
||
const params = {};
|
||
const clause = values
|
||
.map((v, i) => {
|
||
const key = `${prefix}${i}`;
|
||
params[key] = v;
|
||
return `@${key}`;
|
||
})
|
||
.join(',');
|
||
return { clause, params };
|
||
}
|
||
|
||
/**
|
||
* 分级规则(库龄 + 周转率双维度),口径与业务给定一致:
|
||
* 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警
|
||
* 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流
|
||
* 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流
|
||
* 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞
|
||
* 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞
|
||
* 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸
|
||
* 边界:库龄正好 90 天不计入「慢流」(业务描述为 > 3 个月),正好 365 天仍属「3~12 个月」。
|
||
*/
|
||
function buildLevelExpr() {
|
||
const t = 'b.turnoverPct';
|
||
return `
|
||
CASE
|
||
WHEN b.stockQty <= 0 OR ${t} IS NULL OR b.ageDays IS NULL THEN NULL
|
||
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 200 THEN 'warn'
|
||
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 200 THEN 'slow'
|
||
WHEN b.ageDays > ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 100 THEN 'slow'
|
||
WHEN b.ageDays >= ${AGE_3M_DAYS} AND b.ageDays <= ${AGE_12M_DAYS} AND ${t} >= 100 THEN 'stale'
|
||
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 100 AND ${t} < 200 THEN 'stale'
|
||
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 200 THEN 'zombie'
|
||
ELSE NULL
|
||
END`;
|
||
}
|
||
|
||
/**
|
||
* 每个仓库的周转率与四级库存量(一次查询覆盖全部仓库)。
|
||
*
|
||
* 口径:
|
||
* 库存周转天数(周转率)= 月度发货总件数 / 月度日均库存件数 × 100%
|
||
* · 月度发货总件数 = 该仓近 30 天已发货订单件数(jst_OrderItem × jst_Orders,按 send_date,排除 Delete/Cancelled)
|
||
* · 月度日均库存件数 = 该仓聚水潭库存 jst_inventoryInfo(无历史结存表,以当日库存近似月度日均)
|
||
* 库龄(天) = DATEDIFF(基准时刻, SKU 建档时间 created)
|
||
*
|
||
* SKU 关联链路:jst_inventoryInfo.sku_id = jst_OrderItem.sku_id = pm_stockMonitor.sku_id(实测崔桥 1852/1852 命中)
|
||
*/
|
||
async function fetchGradeMetrics(wmsCoIds, bizDate, asOf) {
|
||
if (!wmsCoIds.length || !bizDate || !asOf) return new Map();
|
||
const invIn = buildInClause(wmsCoIds, 'w');
|
||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||
const { shipFrom, shipTo } = shipWindow(asOf);
|
||
const level = buildLevelExpr();
|
||
|
||
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
|
||
),
|
||
created AS (
|
||
SELECT m.sku_id AS skuId, MIN(m.created) AS created
|
||
FROM rpt.pm_stockMonitor m
|
||
WHERE CONVERT(VARCHAR(10), m.date, 23) = @bizDate
|
||
GROUP BY m.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 shipQty
|
||
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 v.wmsCoId,
|
||
v.skuId,
|
||
v.stockQty,
|
||
ISNULL(s.shipQty, 0) AS shipQty,
|
||
DATEDIFF(DAY, c.created, @asOf) AS ageDays,
|
||
CASE WHEN v.stockQty <= 0 THEN NULL
|
||
ELSE ISNULL(s.shipQty, 0) * 100.0 / v.stockQty END AS turnoverPct
|
||
FROM inv v
|
||
LEFT JOIN ship s ON s.wmsCoId = v.wmsCoId AND s.skuId = v.skuId
|
||
LEFT JOIN created c ON c.skuId = v.skuId
|
||
)
|
||
SELECT b.wmsCoId,
|
||
SUM(CASE WHEN ABS(b.stockQty) > 0.0001 THEN 1 ELSE 0 END) AS skuCount,
|
||
SUM(b.stockQty) AS totalStock,
|
||
SUM(b.shipQty) AS totalShip,
|
||
SUM(CASE WHEN b.ageDays IS NULL OR b.turnoverPct IS NULL OR b.stockQty <= 0 THEN 1 ELSE 0 END) AS ungradedSku,
|
||
CASE WHEN SUM(b.stockQty) > 0
|
||
THEN CAST(SUM(b.shipQty) * 100.0 / SUM(b.stockQty) AS DECIMAL(18, 2))
|
||
ELSE NULL END AS turnoverPct,
|
||
SUM(CASE WHEN ${level} = 'warn' THEN b.stockQty ELSE 0 END) AS warnLevel,
|
||
SUM(CASE WHEN ${level} = 'slow' THEN b.stockQty ELSE 0 END) AS slowLevel,
|
||
SUM(CASE WHEN ${level} = 'stale' THEN b.stockQty ELSE 0 END) AS staleLevel,
|
||
SUM(CASE WHEN ${level} = 'zombie' THEN b.stockQty ELSE 0 END) AS zombieLevel
|
||
FROM base b
|
||
GROUP BY b.wmsCoId`;
|
||
|
||
const rows = await cloudDb.query(sqlText, {
|
||
...invIn.params,
|
||
...shipIn.params,
|
||
bizDate,
|
||
asOf,
|
||
shipFrom,
|
||
shipTo,
|
||
}, { requestTimeout: 120000 });
|
||
|
||
const map = new Map();
|
||
rows.forEach((r) => {
|
||
map.set(String(r.wmsCoId), {
|
||
skuCount: toNumber(r.skuCount) || 0,
|
||
gradeStock: toNumber(r.totalStock) || 0,
|
||
shipQty: toNumber(r.totalShip) || 0,
|
||
turnoverDays: toNumber(r.turnoverPct),
|
||
warnLevel: toNumber(r.warnLevel) || 0,
|
||
slowLevel: toNumber(r.slowLevel) || 0,
|
||
staleLevel: toNumber(r.staleLevel) || 0,
|
||
zombieLevel: toNumber(r.zombieLevel) || 0,
|
||
ungradedSku: toNumber(r.ungradedSku) || 0,
|
||
});
|
||
});
|
||
return map;
|
||
}
|
||
|
||
/** 实时库存表的数据时效(一次取全局即可) */
|
||
async function fetchInventoryTakenAt() {
|
||
const rows = await cloudDb.query(
|
||
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
|
||
);
|
||
return rows[0]?.takenAt || null;
|
||
}
|
||
|
||
/**
|
||
* GET /api/daily-inventory
|
||
* 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量)
|
||
*
|
||
* 数据源分工(全部来自数据库,无硬编码业务数值):
|
||
* - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存;
|
||
* 快照缺失时按 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写
|
||
* - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓)
|
||
* - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期
|
||
* - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货,
|
||
* 见 services/dailyInventoryFlows.js),其余 8 列业务库无单据表,返回 null(前端显示「—」)
|
||
*/
|
||
router.get('/', async (req, res) => {
|
||
try {
|
||
const force = String(req.query?.refresh || '') === '1';
|
||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||
const cacheKey = latestBizDate || 'none';
|
||
if (!force && cachedResult && cachedResult.latestBizDate === cacheKey
|
||
&& Date.now() - cachedResultAt < RESULT_CACHE_TTL_MS) {
|
||
return res.json({ success: true, data: cachedResult, cached: true });
|
||
}
|
||
|
||
const asOf = nextDayAsOf(latestBizDate);
|
||
const openingDate = prevDay(latestBizDate);
|
||
const wmsCoIds = DAILY_INVENTORY_BASELINE
|
||
.map((b) => b.wmsCoId)
|
||
.filter((v) => v != null);
|
||
|
||
const [gradeMap, takenAt] = await Promise.all([
|
||
fetchGradeMetrics(wmsCoIds, latestBizDate, asOf).catch((e) => {
|
||
console.error('[daily-inventory] 分级计算失败:', e.message);
|
||
return new Map();
|
||
}),
|
||
fetchInventoryTakenAt().catch(() => null),
|
||
]);
|
||
|
||
// 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐
|
||
let openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map());
|
||
const todayMap = await snapshotDb.fetchSnapshot(latestBizDate).catch(() => new Map());
|
||
const missing = DAILY_INVENTORY_BASELINE
|
||
.filter((b) => b.wmsCoId != null && !todayMap.has(String(b.wmsCoId)))
|
||
.map((b) => {
|
||
const g = gradeMap.get(String(b.wmsCoId));
|
||
return {
|
||
wmsCoId: b.wmsCoId,
|
||
warehouse: b.warehouse,
|
||
dbWarehouse: b.dbWarehouse,
|
||
closingQty: Number(g?.gradeStock ?? 0),
|
||
skuCount: Number(g?.skuCount ?? 0),
|
||
takenAt: takenAt ? new Date(takenAt) : null,
|
||
};
|
||
});
|
||
if (missing.length) {
|
||
await snapshotDb.upsertSnapshot(latestBizDate, missing).catch((e) => {
|
||
console.error('[daily-inventory] 快照补写失败:', e.message);
|
||
});
|
||
}
|
||
|
||
// 期初快照也缺失时,用 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写
|
||
if (openingDate && !openingMap.size) {
|
||
await deriveAndStoreOpeningSnapshot({
|
||
cloudDb,
|
||
snapshotDb,
|
||
baseline: DAILY_INVENTORY_BASELINE,
|
||
openingDate,
|
||
latestBizDate,
|
||
takenAt,
|
||
}).catch((e) => {
|
||
console.error('[daily-inventory] 期初快照推导失败:', e.message);
|
||
});
|
||
openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map());
|
||
}
|
||
|
||
// 流水列:自期初时刻(最新业务日 01:00)以来的单据合计(带符号,流出为负)
|
||
let flowMap = new Map();
|
||
if (openingDate) {
|
||
flowMap = await fetchFlows(wmsCoIds, `${latestBizDate} 01:00:00`).catch((e) => {
|
||
console.error('[daily-inventory] 流水取数失败:', e.message);
|
||
return new Map();
|
||
});
|
||
}
|
||
|
||
const items = DAILY_INVENTORY_BASELINE.map((base) => {
|
||
// 12 个流水列:4 列从业务库单据表取数,其余 8 列无单据来源保持 null(前端渲染为「—」)
|
||
const flows = {};
|
||
for (const col of FLOW_COLUMNS) flows[col.field] = null;
|
||
const itemFlows = base.wmsCoId != null ? flowMap.get(String(base.wmsCoId)) : null;
|
||
if (itemFlows) {
|
||
for (const field of AVAILABLE_FLOW_FIELDS) {
|
||
if (itemFlows[field] != null) flows[field] = Number(itemFlows[field].toFixed(2));
|
||
}
|
||
}
|
||
|
||
const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null;
|
||
const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null;
|
||
|
||
return {
|
||
warehouse: base.warehouse,
|
||
dbWarehouse: base.dbWarehouse,
|
||
openingQty: opening ? opening.closingQty : null,
|
||
openingQtyExtra: null,
|
||
...flows,
|
||
closingQty: grade?.gradeStock ?? null,
|
||
closingSkuCount: grade?.skuCount ?? null,
|
||
closingTakenAt: takenAt,
|
||
shipQty30d: grade?.shipQty ?? null,
|
||
turnoverDays: grade?.turnoverDays ?? null,
|
||
gradeStock: grade?.gradeStock ?? null,
|
||
warnLevel: grade?.warnLevel ?? null,
|
||
slowLevel: grade?.slowLevel ?? null,
|
||
staleLevel: grade?.staleLevel ?? null,
|
||
zombieLevel: grade?.zombieLevel ?? null,
|
||
ungradedSku: grade?.ungradedSku ?? null,
|
||
hasGrade: Boolean(grade),
|
||
};
|
||
});
|
||
|
||
// 合计行:便于与表格总额核对
|
||
const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0);
|
||
const totals = { warehouse: '合计', dbWarehouse: '', isTotal: true };
|
||
for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) {
|
||
// 若所有仓库该列均无数据(null),合计同样置空,避免误读为 0
|
||
totals[k] = items.every((it) => it[k] == null) ? null : Number(sum(k).toFixed(2));
|
||
}
|
||
// 合计行周转率按「总发货 / 总库存」重算,避免简单累加失真
|
||
totals.shipQty30d = Number(sum('shipQty30d').toFixed(2));
|
||
const totalStock = sum('closingQty');
|
||
totals.turnoverDays = totalStock > 0
|
||
? Number((sum('shipQty30d') * 100 / totalStock).toFixed(2))
|
||
: null;
|
||
|
||
const noOpeningCount = items.filter((it) => it.openingQty == null).length;
|
||
|
||
const payload = {
|
||
openingDate,
|
||
latestBizDate,
|
||
asOf,
|
||
shipWindow: asOf ? { ...shipWindow(asOf), days: SHIP_WINDOW_DAYS } : null,
|
||
flowColumns: FLOW_COLUMNS,
|
||
flowColumnsAvailable: AVAILABLE_FLOW_FIELDS,
|
||
noOpeningCount,
|
||
items,
|
||
totals,
|
||
};
|
||
cachedResult = payload;
|
||
cachedResultAt = Date.now();
|
||
return res.json({ success: true, data: payload });
|
||
} catch (error) {
|
||
console.error('[daily-inventory] 查询失败:', error.message);
|
||
return res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|