按「库存监控」表格在侧边栏增加一级/二级菜单与共用页面 (路由 /planning/large-stock-monitor/:warehouseSlug)。 后端: - 新增 largeStockMonitorConfig.js:26 仓配置,含 wms_co_id / 快照仓名 / routeKey / slug 映射 - 新增 largeStockMonitor.js:config / overview / sku-list 三个接口 - 新增 largeStockMonitorFlow.js:采购进仓、采购退货、销售出仓、销售退货四类单据流水取数 - 新增 verify-large-stock-monitor.js:26 仓端到端校验脚本 - index.js:挂载 /api/large-stock-monitor 路由 口径说明: - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%,下沉到 SQL CASE WHEN 计算 - 四级分级(滞销预警/慢流/呆滞/僵尸)按库龄 + 周转率双维度判定 - 期末数量取聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓), 不用 pm_stockMonitor.jsStock —— 后者 warehouse 是「归属仓」口径, 会把已调拨到外仓的货仍计在本仓名下(崔桥工厂仓因此虚高约 2.2 万) - 截止时刻统一为快照日次日 01:00(跑批时点),库龄/分级均以此为基准,不再用 GETDATE() 前端: - 新增 LargeStockMonitor 页面、18 列定义、API 封装与样式 - 路由与侧边栏:26 条二级菜单 + 权限点 冲突处理: - 主仓库已下线 /api/dashboard(backend/src/api/dashboard.js 被移除), 相应删除 dashboardRoutes 的 require 与挂载,避免后端启动时 require 失败崩溃 - 保留主仓库新增的「模块化库存监控」菜单组及其子项
515 lines
22 KiB
JavaScript
515 lines
22 KiB
JavaScript
// 大货库存监控 - 后端路由
|
||
// 业务数据源:rpt.pm_stockMonitor(MSSQL,业务库 JSReportReplica)
|
||
// rpt.jst_*(聚水潭单据/库存表,用于出入库流水)
|
||
// 配置源:C:/Users/.../Desktop/库存监控.xlsx 中的 26 行仓库配置
|
||
//
|
||
// 计算口径(用户给定):
|
||
// 月度发货总件数 ≈ threeAvgSale × 30 (业务库无直接月度发货字段,按 3 天日均销量 × 30 估算)
|
||
// 月度日均库存件数 ≈ jsStock (业务库只有当前日快照,作为日均近似)
|
||
// 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%
|
||
// 注:按公式实际为周转率(%),列名沿用 Excel 表头"库存周转天数"
|
||
// 库龄(月) = DATEDIFF(MONTH, created, GETDATE())
|
||
//
|
||
// 分级口径(用户给定,按"库龄 + 周转天数(%)"两个维度):
|
||
// 滞销预警 warn 库龄 < 3 个月 且 30 ≤ 周转天数 ≤ 199
|
||
// 商品慢流 slow 库龄 < 3 个月 且 周转天数 > 200
|
||
// 或 库龄 > 3 个月 且 30 ≤ 周转天数 ≤ 99
|
||
// 商品呆滞 stale 库龄 3~12 个月 且 周转天数 ≥ 100
|
||
// 或 库龄 > 12 个月 且 100 ≤ 周转天数 ≤ 199
|
||
// 商品僵尸 zombie 库龄 > 12 个月 且 周转天数 ≥ 200
|
||
const express = require('express');
|
||
const authMiddleware = require('../middleware/auth');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const { LARGE_STOCK_MONITOR_WAREHOUSES } = require('../config/largeStockMonitorConfig');
|
||
const { fetchFlowSummary, fetchInventoryQty } = require('../services/largeStockMonitorFlow');
|
||
|
||
const router = express.Router();
|
||
|
||
const LEVEL_LABELS = {
|
||
warn: '滞销预警',
|
||
slow: '商品慢流',
|
||
stale: '商品呆滞',
|
||
zombie: '商品僵尸',
|
||
};
|
||
|
||
// 18 列表头(与库存监控.xlsx 第 1 行的 D..U 列对应)
|
||
// available=false 表示业务库无对应数据源(加工/盘点/调拨/其它出库),前端显示「—」
|
||
const COLUMN_HEADERS = [
|
||
{ key: 'periodStart', label: '期初数据', available: true, estimated: true },
|
||
{ key: 'processIn', label: '加工进仓', available: false },
|
||
{ key: 'purchaseReturn', label: '采购退货', available: true },
|
||
{ key: 'purchaseIn', label: '采购进仓', available: true },
|
||
{ key: 'salesOut', label: '销售出仓', available: true },
|
||
{ key: 'stocktake', label: '盘点', available: false },
|
||
{ key: 'processOut', label: '加工出仓', available: false },
|
||
{ key: 'transferOut', label: '调拨出', available: false },
|
||
{ key: 'salesReturn', label: '销售退货', available: true },
|
||
{ key: 'transferIn', label: '调拨入', available: false },
|
||
{ key: 'openingBalance', label: '期初', available: true, estimated: true },
|
||
{ key: 'otherOut', label: '其它出库', available: false },
|
||
{ key: 'closingQty', label: '期末数量', available: true },
|
||
{ key: 'turnoverDays', label: '库存周转天数', available: true },
|
||
{ key: 'warnLevel', label: '滞销预警级库存量', available: true },
|
||
{ key: 'slowLevel', label: '商品慢流级库存量', available: true },
|
||
{ key: 'staleLevel', label: '商品呆滞级库存量', available: true },
|
||
{ key: 'zombieLevel', label: '商品僵尸级库存量', available: true },
|
||
];
|
||
|
||
// 在 SELECT/WHERE 中复用:库龄月数、月度发货、月度日均库存、周转天数(%)、分级
|
||
// 关键:所有算式都做 NULL/0 兜底,避免分母为 0 导致 NULL 污染整列
|
||
// 时间基准统一使用 @asOf(数据截止时刻,默认快照日次日 01:00),不再用 GETDATE(),
|
||
// 保证同一批报表内库龄/分级与流水口径完全一致。
|
||
function buildDerivedColumns(alias = 'm') {
|
||
const a = alias ? `${alias}.` : '';
|
||
const T = 'CONVERT(DATETIME, @asOf, 120)';
|
||
return {
|
||
ageMonths: `CASE WHEN ${a}created IS NULL THEN NULL ELSE CAST(DATEDIFF(DAY, ${a}created, ${T}) AS DECIMAL(18, 4)) / 30.0 END`,
|
||
monthlyShipment: `(ISNULL(${a}threeAvgSale, 0) * 30.0)`,
|
||
avgDailyStock: `CAST(ISNULL(${a}jsStock, 0) AS DECIMAL(18, 4))`,
|
||
turnoverDays: `CASE WHEN ISNULL(${a}jsStock, 0) = 0 THEN NULL ELSE CAST(ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock AS DECIMAL(18, 4)) END`,
|
||
levelExpr: `
|
||
CASE
|
||
WHEN ${a}created IS NULL OR ISNULL(${a}jsStock, 0) = 0 OR ISNULL(${a}threeAvgSale, 0) * 30.0 / NULLIF(${a}jsStock, 0) IS NULL THEN NULL
|
||
WHEN DATEDIFF(DAY, ${a}created, ${T}) < 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 30 AND 199 THEN 'warn'
|
||
WHEN DATEDIFF(DAY, ${a}created, ${T}) < 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 200 THEN 'slow'
|
||
WHEN DATEDIFF(DAY, ${a}created, ${T}) >= 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 30 AND 99 THEN 'slow'
|
||
WHEN DATEDIFF(DAY, ${a}created, ${T}) BETWEEN 90 AND 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 100 THEN 'stale'
|
||
WHEN DATEDIFF(DAY, ${a}created, ${T}) > 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 100 AND 199 THEN 'stale'
|
||
WHEN DATEDIFF(DAY, ${a}created, ${T}) > 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 200 THEN 'zombie'
|
||
ELSE NULL
|
||
END
|
||
`,
|
||
};
|
||
}
|
||
|
||
function resolveWarehouse(warehouse) {
|
||
const target = String(warehouse || '').trim();
|
||
if (!target) return null;
|
||
return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.warehouse === target) || null;
|
||
}
|
||
|
||
function buildPlaceholderRow() {
|
||
return COLUMN_HEADERS.reduce((acc, column) => {
|
||
acc[column.key] = '—';
|
||
return acc;
|
||
}, {});
|
||
}
|
||
|
||
// 默认跟随业务库最新快照日期;可通过 ?date=YYYY-MM-DD 指定某一天
|
||
const DEFAULT_TARGET_DATE = null;
|
||
|
||
// 数据截止时刻:快照日次日的凌晨 01:00。
|
||
// 业务库每天凌晨 1 点跑批生成快照,标注为前一日(T-1)的库存,
|
||
// 因此「9 月 23 日凌晨 1 点的数据」= 快照日 2026-09-22 + 截止时刻 2026-09-23 01:00。
|
||
const AS_OF_TIME = '01:00';
|
||
|
||
// 流水统计区间:快照所在自然月的 1 日 00:00 ~ 快照日次日 01:00(左闭右开)
|
||
// 即完整覆盖快照日全天,并包含跑批当日凌晨 0 点~1 点的单据
|
||
function buildPeriodRange(bizDate) {
|
||
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
|
||
const [y, mo] = bizDate.split('-');
|
||
const start = `${y}-${mo}-01 00:00`;
|
||
const d = new Date(`${bizDate}T00:00:00Z`);
|
||
d.setUTCDate(d.getUTCDate() + 1);
|
||
const endDay = d.toISOString().slice(0, 10);
|
||
const end = `${endDay} ${AS_OF_TIME}`;
|
||
return { start, end, endDay, asOf: end };
|
||
}
|
||
|
||
// 允许 ?asOf=YYYY-MM-DD HH:mm 手工覆盖截止时刻(排查/补数用)
|
||
function normalizeAsOf(value) {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) return null;
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return `${raw} ${AS_OF_TIME}`;
|
||
const m = raw.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||
if (m) return `${m[1]} ${m[2]}`;
|
||
return null;
|
||
}
|
||
|
||
const pad2 = (n) => String(n).padStart(2, '0');
|
||
|
||
function localDateString(d) {
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||
}
|
||
|
||
function localDateTimeString(d) {
|
||
return `${localDateString(d)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||
}
|
||
|
||
// 无库存快照的仓库(走实时库存兜底)没有「快照日次日」可用,截止时刻取今天凌晨 1 点;
|
||
// 若当前还没到凌晨 1 点(跑批尚未完成),则退化为当前时刻。
|
||
function fallbackAsOf() {
|
||
const now = new Date();
|
||
const hourFloor = Number(AS_OF_TIME.split(':')[0]);
|
||
if (now.getHours() >= hourFloor) return `${localDateString(now)} ${AS_OF_TIME}`;
|
||
return localDateTimeString(now);
|
||
}
|
||
|
||
async function fetchTargetBizDate(dbWarehouse, preferredDate) {
|
||
if (!dbWarehouse) return null;
|
||
const rows = await cloudDb.query(
|
||
`SELECT TOP 1 CONVERT(VARCHAR(10), date, 23) AS d
|
||
FROM rpt.pm_stockMonitor
|
||
WHERE warehouse = @warehouse AND date IS NOT NULL
|
||
AND (@preferredDate IS NULL OR CONVERT(VARCHAR(10), date, 23) <= @preferredDate)
|
||
ORDER BY date DESC`,
|
||
{ warehouse: dbWarehouse, preferredDate: preferredDate || null }
|
||
);
|
||
return rows[0]?.d || null;
|
||
}
|
||
|
||
// 期初倒推:期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货
|
||
// 业务库 pm_stockMonitor 仅保留最新单日快照(无上月末历史),且加工/调拨/盘点/其它出库无数据源,
|
||
// 因此期初只能由已知流水倒推,属估算值(前端标注「估」)。
|
||
// 倒推出现负数说明该仓库本期存在未采集到的出库(调拨/加工/其它出库),此时不再展示估算值,
|
||
// 避免出现「负库存」这类明显失真的数字。
|
||
function estimateOpeningBalance(closingQty, flow) {
|
||
if (closingQty == null) return null;
|
||
const purchaseIn = flow?.purchaseIn ?? 0;
|
||
const purchaseReturn = flow?.purchaseReturn ?? 0;
|
||
const salesOut = flow?.salesOut ?? 0;
|
||
const salesReturn = flow?.salesReturn ?? 0;
|
||
const value = closingQty - purchaseIn + purchaseReturn + salesOut - salesReturn;
|
||
return value >= 0 ? value : null;
|
||
}
|
||
|
||
router.get('/config', authMiddleware, async (_req, res) => {
|
||
try {
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
columns: COLUMN_HEADERS,
|
||
levelLabels: LEVEL_LABELS,
|
||
warehouses: LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({
|
||
warehouse: entry.warehouse,
|
||
group: entry.group,
|
||
routeKey: entry.routeKey,
|
||
hasData: Boolean(entry.dbWarehouse),
|
||
hasWms: Boolean(entry.wmsCoId),
|
||
})),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[LargeStockMonitor] 获取配置失败:', error);
|
||
res.status(500).json({ success: false, error: error.message || '获取配置失败' });
|
||
}
|
||
});
|
||
|
||
router.get('/overview', authMiddleware, async (req, res) => {
|
||
try {
|
||
const config = resolveWarehouse(req.query.warehouse);
|
||
if (!config) {
|
||
return res.status(404).json({ success: false, error: '未知的仓库配置' });
|
||
}
|
||
|
||
const requestedDate = String(req.query.date || '').trim() || null;
|
||
if (requestedDate && !/^\d{4}-\d{2}-\d{2}$/.test(requestedDate)) {
|
||
return res.status(400).json({ success: false, error: 'date 参数格式应为 YYYY-MM-DD' });
|
||
}
|
||
|
||
const dbWarehouse = config.dbWarehouse;
|
||
const wmsCoId = config.wmsCoId;
|
||
const overview = buildPlaceholderRow();
|
||
const baseResponse = {
|
||
warehouse: config.warehouse,
|
||
group: config.group,
|
||
routeKey: config.routeKey,
|
||
requestedDate,
|
||
latestBizDate: null,
|
||
fallbackDate: null,
|
||
asOf: null,
|
||
period: null,
|
||
source: null,
|
||
hasData: false,
|
||
skuCount: 0,
|
||
overview,
|
||
};
|
||
|
||
// 1) 快照日期:优先 pm_stockMonitor;无该仓库时退回聚水潭实时库存(无日期概念)
|
||
let latestBizDate = dbWarehouse
|
||
? await fetchTargetBizDate(dbWarehouse, requestedDate).catch(() => null)
|
||
: null;
|
||
const fallbackDate = requestedDate && latestBizDate && latestBizDate !== requestedDate ? latestBizDate : null;
|
||
const period = buildPeriodRange(latestBizDate);
|
||
// 数据截止时刻:默认快照日次日 01:00(如 2026-09-23 01:00),可用 ?asOf= 覆盖
|
||
const asOf = normalizeAsOf(req.query.asOf) || period?.asOf || fallbackAsOf();
|
||
|
||
// 2) 出入库流水(本月 1 日 ~ 截止时刻);pm_stockMonitor 无该仓库时用当天所在月
|
||
const today = localDateString(new Date());
|
||
const periodForFlow = period
|
||
? { ...period, end: asOf || period.end }
|
||
: { start: buildPeriodRange(today)?.start, end: asOf };
|
||
const flow = await fetchFlowSummary({
|
||
wmsCoId,
|
||
dbWarehouse: dbWarehouse || config.warehouse.replace(/主仓$/, ''),
|
||
periodStart: periodForFlow?.start,
|
||
periodEnd: periodForFlow?.end,
|
||
}).catch(() => ({ purchaseIn: null, purchaseReturn: null, salesOut: null, salesReturn: null }));
|
||
|
||
if (flow.purchaseReturn != null) overview.purchaseReturn = flow.purchaseReturn;
|
||
if (flow.purchaseIn != null) overview.purchaseIn = flow.purchaseIn;
|
||
if (flow.salesOut != null) overview.salesOut = flow.salesOut;
|
||
if (flow.salesReturn != null) overview.salesReturn = flow.salesReturn;
|
||
|
||
// 3) 期末 / 分级
|
||
// 期末数量统一取「聚水潭实际库存」rpt.jst_inventoryInfo(按 wms_co_id 单仓统计):
|
||
// pm_stockMonitor 的 warehouse 是「归属仓」而非实物所在仓,其 jsStock 会把
|
||
// 已调拨到外仓(如横林二仓)的货仍计在本仓名下,与仓库实物库存对不上
|
||
// (例:崔桥工厂仓 快照 jsStock 115135 vs 实物 92439)。
|
||
// 库龄/周转率/四级库存量只能由 pm_stockMonitor 提供(实时库存表无 created、销量字段)。
|
||
let skuCount = 0;
|
||
let source = null;
|
||
let closingSource = null;
|
||
let hasGrade = false;
|
||
let summary = {};
|
||
|
||
if (dbWarehouse && latestBizDate) {
|
||
const D = buildDerivedColumns();
|
||
const summaryRows = await cloudDb.query(
|
||
`SELECT
|
||
COUNT(*) AS skuCount,
|
||
SUM(CAST(jsStock AS DECIMAL(18, 2))) AS totalStock,
|
||
AVG(${D.turnoverDays}) AS avgTurnover,
|
||
MAX(${D.turnoverDays}) AS maxTurnover,
|
||
SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel,
|
||
SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel,
|
||
SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel,
|
||
SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(jsStock 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: latestBizDate, asOf }
|
||
);
|
||
summary = summaryRows[0] || {};
|
||
const snapSkuCount = Number(summary.skuCount || 0);
|
||
if (snapSkuCount > 0) {
|
||
hasGrade = true;
|
||
overview.turnoverDays = summary.avgTurnover == null ? null : Number(summary.avgTurnover);
|
||
overview.warnLevel = Number(summary.warnLevel || 0);
|
||
overview.slowLevel = Number(summary.slowLevel || 0);
|
||
overview.staleLevel = Number(summary.staleLevel || 0);
|
||
overview.zombieLevel = Number(summary.zombieLevel || 0);
|
||
}
|
||
}
|
||
|
||
// 3.2 期末数量:聚水潭实际库存(单仓 wms_co_id);SKU 数同步跟随期末口径
|
||
let closingTakenAt = null;
|
||
if (wmsCoId) {
|
||
const inv = await fetchInventoryQty(wmsCoId).catch(() => null);
|
||
if (inv && inv.qty != null) {
|
||
closingSource = 'jst_inventoryInfo';
|
||
overview.closingQty = inv.qty;
|
||
skuCount = inv.skuCount || 0;
|
||
closingTakenAt = inv.takenAt || null;
|
||
}
|
||
}
|
||
|
||
// 3.3 兜底:拿不到实时库存时退回快照归属库存(并在响应中标记来源)
|
||
if (!closingSource && summary.totalStock != null && Number(summary.skuCount || 0) > 0) {
|
||
closingSource = 'pm_stockMonitor';
|
||
overview.closingQty = Number(summary.totalStock || 0);
|
||
skuCount = Number(summary.skuCount || 0);
|
||
}
|
||
|
||
source = closingSource || (hasGrade ? 'pm_stockMonitor' : null);
|
||
// 四级库存量基于快照归属库存分级,与期末(实际库存)口径不同,供前端提示用
|
||
const gradeBasisStock = Number(summary.totalStock || 0);
|
||
|
||
// 4) 期初倒推(估算)
|
||
const opening = estimateOpeningBalance(
|
||
typeof overview.closingQty === 'number' ? overview.closingQty : null,
|
||
flow
|
||
);
|
||
if (opening != null) {
|
||
overview.periodStart = opening;
|
||
overview.openingBalance = opening;
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
...baseResponse,
|
||
latestBizDate: latestBizDate || null,
|
||
fallbackDate,
|
||
asOf,
|
||
period: periodForFlow,
|
||
source,
|
||
closingSource,
|
||
closingTakenAt,
|
||
hasGrade,
|
||
gradeBasisStock,
|
||
hasData: Boolean(source),
|
||
skuCount,
|
||
overview,
|
||
turnoverDaysAvg: summary.avgTurnover != null ? Number(summary.avgTurnover) : null,
|
||
turnoverDaysMax: summary.maxTurnover != null ? Number(summary.maxTurnover) : null,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[LargeStockMonitor] 获取概览失败:', error);
|
||
res.status(error.statusCode || 500).json({
|
||
success: false,
|
||
error: error.message || '获取概览失败',
|
||
});
|
||
}
|
||
});
|
||
|
||
router.get('/sku-list', authMiddleware, async (req, res) => {
|
||
try {
|
||
const config = resolveWarehouse(req.query.warehouse);
|
||
if (!config) {
|
||
return res.status(404).json({ success: false, error: '未知的仓库配置' });
|
||
}
|
||
|
||
const requestedDate = String(req.query.date || '').trim() || null;
|
||
if (requestedDate && !/^\d{4}-\d{2}-\d{2}$/.test(requestedDate)) {
|
||
return res.status(400).json({ success: false, error: 'date 参数格式应为 YYYY-MM-DD' });
|
||
}
|
||
|
||
const dbWarehouse = config.dbWarehouse;
|
||
const wmsCoId = config.wmsCoId;
|
||
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||
const pageSize = Math.min(Math.max(parseInt(req.query.pageSize, 10) || 20, 1), 200);
|
||
const offset = (page - 1) * pageSize;
|
||
const level = String(req.query.level || '').trim();
|
||
const keyword = String(req.query.keyword || '').trim();
|
||
|
||
const baseResponse = {
|
||
warehouse: config.warehouse,
|
||
group: config.group,
|
||
requestedDate,
|
||
latestBizDate: null,
|
||
fallbackDate: null,
|
||
asOf: null,
|
||
source: null,
|
||
page,
|
||
pageSize,
|
||
total: 0,
|
||
items: [],
|
||
};
|
||
|
||
const latestBizDate = dbWarehouse
|
||
? await fetchTargetBizDate(dbWarehouse, requestedDate).catch(() => null)
|
||
: null;
|
||
const asOf = normalizeAsOf(req.query.asOf) || buildPeriodRange(latestBizDate)?.asOf || fallbackAsOf();
|
||
|
||
// pm_stockMonitor 有数据 → 走原有分级明细
|
||
if (dbWarehouse && latestBizDate) {
|
||
const D = buildDerivedColumns();
|
||
const filters = ['m.warehouse = @warehouse', "CONVERT(VARCHAR(10), m.date, 23) = @bizDate"];
|
||
const params = { warehouse: dbWarehouse, bizDate: latestBizDate, asOf };
|
||
|
||
if (keyword) {
|
||
filters.push('(m.sku LIKE @kw OR m.skuName LIKE @kw)');
|
||
params.kw = `%${keyword}%`;
|
||
}
|
||
if (['warn', 'slow', 'stale', 'zombie'].includes(level)) {
|
||
filters.push(`${D.levelExpr} = @level`);
|
||
params.level = level;
|
||
}
|
||
|
||
const whereClause = filters.join(' AND ');
|
||
const totalRows = await cloudDb.query(
|
||
`SELECT COUNT(*) AS total FROM rpt.pm_stockMonitor m WHERE ${whereClause}`,
|
||
params
|
||
);
|
||
const total = Number(totalRows[0]?.total || 0);
|
||
|
||
params.pageSize = pageSize;
|
||
params.offset = offset;
|
||
const items = await cloudDb.query(
|
||
`SELECT
|
||
m.sku, m.spu, m.skuName, m.supplier, m.purchase, m.category,
|
||
m.jsStock, m.threeAvgSale, m.created,
|
||
${D.ageMonths} AS ageMonths,
|
||
${D.monthlyShipment} AS monthlyShipment,
|
||
${D.avgDailyStock} AS avgDailyStock,
|
||
${D.turnoverDays} AS turnoverDays,
|
||
${D.levelExpr} AS level,
|
||
m.warehouse
|
||
FROM rpt.pm_stockMonitor m
|
||
WHERE ${whereClause}
|
||
ORDER BY m.jsStock DESC, m.sku ASC
|
||
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`,
|
||
params
|
||
);
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
...baseResponse,
|
||
latestBizDate,
|
||
fallbackDate: requestedDate && latestBizDate !== requestedDate ? latestBizDate : null,
|
||
asOf,
|
||
source: 'pm_stockMonitor',
|
||
total,
|
||
items,
|
||
},
|
||
});
|
||
}
|
||
|
||
// 兜底:聚水潭实时库存明细(无库龄/销量字段,level 与周转率留空)
|
||
// 该数据源算不出库龄与周转率,任何分级筛选都不可能命中,直接返回空列表,
|
||
// 避免点选「商品僵尸」等时把整仓库存原样列出造成误导。
|
||
if (['warn', 'slow', 'stale', 'zombie'].includes(level)) {
|
||
return res.json({
|
||
success: true,
|
||
data: { ...baseResponse, asOf, source: 'jst_inventoryInfo', total: 0, items: [] },
|
||
});
|
||
}
|
||
|
||
if (!wmsCoId) {
|
||
return res.json({ success: true, data: baseResponse });
|
||
}
|
||
|
||
const invFilters = ['v.wms_co_id = @wmsCoId', 'ISNULL(v.qty, 0) <> 0'];
|
||
const invParams = { wmsCoId };
|
||
if (keyword) {
|
||
invFilters.push('(v.sku_id LIKE @kw OR v.name LIKE @kw)');
|
||
invParams.kw = `%${keyword}%`;
|
||
}
|
||
const invWhere = invFilters.join(' AND ');
|
||
|
||
const invTotal = await cloudDb.query(
|
||
`SELECT COUNT(*) AS total FROM rpt.jst_inventoryInfo v WHERE ${invWhere}`,
|
||
invParams
|
||
);
|
||
const total = Number(invTotal[0]?.total || 0);
|
||
|
||
invParams.pageSize = pageSize;
|
||
invParams.offset = offset;
|
||
const items = await cloudDb.query(
|
||
`SELECT
|
||
v.sku_id AS sku,
|
||
v.i_id AS spu,
|
||
v.name AS skuName,
|
||
v.qty AS jsStock,
|
||
CAST(NULL AS DECIMAL(18, 4)) AS ageMonths,
|
||
CAST(NULL AS DECIMAL(18, 4)) AS monthlyShipment,
|
||
CAST(v.qty AS DECIMAL(18, 4)) AS avgDailyStock,
|
||
CAST(NULL AS DECIMAL(18, 4)) AS turnoverDays,
|
||
CAST(NULL AS VARCHAR(10)) AS level,
|
||
w.name AS warehouse
|
||
FROM rpt.jst_inventoryInfo v
|
||
LEFT JOIN rpt.jst_warehouseInfo w ON w.wms_co_id = v.wms_co_id
|
||
WHERE ${invWhere}
|
||
ORDER BY v.qty DESC, v.sku_id ASC
|
||
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`,
|
||
invParams
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
data: { ...baseResponse, asOf, source: 'jst_inventoryInfo', total, items },
|
||
});
|
||
} catch (error) {
|
||
console.error('[LargeStockMonitor] 获取 SKU 列表失败:', error);
|
||
res.status(error.statusCode || 500).json({
|
||
success: false,
|
||
error: error.message || '获取 SKU 列表失败',
|
||
});
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|