feat: 新增每日进销存(大货库存监控)
This commit is contained in:
parent
700d7ae96d
commit
f6b755aac4
179
backend/src/api/dailyInventory.js
Normal file
179
backend/src/api/dailyInventory.js
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user