refactor: 移除大货库存监控功能(26 仓) #29
@ -1,113 +0,0 @@
|
||||
// 端到端校验:大货库存监控 26 仓在「数据截止时刻(快照日次日 01:00)」口径下的取数
|
||||
// 用法:node scripts/verify-large-stock-monitor.js
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
||||
const http = require('http');
|
||||
|
||||
const BASE = 'http://127.0.0.1:4001';
|
||||
const { LARGE_STOCK_MONITOR_WAREHOUSES } = require('../src/config/largeStockMonitorConfig');
|
||||
|
||||
function request(method, path, token, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body ? JSON.stringify(body) : null;
|
||||
const req = http.request(
|
||||
`${BASE}${path}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => (data += c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode, json: JSON.parse(data) });
|
||||
} catch (e) {
|
||||
reject(new Error(`响应解析失败 ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const login = await request('POST', '/api/auth/local-preview', null, {});
|
||||
const token = login.json?.token || login.json?.data?.token || login.json?.access_token;
|
||||
if (!token) {
|
||||
console.error('登录失败:', JSON.stringify(login.json).slice(0, 300));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('登录成功\n');
|
||||
|
||||
let ok = 0;
|
||||
const problems = [];
|
||||
let sampleAsOf = null;
|
||||
|
||||
for (const w of LARGE_STOCK_MONITOR_WAREHOUSES) {
|
||||
const url = `/api/large-stock-monitor/overview?warehouse=${encodeURIComponent(w.warehouse)}`;
|
||||
try {
|
||||
const res = await request('GET', url, token);
|
||||
const d = res.json?.data;
|
||||
if (!res.json?.success || !d) {
|
||||
problems.push(`${w.warehouse}: 接口失败 ${res.status} ${res.json?.error || ''}`);
|
||||
continue;
|
||||
}
|
||||
sampleAsOf = sampleAsOf || d.asOf;
|
||||
const o = d.overview || {};
|
||||
const filled = Object.values(o).filter((v) => v !== '—' && v !== null && v !== undefined).length;
|
||||
if (!d.hasData) {
|
||||
problems.push(`${w.warehouse}: 无数据源 (source=${d.source})`);
|
||||
continue;
|
||||
}
|
||||
ok += 1;
|
||||
console.log(
|
||||
`[${String(w.idx).padStart(2)}] ${w.warehouse}\n` +
|
||||
` 快照=${d.latestBizDate} 截止=${d.asOf} 区间=${d.period?.start} ~ ${d.period?.end}\n` +
|
||||
` SKU=${d.skuCount} 期末=${o.closingQty} 期初(估)=${o.periodStart}\n` +
|
||||
` 采购进=${o.purchaseIn} 采购退=${o.purchaseReturn} 销售出=${o.salesOut} 销售退=${o.salesReturn}\n` +
|
||||
` 周转=${o.turnoverDays} 预警=${o.warnLevel} 慢流=${o.slowLevel} 呆滞=${o.staleLevel} 僵尸=${o.zombieLevel}\n` +
|
||||
` 有值列 ${filled}/18 source=${d.source}`
|
||||
);
|
||||
} catch (e) {
|
||||
problems.push(`${w.warehouse}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 明细接口校验:带 @asOf 的分级筛选 SQL(level 过滤会引用 @asOf)
|
||||
console.log('\n===== 明细接口校验 =====');
|
||||
for (const w of LARGE_STOCK_MONITOR_WAREHOUSES.slice(0, 4)) {
|
||||
for (const lv of ['', 'warn', 'slow', 'stale', 'zombie']) {
|
||||
const qs = `warehouse=${encodeURIComponent(w.warehouse)}&page=1&pageSize=5${lv ? `&level=${lv}` : ''}`;
|
||||
try {
|
||||
const res = await request('GET', `/api/large-stock-monitor/sku-list?${qs}`, token);
|
||||
const d = res.json?.data;
|
||||
if (!res.json?.success) {
|
||||
problems.push(`${w.warehouse} level=${lv || '全部'}: ${res.json?.error}`);
|
||||
} else {
|
||||
console.log(` ${w.warehouse} level=${lv || '全部'} -> total=${d?.total} 返回=${d?.items?.length} 截止=${d?.asOf}`);
|
||||
}
|
||||
} catch (e) {
|
||||
problems.push(`${w.warehouse} level=${lv}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n===== 汇总 =====`);
|
||||
console.log(`数据截止时刻(asOf): ${sampleAsOf}`);
|
||||
console.log(`有数据仓库: ${ok}/${LARGE_STOCK_MONITOR_WAREHOUSES.length}`);
|
||||
if (problems.length) {
|
||||
console.log('异常:');
|
||||
problems.forEach((p) => console.log(' - ' + p));
|
||||
} else {
|
||||
console.log('全部仓库取数正常,无异常');
|
||||
}
|
||||
process.exit(0);
|
||||
})();
|
||||
@ -1,514 +0,0 @@
|
||||
// 大货库存监控 - 后端路由
|
||||
// 业务数据源: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;
|
||||
@ -1,76 +0,0 @@
|
||||
// 大货库存监控配置 - 数据源:C:/Users/15018737611/Desktop/库存监控.xlsx
|
||||
// 共 26 行,按 Excel 顺序保留:序号 / 二级功能按键 / 仓库全名
|
||||
// 业务库 rpt.pm_stockMonitor.warehouse 的命名规则与 Excel 不一致:
|
||||
// Excel 通常带「主仓」后缀,业务库只保留「主」前那一段。
|
||||
// 已在 dbWarehouse 字段手工映射出业务库 warehouse 精确值;无法对应的留 null。
|
||||
// 菜单 label 显示 Excel 仓库全名,routeKey 用 slug 化的二级功能按键+序号保证唯一。
|
||||
|
||||
// wmsCoId 来自 rpt.jst_warehouseInfo(聚水潭仓库主数据,155 行,26 个仓库全部命中),
|
||||
// 用于从出入库单据表(jst_purchase / jst_purchaseout / jst_salesOutboundInfo 等)取流水数量。
|
||||
// dbWarehouse 用于 rpt.pm_stockMonitor;该报表仅覆盖 16 个仓库,其余为 null。
|
||||
const RAW_WAREHOUSES = [
|
||||
{ idx: 1, group: '半成品部件仓', warehouse: '简尚-崔桥工厂半成品包装项目仓主仓', dbWarehouse: '简尚-崔桥工厂半成品包装项目仓', wmsCoId: 15718196 },
|
||||
{ idx: 2, group: '运营C', warehouse: '简尚-运营C崔桥工厂仓专用仓主仓', dbWarehouse: null, wmsCoId: 15301815 },
|
||||
{ idx: 3, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂仓主仓', dbWarehouse: '简尚-崔桥工厂仓', wmsCoId: 15038426 },
|
||||
{ idx: 4, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂京东仓主仓', dbWarehouse: null, wmsCoId: 15045345 },
|
||||
{ idx: 5, group: '崔桥工厂品牌仓', warehouse: '简尚-崔桥工厂品牌仓主仓', dbWarehouse: '简尚-崔桥工厂品牌仓', wmsCoId: 15870495 },
|
||||
{ idx: 6, group: '河南洛宁仓', warehouse: '简尚-河南洛宁仓主仓', dbWarehouse: '简尚-河南洛宁仓', wmsCoId: 15539084 },
|
||||
{ idx: 7, group: '运营D', warehouse: '简尚-运营D专用仓库主仓', dbWarehouse: null, wmsCoId: 15051915 },
|
||||
{ idx: 8, group: '运营C', warehouse: '简尚-运营C横林二仓专用仓主仓', dbWarehouse: null, wmsCoId: 15301814 },
|
||||
{ idx: 9, group: '横林二仓', warehouse: '简尚-横林二仓主仓', dbWarehouse: '简尚-横林二仓', wmsCoId: 15033302 },
|
||||
{ idx: 10, group: '横林三仓', warehouse: '简尚-横林三仓主仓', dbWarehouse: '简尚-横林三仓', wmsCoId: 15043561 },
|
||||
{ idx: 11, group: '山东品牌仓', warehouse: '简尚-山东品牌仓主仓', dbWarehouse: '简尚-山东品牌仓', wmsCoId: 15182705 },
|
||||
{ idx: 12, group: '山东青集岗仓', warehouse: '简尚-山东青岗集京东仓主仓', dbWarehouse: null, wmsCoId: 15406664 },
|
||||
{ idx: 13, group: '山东青集岗仓', warehouse: '简尚-山东青岗集仓主仓', dbWarehouse: '简尚-山东青岗集仓', wmsCoId: 15137523 },
|
||||
{ idx: 14, group: '运营D', warehouse: '简尚-运营D苏北专用仓主仓', dbWarehouse: null, wmsCoId: 15406876 },
|
||||
{ idx: 15, group: '运营C', warehouse: '简尚-运营C新苏北专用仓主仓', dbWarehouse: null, wmsCoId: 15301812 },
|
||||
{ idx: 16, group: '苏北仓', warehouse: '简尚-新苏北仓主仓', dbWarehouse: '简尚-新苏北仓', wmsCoId: 15038347 },
|
||||
{ idx: 17, group: '分销仓', warehouse: '简尚-分销部苏北专用仓主仓', dbWarehouse: '简尚-分销部苏北专用仓', wmsCoId: 15045325 },
|
||||
{ idx: 18, group: '运营D', warehouse: '简尚-运营D睢宁专用仓主仓', dbWarehouse: null, wmsCoId: 15051907 },
|
||||
{ idx: 19, group: '运营C', warehouse: '简尚-运营C睢宁专用仓主仓', dbWarehouse: null, wmsCoId: 15301813 },
|
||||
{ idx: 20, group: '睢宁仓', warehouse: '简尚-睢宁仓主仓', dbWarehouse: '简尚-睢宁仓', wmsCoId: 15032603 },
|
||||
{ idx: 21, group: '沭阳金源涌动', warehouse: '简尚-沭阳金源涌动项目仓主仓', dbWarehouse: '简尚-沭阳金源涌动项目仓', wmsCoId: 15043585 },
|
||||
{ idx: 22, group: '分销仓', warehouse: '简尚-分销部睢宁专用仓主仓', dbWarehouse: '简尚-分销部睢宁专用仓', wmsCoId: 15051960 },
|
||||
{ idx: 23, group: '外贸仓', warehouse: '简尚-新外贸仓主仓', dbWarehouse: '简尚-新外贸仓', wmsCoId: 15045308 },
|
||||
{ idx: 24, group: '外贸仓', warehouse: '简尚-济南外贸仓主仓', dbWarehouse: null, wmsCoId: 15051888 },
|
||||
{ idx: 25, group: '分销仓', warehouse: '简尚-分销部专用仓主仓', dbWarehouse: '简尚-分销部专用仓', wmsCoId: 15045426 },
|
||||
{ idx: 26, group: '分销仓', warehouse: '简尚-分销部崔桥专用仓主仓', dbWarehouse: '简尚-分销部崔桥专用仓', wmsCoId: 15404402 },
|
||||
];
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
const LARGE_STOCK_MONITOR_WAREHOUSES = RAW_WAREHOUSES.map((entry) => ({
|
||||
...entry,
|
||||
routeKey: `stock-monitor-large-${entry.idx}`,
|
||||
slug: slugify(entry.warehouse) || `warehouse-${entry.idx}`,
|
||||
hasData: Boolean(entry.dbWarehouse),
|
||||
}));
|
||||
|
||||
// 无业务库数据源的列:业务库(JSReportReplica,220 张表)中不存在加工/盘点/调拨/其它出库
|
||||
// 任何单据表,这 6 列无真实数据可回填,保留占位符「—」并在前端说明。
|
||||
const UNAVAILABLE_COLUMN_KEYS = [
|
||||
'processIn',
|
||||
'stocktake',
|
||||
'processOut',
|
||||
'transferOut',
|
||||
'transferIn',
|
||||
'otherOut',
|
||||
];
|
||||
|
||||
const LARGE_STOCK_MONITOR_PAGE = {
|
||||
pageKey: 'large_stock_monitor',
|
||||
path: '/planning/large-stock-monitor/:warehouseSlug',
|
||||
name: '大货库存监控',
|
||||
defaultRoles: { admin: true, analyst: true, user: true, guest: false },
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
LARGE_STOCK_MONITOR_WAREHOUSES,
|
||||
LARGE_STOCK_MONITOR_PAGE,
|
||||
UNAVAILABLE_COLUMN_KEYS,
|
||||
};
|
||||
@ -35,7 +35,6 @@ const promotionModuleRoutes = require('./api/promotionModule');
|
||||
const productMonitoringRoutes = require('./api/productMonitoring');
|
||||
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
|
||||
const mkhBomRoutes = require('./api/mkhBom');
|
||||
const largeStockMonitorRoutes = require('./api/largeStockMonitor');
|
||||
const database = require('./config/database');
|
||||
const cloudDb = require('./config/cloudDatabase');
|
||||
const jianSunDb = require('./config/jianSunDatabase');
|
||||
@ -655,7 +654,6 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
|
||||
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
|
||||
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
|
||||
cubejsApp.use('/api/large-stock-monitor', authMiddleware, largeStockMonitorRoutes);
|
||||
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
|
||||
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
// 大货库存监控 - 出入库流水取数(本月 1 日 ~ 快照日)
|
||||
//
|
||||
// 业务库 JSReportReplica 里与「库存流水」相关的单据表:
|
||||
// 采购进仓 rpt.jst_purchase (wms_co_id, po_date, status) + rpt.jst_purchaseItem (po_id, inQty)
|
||||
// 采购退货 rpt.jst_purchaseout (wms_co_id, io_date, status) + rpt.jst_purchaseoutItem (io_id, qty)
|
||||
// 销售出仓 rpt.jst_salesOutboundInfo(wms_co_id, io_date, status) + rpt.jst_OrderItem (o_id, qty)
|
||||
// 销售退货 rpt.jst_returnAndRefundInfo(warehouse 名称) + rpt.jst_returnAndRefundItem(as_id, r_qty)
|
||||
//
|
||||
// 有效单据状态:只统计 Confirmed / Finished,排除 Cancelled / Delete / WaitConfirm。
|
||||
// 业务库中不存在加工、盘点、调拨、其它出库任何单据表,这 6 列无法取数。
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
|
||||
// 单据有效状态(已确认/已完成),避免把取消单算进流水
|
||||
const PURCHASE_STATUS = "'Confirmed','Finished'";
|
||||
const PURCHASEOUT_STATUS = "'Confirmed'";
|
||||
const SALESOUT_STATUS = "'Confirmed'";
|
||||
const REFUND_STATUS = "'Confirmed'";
|
||||
|
||||
function toNumber(value) {
|
||||
if (value == null) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购进仓:采购单已入库数量(inQty)
|
||||
*/
|
||||
async function fetchPurchaseIn(wmsCoId, periodStart, periodEnd) {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT ISNULL(SUM(CAST(pi.inQty AS DECIMAL(18, 2))), 0) AS v
|
||||
FROM rpt.jst_purchase p
|
||||
JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id
|
||||
WHERE p.wms_co_id = @wmsCoId
|
||||
AND p.po_date >= @periodStart AND p.po_date < @periodEnd
|
||||
AND p.status IN (${PURCHASE_STATUS})`,
|
||||
{ wmsCoId, periodStart, periodEnd }
|
||||
);
|
||||
return toNumber(rows[0]?.v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购退货:采购退货出库单数量
|
||||
*/
|
||||
async function fetchPurchaseReturn(wmsCoId, periodStart, periodEnd) {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v
|
||||
FROM rpt.jst_purchaseout o
|
||||
JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id
|
||||
WHERE o.wms_co_id = @wmsCoId
|
||||
AND o.io_date >= @periodStart AND o.io_date < @periodEnd
|
||||
AND o.status IN (${PURCHASEOUT_STATUS})`,
|
||||
{ wmsCoId, periodStart, periodEnd }
|
||||
);
|
||||
return toNumber(rows[0]?.v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售出仓:销售出库单关联的订单明细数量
|
||||
* 注意:jst_salesOutboundInfo.qty 是表头字段且恒为 0,真实件数在 jst_OrderItem.qty
|
||||
*/
|
||||
async function fetchSalesOut(wmsCoId, periodStart, periodEnd) {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v
|
||||
FROM rpt.jst_salesOutboundInfo s
|
||||
JOIN rpt.jst_OrderItem oi ON oi.o_id = s.o_id
|
||||
WHERE s.wms_co_id = @wmsCoId
|
||||
AND s.io_date >= @periodStart AND s.io_date < @periodEnd
|
||||
AND s.status IN (${SALESOUT_STATUS})`,
|
||||
{ wmsCoId, periodStart, periodEnd }
|
||||
);
|
||||
return toNumber(rows[0]?.v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售退货:退货退款单明细的实退数量(r_qty)
|
||||
* 该表 wms_co_id 大量为空(9 月 92821 单中 75094 单为空),改用 warehouse 名称匹配:
|
||||
* 名称形如「简尚-崔桥工厂仓销退仓」/「简尚-分销部睢宁专用仓主仓」,统一去掉「销退仓」「主仓」后比对。
|
||||
*/
|
||||
async function fetchSalesReturn(dbWarehouse, periodStart, periodEnd) {
|
||||
if (!dbWarehouse) return null;
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT ISNULL(SUM(CAST(ri.r_qty AS DECIMAL(18, 2))), 0) AS v
|
||||
FROM rpt.jst_returnAndRefundInfo r
|
||||
JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id
|
||||
WHERE REPLACE(REPLACE(ISNULL(r.warehouse, ''), N'销退仓', N''), N'主仓', N'') = @whName
|
||||
AND r.created >= @periodStart AND r.created < @periodEnd
|
||||
AND r.status IN (${REFUND_STATUS})`,
|
||||
{ whName: dbWarehouse, periodStart, periodEnd }
|
||||
);
|
||||
return toNumber(rows[0]?.v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓库实物库存:聚水潭实时库存表 rpt.jst_inventoryInfo(按 wms_co_id 单仓统计)
|
||||
* 这是报表「期末数量」的取数口径 —— pm_stockMonitor 的 warehouse 仅是归属仓,
|
||||
* 其 jsStock 含已调拨到外仓的货,不能代表本仓实物库存。
|
||||
*/
|
||||
async function fetchInventoryQty(wmsCoId) {
|
||||
if (!wmsCoId) return null;
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS v,
|
||||
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 }
|
||||
);
|
||||
return {
|
||||
qty: toNumber(rows[0]?.v),
|
||||
skuCount: toNumber(rows[0]?.skuCount) || 0,
|
||||
takenAt: rows[0]?.takenAt ? new Date(rows[0].takenAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性取全部流水;任一子查询失败不影响其它字段(返回 null 由调用方降级为「—」)
|
||||
*/
|
||||
async function fetchFlowSummary({ wmsCoId, dbWarehouse, periodStart, periodEnd }) {
|
||||
const [purchaseIn, purchaseReturn, salesOut, salesReturn] = await Promise.all([
|
||||
wmsCoId ? fetchPurchaseIn(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
|
||||
wmsCoId ? fetchPurchaseReturn(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
|
||||
wmsCoId ? fetchSalesOut(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
|
||||
fetchSalesReturn(dbWarehouse, periodStart, periodEnd).catch(() => null),
|
||||
]);
|
||||
|
||||
return { purchaseIn, purchaseReturn, salesOut, salesReturn };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchFlowSummary,
|
||||
fetchInventoryQty,
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const BASE = '/api/large-stock-monitor';
|
||||
|
||||
export function getLargeStockMonitorConfig() {
|
||||
return request.get(`${BASE}/config`);
|
||||
}
|
||||
|
||||
export function getLargeStockMonitorOverview(warehouse) {
|
||||
return request.get(`${BASE}/overview`, { params: { warehouse } });
|
||||
}
|
||||
|
||||
export function getLargeStockMonitorSkuList(params = {}) {
|
||||
return request.get(`${BASE}/sku-list`, { params });
|
||||
}
|
||||
@ -1,14 +1,3 @@
|
||||
import { LARGE_STOCK_MONITOR_WAREHOUSES } from '../constants/largeStockMonitor.js';
|
||||
|
||||
const buildLargeStockMonitorChildren = () => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({
|
||||
key: `stock-monitor-large-${entry.idx}`,
|
||||
label: entry.warehouse,
|
||||
icon: 'appstore',
|
||||
routeKey: `stock-monitor-large-${entry.idx}`,
|
||||
visible: true,
|
||||
order: entry.idx * 10,
|
||||
}));
|
||||
|
||||
export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
collapsedByDefault: false,
|
||||
darkModeByDefault: false,
|
||||
@ -37,10 +26,6 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stock-monitor-large', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: false,
|
||||
children: buildLargeStockMonitorChildren(),
|
||||
},
|
||||
{
|
||||
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
|
||||
children: [
|
||||
|
||||
@ -1,165 +0,0 @@
|
||||
// 大货库存监控配置(前端镜像)
|
||||
// 与 backend/src/config/largeStockMonitorConfig.js 同步
|
||||
// 修改需双端同步;运行期也可通过 /api/large-stock-monitor/config 获取
|
||||
|
||||
const RAW_WAREHOUSES = [
|
||||
{ idx: 1, group: '半成品部件仓', warehouse: '简尚-崔桥工厂半成品包装项目仓主仓' },
|
||||
{ idx: 2, group: '运营C', warehouse: '简尚-运营C崔桥工厂仓专用仓主仓' },
|
||||
{ idx: 3, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂仓主仓' },
|
||||
{ idx: 4, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂京东仓主仓' },
|
||||
{ idx: 5, group: '崔桥工厂品牌仓', warehouse: '简尚-崔桥工厂品牌仓主仓' },
|
||||
{ idx: 6, group: '河南洛宁仓', warehouse: '简尚-河南洛宁仓主仓' },
|
||||
{ idx: 7, group: '运营D', warehouse: '简尚-运营D专用仓库主仓' },
|
||||
{ idx: 8, group: '运营C', warehouse: '简尚-运营C横林二仓专用仓主仓' },
|
||||
{ idx: 9, group: '横林二仓', warehouse: '简尚-横林二仓主仓' },
|
||||
{ idx: 10, group: '横林三仓', warehouse: '简尚-横林三仓主仓' },
|
||||
{ idx: 11, group: '山东品牌仓', warehouse: '简尚-山东品牌仓主仓' },
|
||||
{ idx: 12, group: '山东青集岗仓', warehouse: '简尚-山东青岗集京东仓主仓' },
|
||||
{ idx: 13, group: '山东青集岗仓', warehouse: '简尚-山东青岗集仓主仓' },
|
||||
{ idx: 14, group: '运营D', warehouse: '简尚-运营D苏北专用仓主仓' },
|
||||
{ idx: 15, group: '运营C', warehouse: '简尚-运营C新苏北专用仓主仓' },
|
||||
{ idx: 16, group: '苏北仓', warehouse: '简尚-新苏北仓主仓' },
|
||||
{ idx: 17, group: '分销仓', warehouse: '简尚-分销部苏北专用仓主仓' },
|
||||
{ idx: 18, group: '运营D', warehouse: '简尚-运营D睢宁专用仓主仓' },
|
||||
{ idx: 19, group: '运营C', warehouse: '简尚-运营C睢宁专用仓主仓' },
|
||||
{ idx: 20, group: '睢宁仓', warehouse: '简尚-睢宁仓主仓' },
|
||||
{ idx: 21, group: '沭阳金源涌动', warehouse: '简尚-沭阳金源涌动项目仓主仓' },
|
||||
{ idx: 22, group: '分销仓', warehouse: '简尚-分销部睢宁专用仓主仓' },
|
||||
{ idx: 23, group: '外贸仓', warehouse: '简尚-新外贸仓主仓' },
|
||||
{ idx: 24, group: '外贸仓', warehouse: '简尚-济南外贸仓主仓' },
|
||||
{ idx: 25, group: '分销仓', warehouse: '简尚-分销部专用仓主仓' },
|
||||
{ idx: 26, group: '分销仓', warehouse: '简尚-分销部崔桥专用仓主仓' },
|
||||
];
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export const LARGE_STOCK_MONITOR_WAREHOUSES = RAW_WAREHOUSES.map((entry) => ({
|
||||
...entry,
|
||||
routeKey: `stock-monitor-large-${entry.idx}`,
|
||||
slug: slugify(entry.warehouse) || `warehouse-${entry.idx}`,
|
||||
}));
|
||||
|
||||
// 18 列表头(按 Excel 第 1 行 D..U 列)
|
||||
// available=false:业务库(220 张表)中不存在该单据类型,无数据源,显示「—」
|
||||
// estimated=true:由已有字段推导(非原始字段),前端标「估」并在悬浮说明中写明推导式
|
||||
export const OVERVIEW_COLUMNS = [
|
||||
{
|
||||
key: 'periodStart',
|
||||
label: '期初数据',
|
||||
available: true,
|
||||
estimated: true,
|
||||
tip: '业务库仅保留最新单日库存快照,无上月末历史。此值由「期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货」倒推,未包含加工/调拨/盘点/其它出库。倒推为负时显示「—」。',
|
||||
},
|
||||
{ key: 'processIn', label: '加工进仓', available: false, tip: '业务库无加工单据表,无数据源' },
|
||||
{
|
||||
key: 'purchaseReturn',
|
||||
label: '采购退货',
|
||||
available: true,
|
||||
tip: '本月采购退货出库单数量:rpt.jst_purchaseout + jst_purchaseoutItem(状态 Confirmed)',
|
||||
},
|
||||
{
|
||||
key: 'purchaseIn',
|
||||
label: '采购进仓',
|
||||
available: true,
|
||||
tip: '本月采购单已入库数量:rpt.jst_purchase + jst_purchaseItem 的 inQty(状态 Confirmed/Finished)',
|
||||
},
|
||||
{
|
||||
key: 'salesOut',
|
||||
label: '销售出仓',
|
||||
available: true,
|
||||
tip: '本月销售出库单件数:rpt.jst_salesOutboundInfo(状态 Confirmed)关联 jst_OrderItem.qty',
|
||||
},
|
||||
{ key: 'stocktake', label: '盘点', available: false, tip: '业务库无盘点单据表,无数据源' },
|
||||
{ key: 'processOut', label: '加工出仓', available: false, tip: '业务库无加工单据表,无数据源' },
|
||||
{ key: 'transferOut', label: '调拨出', available: false, tip: '业务库无调拨单据表,无数据源' },
|
||||
{
|
||||
key: 'salesReturn',
|
||||
label: '销售退货',
|
||||
available: true,
|
||||
tip: '本月退货退款单实退数量:rpt.jst_returnAndRefundInfo + jst_returnAndRefundItem 的 r_qty(状态 Confirmed,按仓库名称匹配)',
|
||||
},
|
||||
{ key: 'transferIn', label: '调拨入', available: false, tip: '业务库无调拨单据表,无数据源' },
|
||||
{
|
||||
key: 'openingBalance',
|
||||
label: '期初',
|
||||
available: true,
|
||||
estimated: true,
|
||||
tip: '同期初数据(倒推估算值)',
|
||||
},
|
||||
{ key: 'otherOut', label: '其它出库', available: false, tip: '业务库无其它出库单据表,无数据源' },
|
||||
{
|
||||
key: 'closingQty',
|
||||
label: '期末数量',
|
||||
available: true,
|
||||
tip: '聚水潭实际库存 rpt.jst_inventoryInfo(按仓库 wms_co_id 单仓实物口径,实时值)。业务库无历史时点库存快照,此值随每次刷新滚动到最新;与某日「期末结存」的差额即该日之后的出入库与仓间调拨',
|
||||
},
|
||||
{ key: 'turnoverDays', label: '库存周转天数', available: true, tip: '月度发货总件数 / 月度日均库存件数 × 100%(按业务库口径,数值实为周转率 %)' },
|
||||
{ key: 'warnLevel', label: '滞销预警级库存量', available: true, tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计(按库存快照分级,口径为归属库存,与期末实际库存不同)' },
|
||||
{ key: 'slowLevel', label: '商品慢流级库存量', available: true, tip: '库龄 < 3 个月 且 周转率 ≥ 200%;或 库龄 > 3 个月 且 30% ≤ 周转率 ≤ 99%' },
|
||||
{ key: 'staleLevel', label: '商品呆滞级库存量', available: true, tip: '库龄 3~12 个月 且 周转率 ≥ 100%;或 库龄 > 12 个月 且 100% ≤ 周转率 ≤ 199%' },
|
||||
{ key: 'zombieLevel', label: '商品僵尸级库存量', available: true, tip: '库龄 > 12 个月 且 周转率 ≥ 200%' },
|
||||
];
|
||||
|
||||
// 分级标签(与后端 levelExpr 返回值一致)
|
||||
export const LEVEL_LABEL = {
|
||||
warn: '滞销预警',
|
||||
slow: '商品慢流',
|
||||
stale: '商品呆滞',
|
||||
zombie: '商品僵尸',
|
||||
};
|
||||
|
||||
export const LEVEL_FILTER_OPTIONS = [
|
||||
{ label: '全部级别', value: '' },
|
||||
{ label: '滞销预警', value: 'warn' },
|
||||
{ label: '商品慢流', value: 'slow' },
|
||||
{ label: '商品呆滞', value: 'stale' },
|
||||
{ label: '商品僵尸', value: 'zombie' },
|
||||
];
|
||||
|
||||
export const LEVEL_COLOR = {
|
||||
warn: 'gold',
|
||||
slow: 'orange',
|
||||
stale: 'volcano',
|
||||
zombie: 'magenta',
|
||||
};
|
||||
|
||||
export function findWarehouseBySlug(slug) {
|
||||
return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.slug === slug) || null;
|
||||
}
|
||||
|
||||
export function findWarehouseByName(name) {
|
||||
return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.warehouse === name) || null;
|
||||
}
|
||||
|
||||
export const PAGE_KEY = 'large_stock_monitor';
|
||||
export const PAGE_PATH = '/planning/large-stock-monitor/:warehouseSlug';
|
||||
export const PAGE_NAME = '大货库存监控';
|
||||
|
||||
// 顶部"算式说明"提示
|
||||
export const FORMULA_DESCRIPTION = [
|
||||
'库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%(按业务库约定列名沿用「周转天数」,数值实际为周转率,单位 %)',
|
||||
'月度发货总件数 ≈ threeAvgSale × 30 (业务库仅有最近 3 天日均销量)',
|
||||
'月度日均库存件数 ≈ jsStock (业务库仅保留最近一天库存快照,作为日均近似)',
|
||||
'数据截止时刻:库存快照日次日的凌晨 01:00(业务库每天凌晨 1 点跑批,快照标注为前一日 T-1)',
|
||||
'库龄(月) = (数据截止时刻 - SKU 入库时间) / 30',
|
||||
'分级口径:',
|
||||
'· 滞销预警:库龄 < 3 个月 且 30% ≤ 周转天数 ≤ 199%',
|
||||
'· 商品慢流:库龄 < 3 个月 且 周转天数 ≥ 200%;或 库龄 > 3 个月 且 30% ≤ 周转天数 ≤ 99%',
|
||||
'· 商品呆滞:库龄 3~12 个月 且 周转天数 ≥ 100%;或 库龄 > 12 个月 且 100% ≤ 周转天数 ≤ 199%',
|
||||
'· 商品僵尸:库龄 > 12 个月 且 周转天数 ≥ 200%',
|
||||
'流水类字段(本期 = 快照所在自然月 1 日 00:00 ~ 数据截止时刻,左闭右开):',
|
||||
'· 采购进仓:jst_purchase + jst_purchaseItem.inQty(Confirmed/Finished)',
|
||||
'· 采购退货:jst_purchaseout + jst_purchaseoutItem.qty(Confirmed)',
|
||||
'· 销售出仓:jst_salesOutboundInfo 关联 jst_OrderItem.qty(Confirmed)',
|
||||
'· 销售退货:jst_returnAndRefundInfo + jst_returnAndRefundItem.r_qty(Confirmed,按仓库名匹配)',
|
||||
'· 期初数据:业务库无上月末快照,由「期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货」倒推(标「估」,倒推为负时显示「—」)',
|
||||
'· 加工进仓 / 加工出仓 / 盘点 / 调拨出 / 调拨入 / 其它出库:业务库无对应单据表,无数据源',
|
||||
'期末数量:聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓实物口径)',
|
||||
'注意:pm_stockMonitor 的 warehouse 是「归属仓」,jsStock 含已调拨到外仓的货,不能代表本仓实物库存',
|
||||
'四级库存量:只能由库存快照分级得出(实时库存表无入库时间与销量字段),口径为归属库存,与期末实际库存不同',
|
||||
];
|
||||
@ -4,7 +4,6 @@ const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest:
|
||||
export const SIDEBAR_PAGES = [
|
||||
{ pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
|
||||
{ pageKey: 'large_stock_monitor', path: '/planning/large-stock-monitor/:warehouseSlug', name: '大货库存监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
];
|
||||
|
||||
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
|
||||
|
||||
@ -36,7 +36,6 @@ import {
|
||||
markDesktopEmbedSession,
|
||||
requestDesktopSessionRenewal,
|
||||
} from '../utils/desktopEmbedSession';
|
||||
import { LARGE_STOCK_MONITOR_WAREHOUSES } from '../constants/largeStockMonitor.js';
|
||||
|
||||
const AUTH_USER_STORAGE_KEY = 'auth_user';
|
||||
const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode';
|
||||
@ -98,14 +97,6 @@ const SIDEBAR_ROUTE_MAP = {
|
||||
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
|
||||
};
|
||||
|
||||
const LARGE_STOCK_MONITOR_ROUTE_ENTRIES = LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => [
|
||||
`stock-monitor-large-${entry.idx}`,
|
||||
{ path: `/planning/large-stock-monitor/${entry.slug}`, matchMode: 'prefix' },
|
||||
]);
|
||||
|
||||
LARGE_STOCK_MONITOR_ROUTE_ENTRIES.forEach(([key, value]) => {
|
||||
SIDEBAR_ROUTE_MAP[key] = value;
|
||||
});
|
||||
|
||||
const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => {
|
||||
if (entry.requiresAdmin && !canUseSettings) return [];
|
||||
|
||||
@ -1,502 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
Popover,
|
||||
Segmented,
|
||||
Skeleton,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
InfoCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { SycmBusinessPanel, SycmPage } from '../../components/Common';
|
||||
import {
|
||||
findWarehouseBySlug,
|
||||
FORMULA_DESCRIPTION,
|
||||
LARGE_STOCK_MONITOR_WAREHOUSES,
|
||||
LEVEL_COLOR,
|
||||
LEVEL_FILTER_OPTIONS,
|
||||
LEVEL_LABEL,
|
||||
OVERVIEW_COLUMNS,
|
||||
} from '../../constants/largeStockMonitor';
|
||||
import {
|
||||
getLargeStockMonitorOverview,
|
||||
getLargeStockMonitorSkuList,
|
||||
} from '../../api/largeStockMonitor';
|
||||
import './style.css';
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return value;
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return value;
|
||||
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
function formatMonths(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return value;
|
||||
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 月`;
|
||||
}
|
||||
|
||||
function formatStock(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return value;
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
const OVERVIEW_RENDERERS = {
|
||||
periodStart: formatStock,
|
||||
openingBalance: formatStock,
|
||||
purchaseReturn: formatStock,
|
||||
purchaseIn: formatStock,
|
||||
salesOut: formatStock,
|
||||
salesReturn: formatStock,
|
||||
closingQty: formatStock,
|
||||
turnoverDays: formatPercent,
|
||||
warnLevel: formatStock,
|
||||
slowLevel: formatStock,
|
||||
staleLevel: formatStock,
|
||||
zombieLevel: formatStock,
|
||||
};
|
||||
|
||||
const FormulaHelp = () => (
|
||||
<div className="large-stock-monitor-formula">
|
||||
<div className="large-stock-monitor-formula__title">计算口径</div>
|
||||
<ul>
|
||||
{FORMULA_DESCRIPTION.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LargeStockMonitor = () => {
|
||||
const { warehouseSlug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const warehouse = useMemo(() => {
|
||||
if (!warehouseSlug) return null;
|
||||
return findWarehouseBySlug(warehouseSlug) || LARGE_STOCK_MONITOR_WAREHOUSES[0] || null;
|
||||
}, [warehouseSlug]);
|
||||
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [overviewError, setOverviewError] = useState(null);
|
||||
|
||||
const [skuListState, setSkuListState] = useState({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
loading: false,
|
||||
});
|
||||
const [filterLevel, setFilterLevel] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!warehouse) {
|
||||
setOverview(null);
|
||||
setOverviewLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setOverviewLoading(true);
|
||||
setOverviewError(null);
|
||||
getLargeStockMonitorOverview(warehouse.warehouse)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setOverview(res?.data || null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setOverviewError(error?.message || '获取概览失败');
|
||||
setOverview(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setOverviewLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [warehouse]);
|
||||
|
||||
const reloadSkuList = (nextPage, nextPageSize, nextLevel, nextKeyword) => {
|
||||
if (!warehouse) return;
|
||||
setSkuListState((prev) => ({ ...prev, loading: true }));
|
||||
getLargeStockMonitorSkuList({
|
||||
warehouse: warehouse.warehouse,
|
||||
page: nextPage,
|
||||
pageSize: nextPageSize,
|
||||
level: nextLevel,
|
||||
keyword: nextKeyword,
|
||||
})
|
||||
.then((res) => {
|
||||
setSkuListState({
|
||||
items: res?.data?.items || [],
|
||||
total: res?.data?.total || 0,
|
||||
page: nextPage,
|
||||
pageSize: nextPageSize,
|
||||
loading: false,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setSkuListState((prev) => ({ ...prev, loading: false }));
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!warehouse) return;
|
||||
reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [warehouse, filterLevel]);
|
||||
|
||||
const handleTableChange = (pagination) => {
|
||||
const nextPage = pagination.current || 1;
|
||||
const nextPageSize = pagination.pageSize || 20;
|
||||
reloadSkuList(nextPage, nextPageSize, filterLevel, keyword.trim());
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim());
|
||||
};
|
||||
|
||||
const overviewCells = useMemo(() => {
|
||||
const map = overview?.overview || null;
|
||||
return OVERVIEW_COLUMNS.map((column) => {
|
||||
const value = map ? map[column.key] : '—';
|
||||
const rendered = column.available && value !== '—' && OVERVIEW_RENDERERS[column.key]
|
||||
? OVERVIEW_RENDERERS[column.key](value)
|
||||
: value;
|
||||
return { ...column, value, rendered };
|
||||
});
|
||||
}, [overview]);
|
||||
|
||||
const skuColumns = useMemo(() => ([
|
||||
{
|
||||
title: 'SKU',
|
||||
dataIndex: 'sku',
|
||||
key: 'sku',
|
||||
width: 130,
|
||||
fixed: 'left',
|
||||
render: (value, record) => (
|
||||
<div className="large-stock-monitor-sku-cell">
|
||||
<strong>{value || '-'}</strong>
|
||||
{record.spu ? <span className="large-stock-monitor-sku-cell__meta">SPU {record.spu}</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'skuName',
|
||||
key: 'skuName',
|
||||
width: 260,
|
||||
render: (value) => <span title={value}>{value || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
key: 'supplier',
|
||||
width: 140,
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '当前库存',
|
||||
dataIndex: 'jsStock',
|
||||
key: 'jsStock',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: formatStock,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<span>
|
||||
库龄
|
||||
<Tooltip title="(今天 - 入库时间) / 30,单位:月">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'ageMonths',
|
||||
key: 'ageMonths',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: formatMonths,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<span>
|
||||
月度发货
|
||||
<Tooltip title="≈ threeAvgSale × 30(业务库仅有 3 天日均销量)">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'monthlyShipment',
|
||||
key: 'monthlyShipment',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: formatNumber,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<span>
|
||||
日均库存
|
||||
<Tooltip title="≈ jsStock(业务库仅保留最近一天快照,作为日均近似)">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'avgDailyStock',
|
||||
key: 'avgDailyStock',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: formatStock,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<span>
|
||||
库存周转天数
|
||||
<Tooltip title="= 月度发货 / 日均库存 × 100%(实际为周转率 %,沿用 Excel 列名)">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'turnoverDays',
|
||||
key: 'turnoverDays',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: formatPercent,
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 130,
|
||||
fixed: 'right',
|
||||
render: (value) => {
|
||||
if (!value) return <span style={{ color: '#999' }}>—</span>;
|
||||
return <Tag color={LEVEL_COLOR[value]}>{LEVEL_LABEL[value] || value}</Tag>;
|
||||
},
|
||||
},
|
||||
]), []);
|
||||
|
||||
const siblingOptions = useMemo(() => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({
|
||||
label: `${entry.group} · ${entry.warehouse.replace(/^简尚-/, '')}`,
|
||||
value: entry.slug,
|
||||
idx: entry.idx,
|
||||
group: entry.group,
|
||||
})), []);
|
||||
|
||||
const fallbackNotice = warehouse && overview && !overview.hasData;
|
||||
|
||||
return (
|
||||
<SycmPage className="large-stock-monitor-page">
|
||||
<SycmBusinessPanel className="large-stock-monitor-header" aria-label="大货库存监控概览">
|
||||
<div className="large-stock-monitor-header__top">
|
||||
<div>
|
||||
<h2>
|
||||
大货库存监控 · {warehouse ? warehouse.warehouse : '请选择仓库'}
|
||||
<Popover
|
||||
content={<FormulaHelp />}
|
||||
title="计算口径"
|
||||
trigger="hover"
|
||||
placement="rightTop"
|
||||
>
|
||||
<InfoCircleOutlined
|
||||
style={{ marginLeft: 8, fontSize: 16, color: '#1677ff', cursor: 'help' }}
|
||||
/>
|
||||
</Popover>
|
||||
</h2>
|
||||
<p className="large-stock-monitor-header__meta">
|
||||
<span>二级分类:{warehouse ? warehouse.group : '-'}</span>
|
||||
<span>分布时间:{overview?.asOf || overview?.latestBizDate || '—'}</span>
|
||||
{overview?.latestBizDate ? <span>库存快照日:{overview.latestBizDate}</span> : null}
|
||||
{overview?.closingTakenAt ? (
|
||||
<span>库存取数时间:{new Date(overview.closingTakenAt).toLocaleString('zh-CN', { hour12: false })}(实时)</span>
|
||||
) : null}
|
||||
{overview?.period ? (
|
||||
<span>
|
||||
流水区间:{overview.period.start} ~ {overview.period.end}(左闭右开)
|
||||
</span>
|
||||
) : null}
|
||||
<span>SKU 数:{overview?.skuCount ?? '—'}</span>
|
||||
<span>平均周转率:{overview?.turnoverDaysAvg != null ? formatPercent(overview.turnoverDaysAvg) : '—'}</span>
|
||||
<span>最大周转率:{overview?.turnoverDaysMax != null ? formatPercent(overview.turnoverDaysMax) : '—'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="large-stock-monitor-header__actions">
|
||||
<Segmented
|
||||
value={warehouse?.slug}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
navigate(`/planning/large-stock-monitor/${value}`);
|
||||
}
|
||||
}}
|
||||
options={siblingOptions.slice(0, 6)}
|
||||
className="large-stock-monitor-quick-switch"
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
if (warehouse) navigate(`/planning/large-stock-monitor/${warehouse.slug}`);
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overviewError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={`获取概览失败:${overviewError}`}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{fallbackNotice ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<WarningOutlined />}
|
||||
message="业务库暂未同步该仓库的库存快照,周转率与四级库存量无数据源,显示「—」"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{overview && overview.hasGrade === false ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="该仓库没有库存快照(pm_stockMonitor),库龄、周转天数与四级库存量无数据源,显示「—」;期末数量已按聚水潭实际库存取数"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{overview?.hasGrade && overview?.gradeBasisStock ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<WarningOutlined />}
|
||||
message={`期末数量按聚水潭实际库存统计;四级库存量由库存快照分级得出,快照为「归属仓」口径(含已调拨到外仓的货,合计 ${formatStock(overview.gradeBasisStock)}),两者口径不同,不能直接相加核对`}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{overview?.requestedDate && overview?.fallbackDate ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`未找到 ${overview.requestedDate} 的库存快照,已自动回退并展示 ${overview.fallbackDate} 的数据`}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{overview?.requestedDate && !overview?.fallbackDate && !overview?.hasData ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`未找到 ${overview.requestedDate} 及之前的库存快照,当前汇总字段全部为占位符「—」`}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="large-stock-monitor-overview">
|
||||
{overviewLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 2 }} />
|
||||
) : (
|
||||
<div className="large-stock-monitor-grid">
|
||||
{overviewCells.map((cell) => (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={`large-stock-monitor-grid__cell${cell.available ? ' is-available' : ' is-placeholder'}`}
|
||||
>
|
||||
<span className="large-stock-monitor-grid__label">
|
||||
{cell.label}
|
||||
{cell.estimated ? <em className="large-stock-monitor-grid__est">估</em> : null}
|
||||
{cell.tip ? (
|
||||
<Tooltip title={cell.tip}>
|
||||
<QuestionCircleOutlined className="large-stock-monitor-grid__help" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
<strong className="large-stock-monitor-grid__value">{cell.rendered}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SycmBusinessPanel>
|
||||
|
||||
<SycmBusinessPanel className="large-stock-monitor-table" aria-label="SKU 库存明细">
|
||||
<div className="large-stock-monitor-toolbar">
|
||||
<span className="large-stock-monitor-toolbar__note">
|
||||
明细按库存快照(归属仓口径)分级,用于识别滞销/慢流/呆滞/僵尸 SKU
|
||||
</span>
|
||||
<Segmented
|
||||
value={filterLevel}
|
||||
onChange={setFilterLevel}
|
||||
options={LEVEL_FILTER_OPTIONS}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索 SKU 编码或商品名称"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onSearch={handleSearch}
|
||||
enterButton={<><SearchOutlined /> 查询</>}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
className="large-stock-monitor-data-table"
|
||||
rowKey={(record) => `${record.sku}-${record.warehouse}`}
|
||||
columns={skuColumns}
|
||||
dataSource={skuListState.items}
|
||||
loading={skuListState.loading}
|
||||
scroll={{ x: 1280 }}
|
||||
pagination={{
|
||||
current: skuListState.page,
|
||||
pageSize: skuListState.pageSize,
|
||||
total: skuListState.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
locale={{ emptyText: '该仓库暂无 SKU 库存快照' }}
|
||||
/>
|
||||
</SycmBusinessPanel>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default LargeStockMonitor;
|
||||
@ -1,149 +0,0 @@
|
||||
.large-stock-monitor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sycm-space-md);
|
||||
padding: var(--sycm-space-md);
|
||||
}
|
||||
|
||||
.large-stock-monitor-header__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.large-stock-monitor-header__top h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
}
|
||||
|
||||
.large-stock-monitor-header__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
color: var(--sycm-text-secondary);
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.large-stock-monitor-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.large-stock-monitor-quick-switch {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.large-stock-monitor-overview {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.large-stock-monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.large-stock-monitor-grid__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-radius: var(--sycm-radius-sm);
|
||||
background: var(--sycm-bg-subtle);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.large-stock-monitor-grid__cell.is-available {
|
||||
background: var(--sycm-bg-elevated, #fff);
|
||||
border-color: var(--sycm-color-primary-border);
|
||||
}
|
||||
|
||||
.large-stock-monitor-grid__cell.is-placeholder {
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.large-stock-monitor-grid__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
font-weight: var(--sycm-font-weight-medium);
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
/* 估算值标记:由已有字段推导,非业务库原始字段 */
|
||||
.large-stock-monitor-grid__est {
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--sycm-color-warning-bg, #fffbe6);
|
||||
border: 1px solid var(--sycm-color-warning-border, #ffe58f);
|
||||
color: var(--sycm-color-warning-text, #d46b08);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.large-stock-monitor-grid__help {
|
||||
color: var(--sycm-text-tertiary);
|
||||
cursor: help;
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
}
|
||||
|
||||
.large-stock-monitor-grid__value {
|
||||
font-size: var(--sycm-font-size-title);
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
color: var(--sycm-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.large-stock-monitor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.large-stock-monitor-toolbar__note {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.large-stock-monitor-data-table .large-stock-monitor-sku-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.large-stock-monitor-data-table .large-stock-monitor-sku-cell__meta {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: var(--sycm-font-size-micro);
|
||||
}
|
||||
|
||||
.large-stock-monitor-formula {
|
||||
max-width: 480px;
|
||||
font-size: var(--sycm-font-size-caption);
|
||||
color: var(--sycm-text-secondary);
|
||||
}
|
||||
|
||||
.large-stock-monitor-formula__title {
|
||||
font-weight: var(--sycm-font-weight-semibold);
|
||||
color: var(--sycm-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.large-stock-monitor-formula ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@ -19,7 +19,6 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent
|
||||
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
||||
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
|
||||
const LargeStockMonitor = lazy(() => import('../pages/LargeStockMonitor'));
|
||||
|
||||
const lazyLoadingElement = <PageLoadingFallback />;
|
||||
|
||||
@ -132,10 +131,6 @@ const router = createBrowserRouter([
|
||||
path: 'planning/dev-style-preview',
|
||||
element: renderLazyComponent(DevelopmentStylePreview),
|
||||
},
|
||||
{
|
||||
path: 'planning/large-stock-monitor/:warehouseSlug',
|
||||
element: renderLazyComponent(LargeStockMonitor),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: renderLazyComponent(Settings),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user