cangku/backend/src/api/largeStockMonitor.js
lunanting 2bc949099b feat: 新增大货库存监控(26 仓)
按「库存监控」表格在侧边栏增加一级/二级菜单与共用页面
(路由 /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 失败崩溃
- 保留主仓库新增的「模块化库存监控」菜单组及其子项
2026-09-23 13:42:58 +08:00

515 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 大货库存监控 - 后端路由
// 业务数据源: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;