forked from lunanting/cangku
- 期初数量改由本地快照表 daily_inventory_snapshot 提供(上一业务日期末), 每日 01:30 定时任务落库,接口侧缺失时补写兜底,逐日自动滚动 - 新增 dailyInventorySnapshotDb / dailyInventorySnapshotJob 与手动补跑脚本 - dailyInventoryBaseline 瘦身为纯仓库映射,去掉所有硬编码业务数值 - 12 个流水列因业务库缺单据表返回空值,前端灰底显示「—」 - 修复 qty 全为 0 的仓库期末显示 null 的缺陷 - 新增 docs/库存流水单据表需求.md 给 DBA
329 lines
13 KiB
JavaScript
329 lines
13 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 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 中「上一业务日」的期末结存
|
||
* - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓)
|
||
* - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期
|
||
* - 12 个流水列:业务库缺调拨/盘点/加工/其它出入库单据表,返回 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),
|
||
]);
|
||
|
||
// 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐
|
||
const 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);
|
||
});
|
||
}
|
||
|
||
const items = DAILY_INVENTORY_BASELINE.map((base) => {
|
||
// 12 个流水列:业务库无单据来源,一律 null(前端渲染为「—」)
|
||
const flows = {};
|
||
for (const col of FLOW_COLUMNS) flows[col.field] = null;
|
||
|
||
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']) {
|
||
totals[k] = 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: false,
|
||
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;
|