180 lines
6.6 KiB
JavaScript
180 lines
6.6 KiB
JavaScript
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;
|