feat: 新增每日进销存(大货库存监控)

This commit is contained in:
youruo 2026-09-23 17:37:41 +08:00
parent 700d7ae96d
commit f6b755aac4

View File

@ -0,0 +1,179 @@
const express = require('express');
const cloudDb = require('../config/cloudDatabase');
const {
DAILY_INVENTORY_BASELINE,
BASELINE_DATE,
FLOW_COLUMNS,
} = require('../config/dailyInventoryBaseline');
const router = express.Router();
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */
const AS_OF_TIME = '01:00';
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}`;
}
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;
}
/**
* 派生列(SQL CASE WHEN 下沉计算)
* - 库龄(天) = DATEDIFF(asOf, created)
* - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%
* 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock
* - 四级分级:库龄 + 周转率双维度
*/
function buildDerivedColumns(asOf) {
const A = '@asOf';
void A;
const ageDays = `DATEDIFF(DAY, m.created, @asOf)`;
const turnover = `(ISNULL(m.threeAvgSale, 0) * 30.0 * 100.0 / NULLIF(m.jsStock, 0))`;
return {
ageDays,
turnoverDays: `CAST(${turnover} AS DECIMAL(18, 4))`,
levelExpr: `
CASE
WHEN m.created IS NULL OR ISNULL(m.jsStock, 0) = 0 OR ${turnover} IS NULL THEN NULL
WHEN ${ageDays} < 90 AND ${turnover} BETWEEN 30 AND 199 THEN 'warn'
WHEN ${ageDays} < 90 AND ${turnover} >= 200 THEN 'slow'
WHEN ${ageDays} >= 90 AND ${turnover} BETWEEN 30 AND 99 THEN 'slow'
WHEN ${ageDays} BETWEEN 90 AND 365 AND ${turnover} >= 100 THEN 'stale'
WHEN ${ageDays} > 365 AND ${turnover} BETWEEN 100 AND 199 THEN 'stale'
WHEN ${ageDays} > 365 AND ${turnover} >= 200 THEN 'zombie'
ELSE NULL
END`,
};
}
/** 该仓快照里的四级库存量与平均周转天数 */
async function fetchGradeMetrics(dbWarehouse, bizDate, asOf) {
const D = buildDerivedColumns(asOf);
const rows = await cloudDb.query(
`SELECT COUNT(*) AS skuCount,
SUM(CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2))) AS gradeStock,
AVG(${D.turnoverDays}) AS avgTurnover,
SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel,
SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel,
SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel,
SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel
FROM rpt.pm_stockMonitor m
WHERE m.warehouse = @warehouse
AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`,
{ warehouse: dbWarehouse, bizDate, asOf }
);
const r = rows[0] || {};
if (!Number(r.skuCount || 0)) return null;
return {
skuCount: toNumber(r.skuCount),
gradeStock: toNumber(r.gradeStock),
turnoverDays: toNumber(r.avgTurnover),
warnLevel: toNumber(r.warnLevel),
slowLevel: toNumber(r.slowLevel),
staleLevel: toNumber(r.staleLevel),
zombieLevel: toNumber(r.zombieLevel),
};
}
/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */
async function fetchClosingQty(wmsCoId) {
if (wmsCoId == null) return null;
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS qty,
COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount,
MAX(modified) AS takenAt
FROM rpt.jst_inventoryInfo
WHERE wms_co_id = @wmsCoId`,
{ wmsCoId }
);
const r = rows[0] || {};
return {
closingQty: toNumber(r.qty),
closingSkuCount: toNumber(r.skuCount) || 0,
closingTakenAt: r.takenAt || null,
};
}
/**
* GET /api/daily-inventory
* 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量
*
* 数据源分工:
* - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js)
* - 期末数量:jst_inventoryInfo 实时库存
* - 库存周转天数 / 四级库存量:pm_stockMonitor 快照
*/
router.get('/', async (req, res) => {
try {
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const asOf = nextDayAsOf(latestBizDate);
const items = [];
for (const base of DAILY_INVENTORY_BASELINE) {
const flows = {};
for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0;
const closing = await fetchClosingQty(base.wmsCoId).catch(() => null);
const grade = base.dbWarehouse && latestBizDate
? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null)
: null;
items.push({
warehouse: base.warehouse,
dbWarehouse: base.dbWarehouse,
openingQty: base.openingQty,
openingQtyExtra: base.openingQtyExtra,
...flows,
closingQty: closing?.closingQty ?? null,
closingSkuCount: closing?.closingSkuCount ?? null,
closingTakenAt: closing?.closingTakenAt ?? 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,
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));
}
res.json({
success: true,
data: {
baselineDate: BASELINE_DATE,
latestBizDate,
asOf,
flowColumns: FLOW_COLUMNS,
items,
totals,
},
});
} catch (error) {
console.error('[daily-inventory] 查询失败:', error.message);
res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
}
});
module.exports = router;