Compare commits

..

No commits in common. "main" and "codex/ssa-zero-export-0927" have entirely different histories.

53 changed files with 62 additions and 11011 deletions

View File

@ -1,153 +0,0 @@
'use strict';
/**
* 用业务库(JSReportReplica)反算流水,校验已导入的聚水潭官方进销存数据。
* 用法(先起隧道): cd backend && node scripts/validateDailyInventoryFromBiz.js 2026-10-06
*
* 注意:业务库 jst_inventoryInfo 是 T-1 同步快照,只能精确校验「昨天」的期末;
* 更早的历史日会带自然变动(实测 10-05 差 0.1%~1.2%),属正常。
*/
const mysql = require('mysql2/promise');
const sql = require('mssql');
const BIZ_DATE = process.argv[2] || '2026-10-06';
const SYS = {
host: '127.0.0.1',
port: 13307,
user: 'cangku_67c48c6660be787f',
password: 'wIJugzi26RQHF4J4BgnwxCxxecp157goR93M_IXEhqc',
database: 'shujuku2_system',
};
// 业务库能还原的 4 个流水列(与 dailyInventoryFlows.js 口径一致,这里加单日上界)
const FLOWS = [
{
field: 'purchase_in',
label: '采购进仓',
sql: `SELECT p.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(pi.qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_purchase p JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id
WHERE p.wms_co_id IN (%W%) AND p.po_date >= @from AND p.po_date < @to
AND ISNULL(p.status,'') IN ('Confirmed','Finished') GROUP BY p.wms_co_id`,
},
{
field: 'purchase_return',
label: '采购退货',
sql: `SELECT o.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(oi.qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_purchaseout o JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id
WHERE o.wms_co_id IN (%W%) AND o.io_date >= @from AND o.io_date < @to
GROUP BY o.wms_co_id`,
},
{
field: 'sales_out',
label: '销售出仓',
sql: `SELECT o.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(oi.qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_Orders o JOIN rpt.jst_OrderItem oi ON oi.o_id = o.o_id
WHERE o.wms_co_id IN (%W%) AND o.send_date >= @from AND o.send_date < @to
AND ISNULL(o.status,'') NOT IN ('Delete','Cancelled') GROUP BY o.wms_co_id`,
},
{
field: 'sales_return',
label: '销售退货',
sql: `SELECT r.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(ri.r_qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_returnAndRefundInfo r JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id
WHERE r.wms_co_id IN (%W%) AND r.as_date >= @from AND r.as_date < @to
AND ISNULL(r.type,'') IN (N'普通退货',N'拒收退货',N'换货',N'维修') GROUP BY r.wms_co_id`,
},
];
(async () => {
const sys = await mysql.createConnection(SYS);
const [official] = await sys.query(
'SELECT wms_co_id, warehouse, opening_qty, closing_qty, purchase_in, purchase_return, sales_out, sales_return FROM daily_inventory_official WHERE DATE(biz_date)=? ORDER BY closing_qty DESC',
[BIZ_DATE]
);
if (!official.length) {
console.log('官方表无 ' + BIZ_DATE + ' 数据');
await sys.end();
return;
}
const ids = official.map((r) => String(r.wms_co_id)).filter(Boolean);
const clause = ids.map((_, i) => '@w' + i).join(',');
const wparams = {};
ids.forEach((v, i) => (wparams['w' + i] = v));
// 业务库凭据从系统库 database_configs 现取(不落盘)
const [cfgRows] = await sys.query(
"SELECT host, port, database_name, username, password FROM database_configs WHERE name='cloud_database_public' LIMIT 1"
);
const biz = cfgRows[0];
await sql.connect({
server: biz.host,
port: Number(biz.port),
database: biz.database_name,
user: biz.username,
password: biz.password,
options: { encrypt: false, trustServerCertificate: true },
connectionTimeout: 20000,
requestTimeout: 180000,
});
const from = BIZ_DATE + ' 00:00:00';
const d = new Date(BIZ_DATE + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + 1);
const to = d.toISOString().slice(0, 10) + ' 00:00:00';
const computed = {};
for (const f of FLOWS) {
const req = new sql.Request();
req.input('from', sql.DateTime, new Date(from));
req.input('to', sql.DateTime, new Date(to));
ids.forEach((v, i) => req.input('w' + i, sql.NVarChar, v));
const r = await req.query(f.sql.replace('%W%', clause));
r.recordset.forEach((row) => {
computed[String(row.wmsCoId)] = computed[String(row.wmsCoId)] || {};
computed[String(row.wmsCoId)][f.field] = Number(row.qty) || 0;
});
}
console.log('业务库口径反算 vs 聚水潭官方导出 —— ' + BIZ_DATE);
console.log('(业务库仅能还原 4/12 列;采购退货/销售出仓为流出,官方值取绝对值比较)\n');
const pad = (s, n) => String(s).padEnd(n);
console.log(
pad('仓库', 26) + pad('采购进仓 官方/业务库', 26) + pad('采购退货 官方/业务库', 26) + pad('销售出仓 官方/业务库', 26) + '销售退货 官方/业务库'
);
let diffs = { purchase_in: 0, sales_out: 0, purchase_return: 0, sales_return: 0 };
for (const o of official) {
const c = computed[String(o.wms_co_id)] || {};
const cells = ['purchase_in', 'purchase_return', 'sales_out', 'sales_return'].map((k) => {
const ov = Math.abs(Number(o[k]) || 0);
const cv = Math.abs(Number(c[k]) || 0);
if (Math.abs(ov - cv) > 0.005) diffs[k]++;
return pad(ov + ' / ' + cv, 26);
});
console.log(pad(o.warehouse, 26) + cells.join(''));
}
console.log('\n不一致仓数:' + JSON.stringify(diffs) + ' / 共 ' + official.length + ' 仓');
// 期末 vs 业务库实时库存(仅作量级参考,业务库只有实时库存)
const req2 = new sql.Request();
ids.forEach((v, i) => req2.input('w' + i, sql.NVarChar, v));
const inv = await req2.query(
`SELECT wms_co_id AS wmsCoId, SUM(CAST(ISNULL(qty,0) AS DECIMAL(18,2))) AS qty
FROM rpt.jst_inventoryInfo WHERE wms_co_id IN (${clause}) GROUP BY wms_co_id`
);
const invMap = {};
inv.recordset.forEach((r) => (invMap[String(r.wmsCoId)] = Number(r.qty) || 0));
console.log('\n【参考】官方' + BIZ_DATE + '期末 vs 业务库实时库存(业务库无历史快照,仅看量级)');
official
.slice()
.sort((a, b) => Number(b.closing_qty) - Number(a.closing_qty))
.slice(0, 8)
.forEach((o) => {
const live = invMap[String(o.wms_co_id)] || 0;
const off = Number(o.closing_qty) || 0;
const pct = off ? (((live - off) / off) * 100).toFixed(1) : '-';
console.log(pad(o.warehouse, 26) + '期末 ' + off + ' | 实时 ' + live + ' | 差 ' + pct + '%');
});
await sql.close();
await sys.end();
})().catch((e) => {
console.log('FAIL', e.message);
process.exit(1);
});

View File

@ -1,134 +0,0 @@
'use strict';
const express = require('express');
const multer = require('multer');
const {
clearPickBins,
getPickBinMeta,
getReplenishExportRows,
getReplenishList,
importPickBins,
resolveWarehouseKey,
} = require('../services/binReplenishService');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024 },
});
const EXPORT_COLUMNS = [
{ key: 'skuId', title: '商品编码' },
{ key: 'productName', title: '品名' },
{ key: 'supplier', title: '供应商' },
{ key: 'statusLabel', title: '状态' },
{ key: 'pickQty', title: '拣货位库存' },
{ key: 'storageQty', title: '存货位库存' },
{ key: 'totalQty', title: '现有库存合计' },
{ key: 'targetQty', title: '目标拣货位库存' },
{ key: 'suggestQty', title: '建议补货量' },
{ key: 'avgSale', title: '近15天日销' },
{ key: 'pickBinText', title: '拣货位(货位号/数量)' },
{ key: 'storageBinText', title: '存货位(前5个/数量)' },
];
/** GET /api/bin-replenish/list —— 缺货补货监控列表 */
router.get('/list', async (req, res, next) => {
try {
const payload = await getReplenishList({
warehouse: req.query.warehouse,
keyword: req.query.keyword,
status: req.query.status,
page: req.query.page,
pageSize: req.query.pageSize,
targetDays: req.query.targetDays,
refresh: req.query.refresh,
});
res.json({ success: true, data: payload });
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/export —— 服务端全量导出 */
router.get('/export', async (req, res, next) => {
try {
const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const payload = await getReplenishExportRows({
warehouse: req.query.warehouse,
keyword: req.query.keyword,
status: req.query.status,
targetDays: req.query.targetDays,
});
const rows = payload.rows.map((row) => ({
...row,
pickBinText: (row.pickBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
storageBinText: (row.storageBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
}));
sendWorkbookDownload(res, {
fileName: `${payload.warehouse.shortName}缺货补货监控_${new Date().toISOString().slice(0, 10)}`,
format,
sheets: [{
name: '缺货补货监控',
header: EXPORT_COLUMNS.map((column) => column.title),
rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
}],
});
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/pick-bins —— 当前仓库的拣货位清单概况 */
router.get('/pick-bins', async (req, res, next) => {
try {
const meta = await getPickBinMeta(resolveWarehouseKey(req.query.warehouse));
res.json({ success: true, data: meta });
} catch (error) {
next(error);
}
});
/** POST /api/bin-replenish/pick-bins —— 导入拣货位清单(xlsx / xls / csv / txt) */
router.post('/pick-bins', upload.single('file'), async (req, res, next) => {
try {
if (!req.file || !req.file.buffer) {
res.status(400).json({ success: false, message: '请选择要导入的货位清单文件(xlsx / xls / csv)' });
return;
}
// multer 按 latin1 解析 multipart 文件名,中文会乱码,这里转回 UTF-8
const rawName = req.file.originalname || '';
const fileName = /[\u00c0-\u00ff]/.test(rawName)
? Buffer.from(rawName, 'latin1').toString('utf8')
: rawName;
const result = await importPickBins({
buffer: req.file.buffer,
fileName,
warehouseKey: req.body?.warehouse || req.query.warehouse,
importedBy: req.user?.username || req.user?.name || null,
});
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
/** DELETE /api/bin-replenish/pick-bins —— 清除货位清单,回到回退口径 */
router.delete('/pick-bins', async (req, res, next) => {
try {
const result = await clearPickBins(req.query.warehouse);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */
router.get('/ping', (req, res) => {
res.json({ success: true, data: { ok: true } });
});
module.exports = router;

View File

@ -1,282 +0,0 @@
const express = require('express');
const cloudDb = require('../config/cloudDatabase');
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_TOTAL_LABEL,
PICKING_BIN_BIN_TYPES,
} = require('../config/pickingBinSalesAnalysis');
const { fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */
const AS_OF_TIME = '01:00';
/** 结果缓存:一次要扫订单明细 + 仓位明细两张大表,避免连续刷新重复打库 */
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
const cacheByKey = new Map();
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
const round4 = (v) => Math.round((Number(v) || 0) * 10000) / 10000;
/** 快照日 + 次日 01:00 作为统一的统计基准时刻 */
function nextDayAsOf(bizDate) {
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
const d = new Date(`${bizDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
}
/** 发货窗口:[asOf - N 天, asOf) */
function buildShipWindow(asOf, days) {
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
const from = new Date(to.getTime() - days * 86400000);
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
return { shipFrom: fmt(from), shipTo: fmt(to) };
}
async function fetchLatestBizDate() {
const rows = await cloudDb.query(
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
);
return rows[0]?.d || null;
}
async function fetchBinTakenAt() {
const rows = await cloudDb.query(
`SELECT MAX(updateTime) AS takenAt FROM rpt.jst_packInventoryInfo`
);
return rows[0]?.takenAt || null;
}
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
function resolveDays(raw) {
const n = Number.parseInt(String(raw ?? ''), 10);
return PICKING_BIN_WINDOW_OPTIONS.includes(n) ? n : PICKING_BIN_DEFAULT_DAYS;
}
/** 解析仓库过滤,只允许出现在仓库清单里的 wmsCoId,防止任意拼参 */
function resolveWarehouses(raw) {
const known = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
const requested = String(raw || '')
.split(',')
.map((v) => v.trim())
.filter(Boolean);
const picked = requested.length > 0
? known.filter((b) => requested.includes(String(b.wmsCoId)))
: known;
return picked;
}
const GRADE_MAP = new Map(PICKING_BIN_GRADE_RULES.map((g) => [g.key, g]));
/** 明细行按「仓库 × 商品编码」去重后的商品个数 */
function dedupeCount(list) {
return new Set(list.map((it) => `${it.wmsCoId}::${it.skuId}`)).size;
}
/**
* GET /api/picking-bin-sales-analysis
* 拣选库位动销分析:仓库 × 仓位 × 商品编码 → 库存数量 / 商品销量 / 商品日销 / 动销等级
* 动销等级按「商品日销」判定(商品整档销量 ÷ 天数),不做仓位分摊。
*/
router.get('/', async (req, res) => {
try {
const force = String(req.query?.refresh || '') === '1';
const days = resolveDays(req.query?.days);
const warehouses = resolveWarehouses(req.query?.wmsCoId);
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
const cacheKey = `${days}::${wmsCoIds.join('|')}`;
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const cached = cacheByKey.get(cacheKey);
if (!force && cached && cached.latestBizDate === latestBizDate
&& Date.now() - cached.at < RESULT_CACHE_TTL_MS) {
return res.json({ success: true, data: cached.payload, cached: true });
}
const asOf = nextDayAsOf(latestBizDate);
if (!asOf) {
return res.status(503).json({
success: false,
error: '业务库暂无库存监控数据,无法定位统计基准日',
});
}
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
const [rows, takenAt] = await Promise.all([
fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
fetchBinTakenAt().catch(() => null),
]);
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
const items = rows.map((r) => ({
wmsCoId: r.wmsCoId,
binName: r.binName,
binType: r.binType,
skuId: r.skuId,
warehouse: whMap.get(r.wmsCoId) || r.wmsCoId,
stockQty: r.stockQty,
skuCount: r.skuCount,
salesQty: round2(r.salesQty),
dailySales: round4(r.dailySales),
gradeKey: r.gradeKey,
gradeLabel: GRADE_MAP.get(r.gradeKey)?.label || '',
}));
// 每个「仓库 × 商品」只计一次销量,避免同一商品跨多个仓位被重复累加
const skuSalesMap = new Map();
rows.forEach((r) => {
const key = `${r.wmsCoId}::${r.skuId}`;
if (!skuSalesMap.has(key)) skuSalesMap.set(key, r.salesQty);
});
const dedupeSales = (list) => {
const seen = new Set();
return list.reduce((acc, it) => {
const key = `${it.wmsCoId}::${it.skuId}`;
if (seen.has(key)) return acc;
seen.add(key);
return acc + (skuSalesMap.get(key) || 0);
}, 0);
};
// 各动销等级汇总:商品数按「仓库 × 商品」去重,库存按明细行求和(无重复)
const summary = PICKING_BIN_GRADE_RULES.map((grade) => {
const hit = items.filter((it) => it.gradeKey === grade.key);
const salesQty = round2(dedupeSales(hit));
return {
key: grade.key,
label: grade.label,
tip: grade.tip,
rowCount: hit.length,
skuCount: dedupeCount(hit),
stockQty: round2(hit.reduce((acc, it) => acc + it.stockQty, 0)),
salesQty,
dailySales: round4(salesQty / days),
};
});
const dedupeAll = new Set(items.map((it) => `${it.wmsCoId}::${it.skuId}`));
const totalSales = round2(dedupeSales(items));
const total = {
key: '__total__',
label: PICKING_BIN_TOTAL_LABEL,
rowCount: items.length,
skuCount: dedupeAll.size,
stockQty: round2(summary.reduce((acc, s) => acc + s.stockQty, 0)),
salesQty: totalSales,
dailySales: round4(totalSales / days),
};
const payload = {
days,
windowOptions: PICKING_BIN_WINDOW_OPTIONS,
binTypes: [...PICKING_BIN_BIN_TYPES],
binTypeOptions: [...PICKING_BIN_BIN_TYPES],
latestBizDate,
asOf,
shipWindow: { shipFrom, shipTo, days },
takenAt,
grades: PICKING_BIN_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
totalLabel: PICKING_BIN_TOTAL_LABEL,
warehouses: warehouses.map((b) => ({ wmsCoId: String(b.wmsCoId), warehouse: b.warehouse })),
items,
summary,
total,
};
cacheByKey.set(cacheKey, { latestBizDate, at: Date.now(), payload });
return res.json({ success: true, data: payload });
} catch (error) {
console.error('[picking-bin-sales-analysis] 查询失败:', error.message);
return res.status(500).json({
success: false,
error: '拣选库位动销分析数据查询失败',
detail: error.message,
});
}
});
/**
* GET /api/picking-bin-sales-analysis/export
* 导出库位明细(只含拣选位)。query: days、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv)
*/
router.get('/export', async (req, res) => {
try {
const days = resolveDays(req.query?.days);
const warehouses = resolveWarehouses(req.query?.wmsCoId);
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const grades = String(req.query?.grade || '')
.split(',')
.map((v) => v.trim())
.filter((v) => GRADE_MAP.has(v));
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const asOf = nextDayAsOf(latestBizDate);
if (!asOf) {
return res.status(503).json({
success: false,
error: '业务库暂无库存监控数据,无法定位统计基准日',
});
}
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
// 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行
const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
const filtered = grades.length > 0
? rows.filter((r) => grades.includes(r.gradeKey))
: rows;
const sheetRows = filtered.map((r) => [
whMap.get(r.wmsCoId) || r.wmsCoId,
r.binName,
r.binType,
r.skuId,
r.stockQty,
r.skuCount,
round2(r.salesQty),
round4(r.allocatedSalesQty),
round4(r.dailySales),
GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey,
days,
latestBizDate || '',
]);
const fileName = `拣选库位动销分析-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
const { totalRows } = sendWorkbookDownload(res, {
fileName,
format,
sheets: [{
name: '库位动销明细',
header: [
'仓库', '仓位', '类型', '商品编码', '库存数量', '仓位SKU数',
`商品销量(近${days}天)`, `分摊销量(近${days}天)`, '商品日销',
'动销等级', '统计天数', '统计基准日',
],
rows: sheetRows,
}],
});
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 仅拣选位, 窗口 ${days} 天`);
return null;
} catch (error) {
console.error('[picking-bin-sales-analysis] 导出失败:', error.message);
if (!res.headersSent) {
return res.status(500).json({
success: false,
error: '拣选库位动销分析导出失败',
detail: error.message,
});
}
return null;
}
});
module.exports = router;

View File

@ -243,7 +243,6 @@ router.get('/zero-sales-export', async (req, res) => {
base?.warehouse || r.wmsCoId, base?.warehouse || r.wmsCoId,
r.skuId, r.skuId,
r.skuName, r.skuName,
r.binName,
r.stockQty, r.stockQty,
r.salesQty, r.salesQty,
days, days,
@ -258,7 +257,7 @@ router.get('/zero-sales-export', async (req, res) => {
format, format,
sheets: [{ sheets: [{
name: '销售=0明细', name: '销售=0明细',
header: ['仓库', 'SKU编码', 'SKU名称', '系统仓位', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'], header: ['仓库', 'SKU编码', 'SKU名称', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'],
rows: sheetRows, rows: sheetRows,
}], }],
}); });

View File

@ -1,173 +0,0 @@
'use strict';
/**
* 仓储中心物流转运异常 —— HTTP 路由(2026-10-21 新增)
* ────────────────────────────────────────────────────────────────────────────
* GET /api/warehouse-logistics-exception/list
* query:
* date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天)
* limit 明细条数上限(默认 500,最大 2000)
* force 1 = 绕过 5 分钟结果缓存
* GET /api/warehouse-logistics-exception/export
* 同 /list 口径的不分页明细(导出用,上限 5000)
*
* 每天 09:10 推 Excel 到钉钉群(2026-10-22 新增):
* GET /api/warehouse-logistics-exception/notify/settings 读配置(AppSecret 只回掩码)
* PUT /api/warehouse-logistics-exception/notify/settings 保存配置(AppSecret 留空=不变)
* POST /api/warehouse-logistics-exception/notify/run 立即推送一次(force / dryRun)
* GET /api/warehouse-logistics-exception/notify/state 上次推送结果
*
* 挂载见 src/index.js:`/api/warehouse-logistics-exception` + authMiddleware
*/
const express = require('express');
const {
getLogisticsExceptions,
getLogisticsExceptionExportRows,
} = require('../services/warehouseLogisticsExceptionService');
const {
CENTERS, EXCEPTION_TYPES, LOOKBACK_DAYS, VERIFY_HOUR,
} = require('../services/warehouseLogisticsExceptionRegistry');
const {
getWarehouseLogisticsExceptionNotifyAdminConfig,
loadWarehouseLogisticsExceptionNotifyState,
missingFields,
saveWarehouseLogisticsExceptionNotifyConfig,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
} = require('../services/warehouseLogisticsExceptionNotifySettings');
const {
pushWarehouseLogisticsExceptionNotify,
} = require('../jobs/warehouseLogisticsExceptionNotifyJob');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[WarehouseLogisticsException]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// 转运异常总览:12 个发货仓异常单量 + 明细
router.get('/list', async (req, res) => {
try {
const data = await getLogisticsExceptions(req.query || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '加载物流转运异常失败');
}
});
// 导出用:与 /list 同一套口径,只是把明细上限放开(不分页)
// 页面筛选(仓 / 异常类型)由前端用同一份过滤函数处理,保证导出的就是页面看到的
router.get('/export', async (req, res) => {
try {
const data = await getLogisticsExceptionExportRows(req.query || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '导出物流转运异常失败');
}
});
// 口径元数据(不查库,秒回):页面文案与图例用它,避免前后端各写一套
router.get('/meta', (_req, res) => {
res.json({
success: true,
data: {
verifyHour: VERIFY_HOUR,
lookbackDays: LOOKBACK_DAYS,
centers: CENTERS.map((c) => ({ key: c.key, name: c.name })),
exceptionTypes: EXCEPTION_TYPES,
},
});
});
// ── 每天推送到钉钉群 ────────────────────────────────────────────────────────
// 读配置:AppSecret 只回掩码 + 是否已配置,明文永不出后端
router.get('/notify/settings', async (_req, res) => {
try {
const [config, state] = await Promise.all([
getWarehouseLogisticsExceptionNotifyAdminConfig(),
loadWarehouseLogisticsExceptionNotifyState(),
]);
res.json({ success: true, data: { ...config, lastState: state || {} } });
} catch (error) {
sendError(res, error, '读取异常通知配置失败');
}
});
// 保存配置;AppSecret 留空表示「沿用已存的」,不必每次重填
router.put('/notify/settings', async (req, res) => {
try {
const data = await saveWarehouseLogisticsExceptionNotifyConfig(req.body || {});
res.json({ success: true, data });
} catch (error) {
const status = Number(error?.status) || 400;
console.error('[WarehouseLogisticsException] 保存通知配置失败:', error?.message);
res.status(status).json({ success: false, error: error.message || '保存异常通知配置失败' });
}
});
router.get('/notify/state', async (_req, res) => {
try {
const state = await loadWarehouseLogisticsExceptionNotifyState();
res.json({ success: true, data: state || {} });
} catch (error) {
sendError(res, error, '读取推送状态失败');
}
});
/**
* 立即推送一次(页面「立即推送」/「预览」/「测试通道」)。
* body:
* dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径)
* probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通)
* force true = 忽略「今天已经推过」(默认手动触发就是 true,除非显式给 false)
* date 指定核验日
*/
router.post('/notify/run', async (req, res) => {
try {
const body = req.body || {};
const dryRun = body.dryRun === true || body.dryRun === 'true';
const probe = body.probe === true || body.probe === 'true';
const force = body.force === undefined ? true : (body.force === true || body.force === 'true');
if (!dryRun) {
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
const missing = missingFields(config);
if (missing.length) {
res.status(400).json({
success: false,
error: `请先补全并保存:${missing.join(' / ')}`,
data: { missingFields: missing },
});
return;
}
}
const result = await pushWarehouseLogisticsExceptionNotify({
dryRun,
probe,
force,
date: body.date || undefined,
});
// 查完这一轮记得让页面上的状态同步(lastState 由前端重新拉 settings 获取)
res.json({ success: true, data: result });
} catch (error) {
const status = Number(error?.status) || 500;
console.error('[WarehouseLogisticsException] 推送失败:', {
message: error?.message,
code: error?.code,
dingtalk: error?.dingtalk,
});
res.status(status).json({
success: false,
error: error.message || '推送到钉钉群失败',
data: { code: error?.code ?? null },
});
}
});
module.exports = router;

View File

@ -1,62 +0,0 @@
'use strict';
/**
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* GET /api/warehouse-ship-timeliness/list
* query:
* round 'NOON' | 'AFTERNOON' 手动指定核验轮次;不传按当前时刻自动判定
* limit 明细条数上限(默认 500,最大 2000)
*
* GET /api/warehouse-ship-timeliness/rounds
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
*
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
*/
const express = require('express');
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[WarehouseShipTimeliness]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// 时效总览:每个主仓「超时未发」数量 + 超时订单明细
router.get('/list', async (req, res) => {
try {
const data = await getShipTimeliness(req.query || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '加载仓储中心发货时效失败');
}
});
// 仅轮次信息(不查订单)
router.get('/rounds', async (_req, res) => {
try {
res.json({
success: true,
data: {
rounds: describeRounds(new Date()),
centers: CENTERS.map((c) => ({
key: c.key,
name: c.name,
placeholder: Boolean(c.placeholder),
})),
},
});
} catch (error) {
sendError(res, error, '加载核验轮次失败');
}
});
module.exports = router;

View File

@ -764,20 +764,7 @@ async function connectWithCandidate(candidate) {
} }
async function queryWithCandidateConnection(candidate, sqlText, params = {}, options = {}) { async function queryWithCandidateConnection(candidate, sqlText, params = {}, options = {}) {
// request.timeout(请求级)在 mssql 12.x 上实测不生效(始终按池配置的默认值掐断), const nextPool = new sql.ConnectionPool(candidate.mssqlConfig);
// 慢查询必须通过「池配置级 requestTimeout」放宽,这里允许调用方覆盖候选连接的池级超时。
let mssqlConfig = candidate.mssqlConfig;
const poolRequestTimeout = parsePositiveInt(options.connectRequestTimeoutMs, 0);
if (poolRequestTimeout > 0) {
mssqlConfig = {
...candidate.mssqlConfig,
options: {
...candidate.mssqlConfig.options,
requestTimeout: poolRequestTimeout,
},
};
}
const nextPool = new sql.ConnectionPool(mssqlConfig);
try { try {
await nextPool.connect(); await nextPool.connect();
@ -1106,28 +1093,6 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
return []; return [];
}; };
/**
* 慢查询专用入口:走独立临时连接,池级 requestTimeout 放宽到 SLOW_QUERY_POOL_TIMEOUT_MS。
* 背景:mssql 12.x 的 request.timeout(请求级)不生效,超过池默认 60s 的查询必须用
* 池配置级 requestTimeout 才能放宽。适用场景:动销分析这类多表大窗口聚合。
*/
const SLOW_QUERY_POOL_TIMEOUT_MS = parsePositiveInt(
process.env.CLOUD_DB_SLOW_QUERY_TIMEOUT_MS,
300000
);
const querySlow = async (sqlText, params = {}, options = {}) => {
const poolRequestTimeout = parsePositiveInt(
options.connectRequestTimeoutMs,
SLOW_QUERY_POOL_TIMEOUT_MS
);
return queryWithFreshConnection(sqlText, params, {
...options,
connectRequestTimeoutMs: poolRequestTimeout,
requestTimeout: poolRequestTimeout,
});
};
/** /**
* 测试连接 * 测试连接
*/ */
@ -1178,7 +1143,6 @@ const reload = async () => {
module.exports = { module.exports = {
getPool, getPool,
query, query,
querySlow,
queryWithFreshConnection, queryWithFreshConnection,
createPinnedQueryClient, createPinnedQueryClient,
test, test,

View File

@ -1,78 +0,0 @@
/**
* 拣选库位动销分析 —— 口径配置(纯规则,不含业务数值)
*
* 一级菜单:大货库存监控
* 二级菜单:拣选库位动销分析
*
* 表头(需求表原列为 所在仓库 | 仓位 | 数量 | 类型 | 动销等级,这里做了扩充与优化):
* 仓库 | 仓位 | 类型 | 商品编码 | 库存数量 | 近N天销量 | 日销 | 动销等级
*
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用。
*
* 口径:
* 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty
* 统计范围:只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);
* 箱记录(pack_type=Pack)与存货位(bin_group=存货)不纳入
* 销量: 4 个来源分支,均按 send_date 窗口(详见 services/pickingBinSalesAnalysis.js):
* ① jst_Orders 普通行;② jst_Orders 组合装行 → jst_comboProductItem 展开为子件;
* ③ jst_qmcust_orderlist(全民客户 B2B 分销)普通行(排除拆单父单 Split);
* ④ qmcust 组合装行 → 同样展开。
* 日销: 商品窗口销量 / 窗口天数(整档,不按仓位分摊)
* 等级: 严格按商品日销判定,同一商品占多少个仓位都是同一等级
*
* 【为什么不分摊来定等级】同一 SKU 常常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个
* 拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。若把销量摊到仓位再定等级,
* 一件爆款商品放在 10 个仓位就会被算成日销 1/10 的滞销品 —— 判断的是「摆法」而不是「商品」。
* 因此等级一律以商品自身的销售速度为准。
*
* 【分摊销量列】导出仍附带「分摊销量」= 商品销量 ÷ 其占用的同类型仓位数,
* 供需要按行求和的场景使用:恒等式(已实测校验,差异 0)
* 全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
*/
const PICKING_BIN_LEVEL1 = '大货库存监控';
const PICKING_BIN_LEVEL2 = '拣选库位动销分析';
/** 可选的统计窗口天数;默认 30 天(需求指定按近 30 天销量划分动销等级) */
const PICKING_BIN_WINDOW_OPTIONS = [7, 15, 30, 60];
const PICKING_BIN_DEFAULT_DAYS = 30;
/**
* 仓位类型:本功能只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空)。
* 箱记录(pack_type=Pack)和存货位(bin_group=存货)不纳入动销等级统计,
* 取数在仓位库存表上直接过滤,
* 页面也不再提供类型切换。
*/
const BIN_TYPE_PICKING = '拣选';
/** 固定统计范围:仅拣选位(保持数组形式,便于接口字段与后续扩展) */
const PICKING_BIN_BIN_TYPES = [BIN_TYPE_PICKING];
/**
* 动销等级(顺序即展示顺序)。判定依据为「商品日销」= 商品窗口整档销量 ÷ 天数,
* 不做仓位分摊,同一商品在所有仓位显示同一等级。
* lower 为日销下界(含),upper 为日销上界(不含);null 表示不限制。
* zero 档为「窗口内无销量」,由 salesQty <= 0 兜底。
*/
const PICKING_BIN_GRADE_RULES = [
{ key: 'hot', label: '爆款:日销≥20', lower: 20, upper: null, tip: '商品日均销量 ≥ 20 件' },
{ key: 'best', label: '热卖款:10≤日销<20', lower: 10, upper: 20, tip: '商品日均销量 10 ~ 20 件(含 10,不含 20)' },
{ key: 'normal', label: '平销款:3≤日销<10', lower: 3, upper: 10, tip: '商品日均销量 3 ~ 10 件(含 3,不含 10)' },
{ key: 'slow1', label: '滞销款-1:1≤日销<3', lower: 1, upper: 3, tip: '商品日均销量 1 ~ 3 件(含 1,不含 3)' },
{ key: 'slow2', label: '滞销款-2:0<日销<1', lower: 0, upper: 1, tip: '商品日均销量大于 0 且小于 1 件' },
{ key: 'zero', label: '销售=0', lower: 0, upper: 0, tip: '统计窗口内该商品无销量(仍占用库存)' },
];
const PICKING_BIN_TOTAL_LABEL = '汇总';
module.exports = {
PICKING_BIN_LEVEL1,
PICKING_BIN_LEVEL2,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
BIN_TYPE_PICKING,
PICKING_BIN_BIN_TYPES,
PICKING_BIN_GRADE_RULES,
PICKING_BIN_TOTAL_LABEL,
};

View File

@ -45,11 +45,6 @@ const SIDEBAR_PAGES = [
pageName: '大货库存监控 · 库存动销分析', pageName: '大货库存监控 · 库存动销分析',
path: '/planning/operation/stock-sales-analysis', path: '/planning/operation/stock-sales-analysis',
}), }),
buildPlanningPage({
pageKey: 'picking_bin_sales_analysis',
pageName: '大货库存监控 · 拣选库位动销分析',
path: '/planning/operation/picking-bin-sales-analysis',
}),
{ {
pageKey: 'settings', pageKey: 'settings',
pageName: '系统设置', pageName: '系统设置',

View File

@ -35,13 +35,9 @@ const promotionModuleRoutes = require('./api/promotionModule');
const productMonitoringRoutes = require('./api/productMonitoring'); const productMonitoringRoutes = require('./api/productMonitoring');
const modularStockMonitorRoutes = require('./api/modularStockMonitor'); const modularStockMonitorRoutes = require('./api/modularStockMonitor');
const mkhBomRoutes = require('./api/mkhBom'); const mkhBomRoutes = require('./api/mkhBom');
const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness');
const warehouseLogisticsExceptionRoutes = require('./api/warehouseLogisticsException');
const dailyInventoryRoutes = require('./api/dailyInventory'); const dailyInventoryRoutes = require('./api/dailyInventory');
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification'); const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis'); const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
const binReplenishRoutes = require('./api/binReplenish');
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning'); const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
const returnMonitorRoutes = require('./api/returnMonitor'); const returnMonitorRoutes = require('./api/returnMonitor');
const returnStockWarningRoutes = require('./api/returnStockWarning'); const returnStockWarningRoutes = require('./api/returnStockWarning');
@ -69,9 +65,6 @@ const {
const { const {
startMkhReplenishAlertScheduler, startMkhReplenishAlertScheduler,
} = require('./jobs/mkhReplenishAlertJob'); } = require('./jobs/mkhReplenishAlertJob');
const {
startWarehouseLogisticsExceptionNotifyScheduler,
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
const { ensureLocalSchema } = require('./services/localSchema'); const { ensureLocalSchema } = require('./services/localSchema');
const { const {
@ -678,21 +671,14 @@ async function startServer(options = {}) {
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes); cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes); cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes);
cubejsApp.use('/api/picking-bin-sales-analysis', authMiddleware, pickingBinSalesAnalysisRoutes);
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes); cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控 // 崔桥工厂仓客退:退货入库核销 + 每日库存监控
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes); cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
// 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓) // 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓)
cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes); cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes);
// 缺货补货监控(拣货位缺货 → 从存货位补货)
cubejsApp.use('/api/bin-replenish', authMiddleware, binReplenishRoutes);
// ===================== // =====================
// 数据分析 API(公开访问,可根据需要添加认证) // 数据分析 API(公开访问,可根据需要添加认证)
@ -797,7 +783,6 @@ async function startServer(options = {}) {
startGoodsFirstSeenScheduler(); startGoodsFirstSeenScheduler();
startAlibaba1688ImageSearchExpiryScheduler(); startAlibaba1688ImageSearchExpiryScheduler();
startMkhReplenishAlertScheduler(); startMkhReplenishAlertScheduler();
startWarehouseLogisticsExceptionNotifyScheduler();
startDailyInventorySnapshotScheduler(); startDailyInventorySnapshotScheduler();
// Keep the first selection-pool page request from paying the remote schema // Keep the first selection-pool page request from paying the remote schema

View File

@ -1,433 +0,0 @@
'use strict';
/**
* 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群
* ────────────────────────────────────────────────────────────────────────────
* 业务口径(2026-10-22 定稿):
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
*
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
*/
const cron = require('node-cron');
const { getLogisticsExceptionExportRows } = require('../services/warehouseLogisticsExceptionService');
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
const {
DEFAULT_MAX_DETAIL_ROWS,
DEFAULT_PAGE_URL,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
isWarehouseLogisticsExceptionNotifyReady,
loadWarehouseLogisticsExceptionNotifyState,
saveWarehouseLogisticsExceptionNotifyState,
} = require('../services/warehouseLogisticsExceptionNotifySettings');
const TIMEZONE = 'Asia/Shanghai';
// 每分钟醒一次,比对「现在是不是配置的推送时刻」—— 这样业务在页面上改时间不用重启后端
const DEFAULT_TICK_CRON = '* * * * *';
// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
let tickRunning = false;
function pad2(value) {
return String(value).padStart(2, '0');
}
/** 北京时间的 YYYY-MM-DD */
function chinaDateKey(date = new Date()) {
const base = date instanceof Date ? date : new Date(date);
if (!Number.isFinite(base.getTime())) return '';
return new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(base);
}
/** 北京时间的 HH:mm —— 用来跟配置里的 sendTime 比对 */
function chinaHourMinute(date = new Date()) {
const base = date instanceof Date ? date : new Date(date);
if (!Number.isFinite(base.getTime())) return '';
return new Intl.DateTimeFormat('en-GB', {
timeZone: TIMEZONE,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(base);
}
/**
* 字段清洗:换行/连续空白压成单空格。
* ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。
*/
function cleanCell(value, fallback = '') {
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
return text || fallback;
}
/** '2026-10-21 16:00:00' → '10-21 16:00';空值给「—」 */
function shortTime(value) {
const text = cleanCell(value);
if (!text) return '—';
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
if (!match) return text.slice(0, 16);
return `${match[2]}-${match[3]} ${match[4]}:${match[5]}`;
}
function formatHours(value) {
const num = Number(value);
if (!Number.isFinite(num)) return '—';
return `${Math.round(num * 10) / 10}h`;
}
/** 明细一行:仓|内部单号|快递|异常类型|发货时间|待处理 */
function buildDetailLine(row, index) {
const parts = [
cleanCell(row?.warehouseName, '未知仓'),
cleanCell(row?.oId, '无单号'),
cleanCell(row?.carrier, '未知快递'),
cleanCell(row?.exceptionLabel, '异常'),
shortTime(row?.sendTime),
formatHours(row?.pendingHours),
];
return `${index}. ${parts.join('|')}`;
}
/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/
function buildCenterLines(data = {}) {
const rows = Array.isArray(data.rows) ? data.rows : [];
const packagesByCenter = rows.reduce((acc, row) => {
const key = String(row?.centerKey || '');
if (!key) return acc;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
return (data.centers || [])
.map((center) => ({
name: cleanCell(center.name || center.key, '未知仓'),
exceptionPackages: packagesByCenter[center.key] || 0,
shippedOrders: Number(center.shippedOrders) || 0,
}))
.filter((item) => item.exceptionPackages > 0)
.sort((a, b) => b.exceptionPackages - a.exceptionPackages)
.slice(0, 12)
.map((item, index) => `${index + 1}. ${item.name} ${item.exceptionPackages} 个(发货 ${item.shippedOrders} 单)`);
}
function buildTypeLine(data = {}) {
const summary = data.summary || {};
const entries = Object.entries(summary.byType || {});
if (!entries.length) return '';
return entries
.map(([type, count]) => {
const meta = (data.types || []).find((item) => item.key === type);
return { label: meta?.label || type, count: Number(count) || 0 };
})
.sort((a, b) => b.count - a.count)
.map((item) => `${item.label} ${item.count}`)
.join(' · ');
}
function buildPageLink(pageUrl, verifyDate) {
const base = String(pageUrl || DEFAULT_PAGE_URL);
if (!verifyDate) return base;
const glue = base.includes('?') ? '&' : '?';
return `${base}${glue}date=${encodeURIComponent(verifyDate)}`;
}
/**
* 组装群消息正文(纯函数,便于单测)。
* 结构:抬头 → 数字 → 分类型 → 按仓 → 明细前 N 条 → 页面链接
* 超长处理:从明细末尾往前砍,砍到能发为止(绝不让钉钉 400 把整条消息吞掉)。
*
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
*/
function buildNotifyText(data = {}, options = {}) {
const batch = data.batch || {};
const summary = data.summary || {};
const rows = Array.isArray(data.rows) ? data.rows : [];
const exclusions = data.exclusions || {};
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
: DEFAULT_MAX_DETAIL_ROWS;
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, batch.verifyDate);
const totalPackages = Number(summary.exceptionPackages) || rows.length;
const shipped = Number(summary.shippedOrders) || 0;
const ratio = Number.isFinite(Number(summary.exceptionRate)) ? `${Number(summary.exceptionRate)}%` : '—';
const head = [
`【仓储物流转运异常】${cleanCell(batch.label, '本批核验')}`,
`已发货 ${shipped} 单 / ${Number(summary.packageCount) || 0} 个包裹,异常 ${totalPackages} 个包裹(${ratio})`,
];
const typeLine = buildTypeLine(data);
if (typeLine) head.push(`类型:${typeLine}`);
if (totalPackages === 0) {
head.push('本批无转运异常,全部正常。');
}
const excludedCount = (Number(exclusions.carrierPackages) || 0)
+ (Number(exclusions.voidedPackages) || 0)
+ (Number(exclusions.nonRealPackages) || 0);
if (excludedCount > 0) {
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
}
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1));
const wantCount = Math.min(maxDetailRows, detailAll.length);
// 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出)
const buildTail = (listed) => {
const lines = [];
if (totalPackages > 0) {
if (listed === 0) {
lines.push('');
lines.push(`明细共 ${totalPackages} 条,消息里不放,请点下面链接查看。`);
} else if (totalPackages > listed) {
lines.push(`…其余 ${totalPackages - listed} 条见页面。`);
}
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
}
return lines;
};
const assemble = (listed, withCenters, extraNote = '') => {
const blocks = [...head];
if (withCenters && centerLines.length) {
blocks.push('');
blocks.push('按仓(异常包裹):');
blocks.push(...centerLines);
}
if (listed > 0) {
blocks.push('');
blocks.push(`明细前 ${listed} 条:`);
blocks.push(...detailAll.slice(0, listed));
}
const tail = buildTail(listed);
if (tail.length) blocks.push(...tail);
if (extraNote) blocks.push(extraNote);
const text = blocks.join('\n');
return { text, bytes: Buffer.byteLength(text, 'utf8') };
};
// 先按期望条数拼;超长就从明细末尾往前砍(先砍明细,再砍分仓块)
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」——
// 后者是正常行为(listedCount < rowCount),混在一起会让页面误报「超长精简」。
let listed = wantCount;
let withCenters = centerLines.length > 0;
let result = assemble(listed, withCenters);
let truncated = false;
while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) {
listed -= 1;
truncated = true;
result = assemble(listed, withCenters);
}
if (result.bytes > SAFE_CONTENT_BYTES && withCenters) {
withCenters = false;
result = assemble(listed, withCenters);
}
if (result.bytes > SAFE_CONTENT_BYTES) {
// 极端情况(仓名/链接特别长):硬截断,至少保证消息能发出去
const chars = Array.from(result.text);
let bytes = 0;
let cut = '';
for (const char of chars) {
const size = Buffer.byteLength(char, 'utf8');
if (bytes + size > SAFE_CONTENT_BYTES) break;
bytes += size;
cut += char;
}
result = { text: cut, bytes };
truncated = true;
}
return {
text: result.text,
rowCount: totalPackages,
listedCount: listed,
bytes: result.bytes,
truncated,
};
}
/**
* 执行一次推送。参数:
* now 指定时间(测试用)
* date 指定核验日 'YYYY-MM-DD'
* force 忽略「今天已经推过」的限制(页面「立即推送」用)
* dryRun 只组装消息不发送(预览 / 单测用)
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
*/
async function pushWarehouseLogisticsExceptionNotify(options = {}) {
const now = options.now === undefined ? new Date() : new Date(options.now);
const pushDate = chinaDateKey(now);
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
if (options.probe === true) {
if (!isWarehouseLogisticsExceptionNotifyReady(config)) {
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
}
const delivery = await sendTextToWebhook({
webhookUrl: config.webhookUrl,
secret: config.webhookSecret,
content: '【仓储物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。',
});
return { sent: true, probe: true, delivery };
}
const state = await loadWarehouseLogisticsExceptionNotifyState();
if (state?.lastSentDate === pushDate && options.force !== true) {
return { sent: false, reason: 'already_sent_today', pushDate, lastSentAt: state.lastSentAt || null };
}
if (!config.enabled && options.force !== true) {
return { sent: false, reason: 'disabled' };
}
const dryRun = options.dryRun === true;
if (!dryRun && !isWarehouseLogisticsExceptionNotifyReady(config)) {
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
}
const data = await getLogisticsExceptionExportRows({ date: options.date });
if (data?.notStarted) {
// 还没到核验时刻(例如把推送时间设在 09:00 之前)→ 不推空数据,等下一轮或人工
return { sent: false, reason: 'not_started', batch: data.batch || null };
}
const built = buildNotifyText(data, {
maxDetailRows: config.maxDetailRows,
pageUrl: config.pageUrl,
});
if (dryRun) {
return {
sent: false,
reason: 'dry_run',
text: built.text,
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
batch: data.batch || null,
summary: data.summary || null,
};
}
const delivery = await sendTextToWebhook({
webhookUrl: config.webhookUrl,
secret: config.webhookSecret,
content: built.text,
atAll: config.atAll === true,
});
await saveWarehouseLogisticsExceptionNotifyState({
lastSentDate: pushDate,
lastSentAt: new Date().toISOString(),
lastStatus: 'sent',
lastError: '',
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
verifyDate: data.batch?.verifyDate || '',
});
return {
sent: true,
rowCount: built.rowCount,
listedCount: built.listedCount,
bytes: built.bytes,
truncated: built.truncated,
text: built.text,
batch: data.batch || null,
summary: data.summary || null,
delivery,
};
}
async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', options = {}) {
try {
const result = await pushWarehouseLogisticsExceptionNotify(options);
if (result.sent) {
console.log(`[WleNotify] ${source} 已推送(${result.rowCount} 条异常,列出 ${result.listedCount} 条,${result.bytes} 字节)`);
} else {
console.log(`[WleNotify] ${source} 跳过: ${result.reason}`);
}
return result;
} catch (error) {
console.error(`[WleNotify] ${source} 执行失败:`, error.message || error);
try {
await saveWarehouseLogisticsExceptionNotifyState({
...(await loadWarehouseLogisticsExceptionNotifyState()),
lastError: String(error.message || error).slice(0, 500),
lastFailedAt: new Date().toISOString(),
});
} catch (_ignored) { /* 状态写失败不影响主流程 */ }
return { sent: false, reason: 'error', error: error.message || String(error) };
}
}
/**
* 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
*/
function startWarehouseLogisticsExceptionNotifyScheduler() {
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
if (disabled) {
console.log('[Scheduler] 物流转运异常钉钉推送已禁用');
return;
}
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
cron.schedule(expression, () => {
if (tickRunning) return;
tickRunning = true;
(async () => {
try {
const now = new Date();
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
if (!config.enabled) return;
if (chinaHourMinute(now) !== config.sendTime) return;
await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now });
} catch (error) {
console.error('[WleNotify] 定时检查失败:', error.message || error);
} finally {
tickRunning = false;
}
})();
}, { timezone: TIMEZONE });
console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`);
}
module.exports = {
DEFAULT_TICK_CRON,
SAFE_CONTENT_BYTES,
TIMEZONE,
buildCenterLines,
buildDetailLine,
buildNotifyText,
buildTypeLine,
chinaDateKey,
chinaHourMinute,
cleanCell,
pushWarehouseLogisticsExceptionNotify,
runWarehouseLogisticsExceptionNotifyJob,
shortTime,
startWarehouseLogisticsExceptionNotifyScheduler,
};

View File

@ -1,53 +0,0 @@
'use strict';
/**
* 缺货补货监控(拣货位 → 存货位)本地表
* --------------------------------------------------------------------------
* 为什么需要本地表:
* 聚水潭「箱及仓位库存」在副本里是**视图** rpt.jst_packInventoryInfo,
* 只有货位号(bin)与数量,**没有货位类型**(拣货位 / 存货位)这层主数据
* (全库 5,496 个字段里没有 bin_type / location_type 一类的列,见 2026-10-22 复核)。
* 而业务判断「拣货位缺货、要从存货位补」必须先知道哪些 bin 是拣货位,
* 所以把业务给的货位清单导入本地库,作为判定基准。
*
* 两张表:
* bin_replenish_pick_bin —— 拣货位清单(按仓库隔离,可重复导入覆盖)
* bin_replenish_import_batch —— 导入批次日志(谁、什么时候、导入多少个)
*
* 未导入清单时,服务层回退到「pack_type = 'Bin' 的记录」作为拣货位口径,
* 页面会明确提示当前用的是回退口径。
*/
const database = require('../config/database');
const SCHEMA_SQL = [
`CREATE TABLE IF NOT EXISTS bin_replenish_pick_bin (
warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识(changzhou/henglin3/subei/suining/henglin2/wh7)',
bin_code VARCHAR(64) NOT NULL COMMENT '货位号(大写去空格)',
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
PRIMARY KEY (warehouse_key, bin_code),
KEY idx_brpb_batch (batch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位清单'`,
`CREATE TABLE IF NOT EXISTS bin_replenish_import_batch (
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识',
file_name VARCHAR(255) NULL COMMENT '文件名',
total_cells INT NOT NULL DEFAULT 0 COMMENT '扫描到的候选单元格数',
imported_rows INT NOT NULL DEFAULT 0 COMMENT '写入的货位数',
imported_by VARCHAR(100) NULL COMMENT '导入人',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
PRIMARY KEY (batch_id),
KEY idx_brpib_wh (warehouse_key, imported_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位导入批次'`,
];
async function ensureBinReplenishSchema() {
for (const sql of SCHEMA_SQL) {
// eslint-disable-next-line no-await-in-loop
await database.local.query(sql);
}
}
module.exports = { ensureBinReplenishSchema };

View File

@ -1,613 +0,0 @@
'use strict';
/**
* 缺货补货监控 —— 拣货位缺货 / 从存货位补货
* ==========================================================================
* 业务需求(2026-10-22 用户确认):
* 监控现有库存 → 看哪些「拣货库位」缺货 → 需要从「存货仓位」补货过去。
*
* ── 数据源 ───────────────────────────────────────────────────────────
* rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」)
* · 列:sku_id / bin(货位号)/ pack_type / bin_group / qty / wms_co_id
* · 一行 = 某货位上某商品的库存;同一货位可能多行(不同箱)
* · pack_type:Bin = 货位散件库存;Pack = 整箱;DefaultPack = 未分箱(仓库级)
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
* 崔桥/山东/横林二/苏北/河南全部挂在 wh_id=1 下,按 wh_id 会混成一个桶。
* ⚠️ 该对象在副本里是**视图**(sys.objects.type_desc = VIEW),
* 没有暴露底层表;单次按 SKU+货位聚合实测 **10~45 秒**(随缓存/负载波动),
* 直接取原始行更慢(25,488 行约 80 秒)。因此必须缓存 + 启动预热,
* 不要把它改回「每次请求都回源」。
*
* ── 拣货位怎么认定(关键)─────────────────────────────────────────────
* 副本**没有独立的货位类型主数据**,但「拣选库位动销分析」已确认口径:
* 真实拣选位 = `pack_type = 'Bin'` 且 `bin_group` 为空
* (`bin_group='存货'`、`pack_type='Pack'/'DefaultPack'` 都算存货位)
* 本页默认就用这个口径;如果业务另有一份拣货位清单,
* 页面导入后以清单为准(`bin ∈ 清单` → 拣货位),其余一律算存货位。
*
* ── 指标口径 ─────────────────────────────────────────────────────────
* 拣货位库存 pickQty = 该 SKU 在所有拣货位上的 qty 合计
* 存货位库存 storageQty = 该 SKU 在其余货位(含整箱/未分箱)上的 qty 合计
* 现有库存 totalQty = pickQty + storageQty
* 目标拣货位库存 target = max(10, ceil(近15天日销 × targetDays)),targetDays 默认 7
* 建议补货量 = min(storageQty, max(0, target − pickQty)),向上取整
*
* 状态:
* pickout 拣货位缺货(pickQty ≤ 0,且存货位有货 → 可以补)
* empty 无货可补(pickQty ≤ 0,存货位也没货 → 需要采购)
* low 拣货位偏低(0 < pickQty < target,存货位有货)
* ok 正常
*
* 近15天日销取自 rpt.pm_stockMonitor(同仓最新快照的 fifteenAvgSale),
* 只用于算目标和排序;取不到时目标按 10 件兜底。
*/
const crypto = require('crypto');
const XLSX = require('xlsx');
const cloudDb = require('../config/cloudDatabase');
const database = require('../config/database');
/**
* 仓库清单:**按 wms_co_id 取数,不能用 wh_id**
* ---------------------------------------------------------------------------
* 2026-10-22 实测:jst_packInventoryInfo.wh_id 是「货主」(简尚=1),
* 崔桥/山东/横林二/苏北/河南的仓位记录**全部挂在 wh_id=1 底下**,
* 按 wh_id 汇总会把 10 个仓混成一个桶。真实仓库是 wms_co_id。
* 这里只列「有真实拣选位」的仓(拣选位 = pack_type='Bin' 且 bin_group 为空),
* 口径与「拣选库位动销分析」保持一致。
*/
const WAREHOUSES = Object.freeze({
cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 },
shandong: { key: 'shandong', wmsCoId: 15137523, name: '简尚-山东青岗集仓', shortName: '山东青岗集仓', order: 20 },
henglin2: { key: 'henglin2', wmsCoId: 15033302, name: '简尚-横林二仓', shortName: '横林二仓', order: 30 },
subei: { key: 'subei', wmsCoId: 15038347, name: '简尚-新苏北仓', shortName: '新苏北仓', order: 40 },
henan: { key: 'henan', wmsCoId: 15539084, name: '简尚-河南洛宁仓', shortName: '河南洛宁仓', order: 50 },
shandongBrand: { key: 'shandongBrand', wmsCoId: 15182705, name: '简尚-山东品牌仓', shortName: '山东品牌仓', order: 60 },
cuiqiaoBcp: { key: 'cuiqiaoBcp', wmsCoId: 15718196, name: '简尚-崔桥工厂半成品包装项目仓', shortName: '崔桥半成品包装仓', order: 70 },
suining: { key: 'suining', wmsCoId: 15032603, name: '简尚-睢宁仓', shortName: '睢宁仓', order: 80 },
cuiqiaoBrand: { key: 'cuiqiaoBrand', wmsCoId: 15870495, name: '简尚-崔桥工厂品牌仓', shortName: '崔桥品牌仓', order: 90 },
henglin3: { key: 'henglin3', wmsCoId: 15043561, name: '简尚-横林三仓', shortName: '横林三仓', order: 100 },
});
const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order);
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
const DEFAULT_TARGET_DAYS = 7;
const MIN_TARGET_QTY = 10;
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
const MAX_PAGE_SIZE = 5000;
const STATUS = Object.freeze({
PICK_OUT: 'pickout',
EMPTY: 'empty',
LOW: 'low',
NO_PICK: 'nopick',
OK: 'ok',
});
const STATUS_LABELS = Object.freeze({
pickout: '拣货位缺货',
empty: '无货可补',
low: '拣货位偏低',
nopick: '未设拣货位',
ok: '正常',
});
const STATUS_RANK = Object.freeze({ pickout: 4, empty: 3, low: 2, nopick: 1, ok: 0 });
const cacheStore = new Map();
let cacheEpoch = 0;
function toText(value, maxLength = 200) {
return String(value ?? '').trim().slice(0, maxLength);
}
function toNumber(value, fallback = 0) {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
function round2(value) {
return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100;
}
function normalizeBin(value) {
return toText(value, 64).toUpperCase().replace(/\s+/g, '');
}
function resolveWarehouseKey(value) {
const key = toText(value, 32).toLowerCase();
return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY;
}
function resolveWarehouse(value) {
return WAREHOUSES[resolveWarehouseKey(value)];
}
function resolveCacheTtlMs() {
const raw = process.env.BIN_REPLENISH_CACHE_TTL_MS;
if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CACHE_TTL_MS;
const num = Number(raw);
if (!Number.isFinite(num) || num < 0) return DEFAULT_CACHE_TTL_MS;
return num;
}
function resolveTargetDays(value) {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) return DEFAULT_TARGET_DAYS;
return Math.min(Math.round(num), 90);
}
// ── 拣货位清单(本地库)────────────────────────────────────────────
async function loadPickBinRows(warehouseKey) {
const rows = await database.local.query(
'SELECT bin_code AS binCode, imported_at AS importedAt FROM bin_replenish_pick_bin WHERE warehouse_key = ?',
[warehouseKey]
);
return rows || [];
}
async function loadLatestPickBinBatch(warehouseKey) {
const rows = await database.local.query(
`SELECT batch_id AS batchId, file_name AS fileName, imported_rows AS importedRows,
imported_by AS importedBy, imported_at AS importedAt
FROM bin_replenish_import_batch WHERE warehouse_key = ?
ORDER BY imported_at DESC LIMIT 1`,
[warehouseKey]
);
return rows?.[0] || null;
}
async function getPickBinMeta(warehouseKey) {
const [bins, batch] = await Promise.all([
loadPickBinRows(warehouseKey),
loadLatestPickBinBatch(warehouseKey),
]);
return {
count: bins.length,
sample: bins.slice(0, 20).map((row) => row.binCode),
lastImport: batch,
source: bins.length > 0 ? 'import' : 'fallback',
};
}
/** 从任意表格/文本里抽取货位号:扫描所有单元格,按逗号/分号/换行拆开,保留 ASCII 货位形态 */
const BIN_TOKEN_PATTERN = /^[0-9A-Z]+(?:-[0-9A-Z]+){0,5}$/;
// 表头 / 常见非货位词,避免把标题行当成货位导进来
const BIN_TOKEN_BLOCKLIST = new Set(['BIN', 'CODE', 'SKU', 'SNO', 'NO', 'ID', 'LOCATION', 'LOC', 'POSITION']);
function extractBinCodes(matrix = []) {
const codes = new Set();
let candidates = 0;
matrix.forEach((row) => {
(Array.isArray(row) ? row : [row]).forEach((cell) => {
if (cell === null || cell === undefined) return;
if (cell instanceof Date) return;
const text = String(cell).trim();
if (!text) return;
const tokens = text.split(/[,,;;\s\r\n\t|、]+/u).filter(Boolean);
tokens.forEach((token) => {
const code = normalizeBin(token);
if (!code) return;
candidates += 1;
if (code.length < 3 || code.length > 64) return;
if (BIN_TOKEN_BLOCKLIST.has(code)) return;
if (!BIN_TOKEN_PATTERN.test(code)) return;
// 纯数字/纯字母也放过(有些仓用 A01、1234 这种),但单个字符的不要
if (/^\d+$/.test(code) && code.length < 2) return;
codes.add(code);
});
});
});
return { codes: [...codes], candidates };
}
async function importPickBins({ buffer, fileName, warehouseKey, importedBy }) {
if (!buffer || !buffer.length) {
const error = new Error('文件内容为空,请重新选择');
error.statusCode = 400;
throw error;
}
const key = resolveWarehouseKey(warehouseKey);
let workbook;
try {
workbook = XLSX.read(buffer, { type: 'buffer', cellDates: false });
} catch (error) {
const err = new Error(`文件解析失败:${error.message}`);
err.statusCode = 400;
throw err;
}
const matrix = [];
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return;
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false });
rows.forEach((row) => matrix.push(row));
});
const { codes, candidates } = extractBinCodes(matrix);
if (!codes.length) {
const error = new Error('没有识别到货位号。清单里请只放货位号(如 XA01-01-11),或把它放在单独一列。');
error.statusCode = 400;
throw error;
}
const batchId = crypto.randomBytes(8).toString('hex');
await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]);
const CHUNK = 500;
for (let i = 0; i < codes.length; i += CHUNK) {
const chunk = codes.slice(i, i + CHUNK);
const placeholders = chunk.map(() => '(?,?,?,NOW())').join(',');
const params = [];
chunk.forEach((code) => {
params.push(key, code, batchId);
});
// eslint-disable-next-line no-await-in-loop
await database.local.query(
`INSERT INTO bin_replenish_pick_bin (warehouse_key, bin_code, batch_id, imported_at) VALUES ${placeholders}`,
params
);
}
await database.local.query(
`INSERT INTO bin_replenish_import_batch
(batch_id, warehouse_key, file_name, total_cells, imported_rows, imported_by, imported_at)
VALUES (?,?,?,?,?,?,NOW())`,
[batchId, key, toText(fileName, 255) || null, candidates, codes.length, toText(importedBy, 100) || null]
);
invalidateCache(key);
return {
batchId,
warehouse: WAREHOUSES[key],
importedRows: codes.length,
totalCells: candidates,
fileName: toText(fileName, 255) || null,
sample: codes.slice(0, 20),
};
}
async function clearPickBins(warehouseKey) {
const key = resolveWarehouseKey(warehouseKey);
const rows = await database.local.query(
'SELECT COUNT(*) AS cnt FROM bin_replenish_pick_bin WHERE warehouse_key = ?',
[key]
);
await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]);
invalidateCache(key);
return { warehouse: WAREHOUSES[key], removed: Number(rows?.[0]?.cnt || 0) };
}
// ── 副本取数 ────────────────────────────────────────────────────────
/**
* 按 SKU + 货位 + 记录类型聚合。
* 必须用 GROUP BY(不要把原始行拉回进程):视图原始行 25,488 行约 80s,
* 聚合后 23,751 行约 43s,是当前可用的最快形态。
*/
async function loadReplicaRows(warehouse) {
const sql = `
SELECT p.sku_id AS skuId,
p.bin AS bin,
p.pack_type AS packType,
p.bin_group AS binGroup,
SUM(p.qty) AS qty,
MAX(s.name) AS productName,
MAX(s.supplier_name) AS supplier
FROM rpt.jst_packInventoryInfo p
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
WHERE p.wms_co_id = @wmsCoId
GROUP BY p.sku_id, p.bin, p.pack_type, p.bin_group`;
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 });
}
/** 近15天日销(同仓最新快照),只用于算目标库存与排序 */
async function loadDailySaleMap(warehouse) {
const rows = await cloudDb.query(
`SELECT sku_id AS skuId, fifteenAvgSale AS avgSale
FROM rpt.pm_stockMonitor
WHERE warehouse = @warehouseName
AND CONVERT(varchar(10), date, 23) = (
SELECT CONVERT(varchar(10), MAX(date), 23)
FROM rpt.pm_stockMonitor WHERE warehouse = @warehouseName
)`,
{ warehouseName: warehouse.name },
{ requestTimeout: 300000 }
);
const map = new Map();
(rows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (skuId) map.set(skuId, toNumber(row.avgSale));
});
return map;
}
// ── 组装 ────────────────────────────────────────────────────────────
function buildSkuRows({ replicaRows, pickBinSet, dailySaleMap, targetDays, useFallback }) {
const skuMap = new Map();
(replicaRows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (!skuId) return;
const bin = normalizeBin(row.bin);
const qty = toNumber(row.qty);
const isPick = useFallback
// 默认口径与「拣选库位动销分析」一致:真实拣选位 = pack_type='Bin' 且 bin_group 为空
// (bin_group='存货' 与 pack_type='Pack' 都是存货,不算拣选位)
? (toText(row.packType) === 'Bin' && !toText(row.binGroup))
: (bin ? pickBinSet.has(bin) : false);
let entry = skuMap.get(skuId);
if (!entry) {
entry = {
skuId,
productName: toText(row.productName, 300) || '',
supplier: toText(row.supplier, 120) || '',
pickQty: 0,
storageQty: 0,
pickBins: [],
storageBins: [],
};
skuMap.set(skuId, entry);
}
if (!entry.productName) entry.productName = toText(row.productName, 300) || '';
if (!entry.supplier) entry.supplier = toText(row.supplier, 120) || '';
if (isPick) {
entry.pickQty += qty;
entry.pickBins.push({ bin: bin || '未维护', qty });
} else {
entry.storageQty += qty;
entry.storageBins.push({ bin: bin || '未维护', qty });
}
});
const rows = [];
skuMap.forEach((entry) => {
const avgSale = toNumber(dailySaleMap.get(entry.skuId), 0);
const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(avgSale * targetDays));
const pickQty = round2(entry.pickQty);
const storageQty = round2(entry.storageQty);
let status = STATUS.OK;
if (entry.pickBins.length === 0) {
// 这个 SKU 压根没有拣货位(整箱直发 / 未维护)——不是「拣货位缺货」,
// 不能混进待补货清单,否则会刷出几千条误报。
status = STATUS.NO_PICK;
} else if (pickQty <= 0) {
status = storageQty > 0 ? STATUS.PICK_OUT : STATUS.EMPTY;
} else if (pickQty < targetQty && storageQty > 0) {
status = STATUS.LOW;
}
const suggestQty = status === STATUS.PICK_OUT || status === STATUS.LOW
? Math.max(0, Math.min(Math.ceil(storageQty), Math.ceil(targetQty - pickQty)))
: 0;
const pickBins = entry.pickBins
.slice()
.sort((a, b) => b.qty - a.qty || a.bin.localeCompare(b.bin));
const storageBins = entry.storageBins
.filter((item) => item.qty > 0)
.sort((a, b) => b.qty - a.qty)
.slice(0, 5);
rows.push({
skuId: entry.skuId,
productName: entry.productName,
supplier: entry.supplier,
status,
statusLabel: STATUS_LABELS[status],
pickQty,
storageQty,
totalQty: round2(pickQty + storageQty),
targetQty,
suggestQty,
avgSale: round2(avgSale),
pickBinCount: entry.pickBins.length,
pickBins,
storageBins,
hasPickSlot: entry.pickBins.length > 0,
});
});
return rows;
}
/** 按页面传入的目标天数重算目标库存与建议补货量(缓存里存的是默认 7 天口径) */
function applyTargetDays(rows, targetDays) {
if (targetDays === DEFAULT_TARGET_DAYS) return rows;
return rows.map((row) => {
const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(row.avgSale * targetDays));
const suggestQty = (row.status === STATUS.PICK_OUT || row.status === STATUS.LOW)
? Math.max(0, Math.min(Math.ceil(row.storageQty), Math.ceil(targetQty - row.pickQty)))
: 0;
return { ...row, targetQty, suggestQty };
});
}
function buildSummary(rows, meta) {
const summary = {
skuTotal: rows.length,
pickout: 0,
empty: 0,
low: 0,
nopick: 0,
ok: 0,
suggestQtyTotal: 0,
pickoutQtyTotal: 0,
storageQtyTotal: 0,
withPickSlot: 0,
source: meta.source,
pickBinCount: meta.pickBinCount,
};
rows.forEach((row) => {
if (summary[row.status] !== undefined) summary[row.status] += 1;
summary.suggestQtyTotal += row.suggestQty || 0;
summary.storageQtyTotal += row.storageQty || 0;
if (row.status === STATUS.PICK_OUT) summary.pickoutQtyTotal += row.suggestQty || 0;
if (row.hasPickSlot) summary.withPickSlot += 1;
});
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
summary.pickoutQtyTotal = round2(summary.pickoutQtyTotal);
summary.storageQtyTotal = round2(summary.storageQtyTotal);
return summary;
}
async function loadDataset(warehouse, { refresh = false } = {}) {
const cacheKey = warehouse.key;
const cached = cacheStore.get(cacheKey);
const ttl = resolveCacheTtlMs();
const now = Date.now();
if (!refresh && cached && ttl > 0 && now - cached.at < ttl) {
return { ...cached, cacheHit: true };
}
const epoch = cacheEpoch;
const [replicaRows, pickBins, dailySaleMap] = await Promise.all([
loadReplicaRows(warehouse),
loadPickBinRows(warehouse.key),
loadDailySaleMap(warehouse).catch(() => new Map()),
]);
const pickBinSet = new Set(pickBins.map((row) => normalizeBin(row.binCode)).filter(Boolean));
const useFallback = pickBinSet.size === 0;
const rows = buildSkuRows({
replicaRows,
pickBinSet,
dailySaleMap,
targetDays: DEFAULT_TARGET_DAYS,
useFallback,
});
const dataset = {
at: now,
rows,
warehouse,
meta: {
source: useFallback ? 'fallback' : 'import',
pickBinCount: pickBinSet.size,
dailySaleSkus: dailySaleMap.size,
rowCount: rows.length,
},
};
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
if (epoch === cacheEpoch) cacheStore.set(cacheKey, dataset);
return { ...dataset, cacheHit: false };
}
function invalidateCache(warehouseKey) {
cacheEpoch += 1;
if (warehouseKey) cacheStore.delete(resolveWarehouseKey(warehouseKey));
else cacheStore.clear();
}
function matchesStatus(row, status) {
const key = toText(status, 20).toLowerCase();
if (!key || key === 'all') return true;
if (key === 'action' || key === 'todo') {
return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW || row.status === STATUS.EMPTY;
}
if (key === 'replenish') return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW;
return row.status === key;
}
function sortRows(rows) {
return rows.slice().sort((a, b) => (
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|| (b.suggestQty || 0) - (a.suggestQty || 0)
|| String(a.skuId).localeCompare(String(b.skuId))
));
}
function filterRows(rows, { keyword, status }) {
const kw = toText(keyword, 120).toLowerCase();
return rows.filter((row) => {
if (!matchesStatus(row, status)) return false;
if (!kw) return true;
return row.skuId.toLowerCase().includes(kw)
|| String(row.productName || '').toLowerCase().includes(kw)
|| String(row.supplier || '').toLowerCase().includes(kw);
});
}
async function getReplenishList(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
const targetDays = resolveTargetDays(options.targetDays);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
// targetDays 是页面参数,不进缓存:缓存的是原始聚合,这里按参数重算目标/建议量
const rows = applyTargetDays(dataset.rows, targetDays);
const filtered = sortRows(filterRows(rows, options));
const pickBinMeta = await getPickBinMeta(warehouse.key);
const summary = buildSummary(rows, {
source: pickBinMeta.source,
pickBinCount: pickBinMeta.count,
});
const page = Math.max(1, Number.parseInt(options.page, 10) || 1);
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50));
const start = (page - 1) * pageSize;
return {
rows: filtered.slice(start, start + pageSize),
total: filtered.length,
page,
pageSize,
summary,
warehouse,
warehouses: Object.values(WAREHOUSES),
targetDays,
cacheHit: dataset.cacheHit,
updatedAt: new Date(dataset.at).toISOString(),
cacheTtlMs: resolveCacheTtlMs(),
pickBinMeta,
dailySaleSkus: dataset.meta.dailySaleSkus,
};
}
async function getReplenishExportRows(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const targetDays = resolveTargetDays(options.targetDays);
const rows = applyTargetDays(dataset.rows, targetDays);
return {
warehouse,
rows: sortRows(filterRows(rows, options)),
summary: buildSummary(rows, {
source: dataset.meta.source,
pickBinCount: dataset.meta.pickBinCount,
}),
};
}
/** 启动预热:视图查询 35~45s,不能让首个用户请求等 */
async function warmBinReplenishCache() {
try {
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
const t0 = Date.now();
await loadDataset(warehouse, { refresh: true });
console.log(`[BinReplenish] 预热完成 ${warehouse.name},耗时 ${Date.now() - t0}ms`);
} catch (error) {
console.warn('[BinReplenish] 预热失败:', error.message);
}
}
module.exports = {
DEFAULT_WAREHOUSE_KEY,
STATUS,
STATUS_LABELS,
WAREHOUSES,
clearPickBins,
getPickBinMeta,
getReplenishExportRows,
getReplenishList,
importPickBins,
loadDataset,
normalizeBin,
resolveWarehouse,
resolveWarehouseKey,
warmBinReplenishCache,
};

View File

@ -1,210 +0,0 @@
'use strict';
/**
* 钉钉「群自定义机器人」webhook 发文本通道(2026-10-22 新增)
* ────────────────────────────────────────────────────────────────────────────
* 为什么不用企业内部应用发文件?
* 群机器人 webhook **只能发** text / markdown / link / actionCard / feedCard,
* 发 .xlsx 附件必须走企业内部应用(AppKey/AppSecret/robotCode/openConversationId)。
* 业务最终选择「群里直接看文本」,于是回到最简单的一条路:
* 只要一个 webhook 地址(加签可选),不用申请应用、不用管 token 过期。
*
* 请求形状(钉钉官方「自定义机器人接入」):
* POST https://oapi.dingtalk.com/robot/send?access_token=xxx&timestamp=xxx&sign=xxx
* { "msgtype": "text", "text": { "content": "..." }, "at": { "isAtAll": false } }
* → { "errcode": 0, "errmsg": "ok" }
*
* 三个必须知道的限制(踩过就知道疼):
* ① text.content **上限 20000 字节**(中文 UTF-8 是 3 字节/字)→ 超了钉钉直接 400,
* 所以这里按**字节**截断(不是按字符),并返回是否被截断。
* ② 加了「自定义关键词」安全设置时,content 里必须含关键词(常用「异常」「提醒」),
* 否则 errcode 310000。这个错要让业务自己看得懂,所以原样透出 errmsg。
* ③ 加签密钥(SEC 开头)算出来的 sign 必须 **urlEncode**,且 timestamp 是**毫秒**。
* 顺序错了就是 310000 sign not match,很难看出是编码问题。
*
* 失败一律抛错并带上钉钉原始 errcode/errmsg —— 业务排错全靠这个码。
*/
const crypto = require('crypto');
const OAPI_BASE = process.env.DINGTALK_OAPI_BASE || 'https://oapi.dingtalk.com';
const REQUEST_TIMEOUT_MS = Math.max(Number(process.env.DINGTALK_WEBHOOK_TIMEOUT_MS) || 20000, 3000);
// 钉钉 text 消息 content 的硬上限(字节)。留 200 字节余量给自己追加的尾巴
const MAX_CONTENT_BYTES = 20000;
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 200;
function normalizeText(value, maxLength = 512) {
return String(value ?? '').trim().slice(0, maxLength);
}
/** 按 UTF-8 **字节**截断,且不把多字节字符切一半 */
function truncateBytes(text, maxBytes = SAFE_CONTENT_BYTES) {
const source = String(text ?? '');
if (Buffer.byteLength(source, 'utf8') <= maxBytes) return { text: source, truncated: false };
const chars = Array.from(source);
let bytes = 0;
let out = '';
for (const char of chars) {
const size = Buffer.byteLength(char, 'utf8');
if (bytes + size > maxBytes) break;
bytes += size;
out += char;
}
return { text: out, truncated: true };
}
function buildDingTalkError(label, payload, httpStatus) {
const code = payload?.errcode ?? payload?.code ?? httpStatus ?? null;
const message = payload?.errmsg || payload?.message || `${label}失败`;
const error = new Error(`${label}失败(${code}):${message}`);
error.code = code;
error.httpStatus = httpStatus || null;
error.dingtalk = payload || null;
return error;
}
/**
* 校验 webhook 地址。放宽 host(支持自建代理 / 测试 stub),但必须 https 且是 robot/send。
* 返回去掉首尾空白后的地址;不合法抛 400(页面上直接提示,不让它跑到定时任务里才炸)。
*/
function normalizeWebhookUrl(value) {
const text = normalizeText(value, 1024);
if (!text) {
throw Object.assign(new Error('请填写钉钉群机器人的 Webhook 地址'), { status: 400, code: 'WEBHOOK_REQUIRED' });
}
let url;
try {
url = new URL(text);
} catch (_error) {
throw Object.assign(new Error('Webhook 地址不是合法 URL'), { status: 400, code: 'WEBHOOK_INVALID' });
}
if (!/^https?:$/.test(url.protocol)) {
throw Object.assign(new Error('Webhook 地址必须以 http(s):// 开头'), { status: 400, code: 'WEBHOOK_INVALID' });
}
// 严格匹配 `/robot/send`(不要用「以 /robot/send 结尾」—— 那样
// `topapi/robot/send` 这种企业内部应用地址也会被放过去,最后报一个难懂的 404)
if (!/^\/robot\/send\/?$/.test(url.pathname)) {
throw Object.assign(
new Error('这不像群机器人 Webhook(正常形如 https://oapi.dingtalk.com/robot/send?access_token=xxx)'),
{ status: 400, code: 'WEBHOOK_INVALID' }
);
}
if (!url.searchParams.get('access_token')) {
throw Object.assign(
new Error('Webhook 地址缺少 access_token 参数,请从钉钉群机器人设置里完整复制'),
{ status: 400, code: 'WEBHOOK_INVALID' }
);
}
return url.toString();
}
/**
* 加签:sign = urlEncode(base64(HmacSHA256(secret, `${timestamp}\n${secret}`)))
* ⚠️ HMAC 的 key 是 secret、消息是 `timestamp\nsecret`(顺序反过来就是 310000)
*/
function buildSignedUrl(webhookUrl, secret, timestampMs = Date.now()) {
const timestamp = String(Number(timestampMs) || Date.now());
const timestamped = new URL(webhookUrl);
timestamped.searchParams.set('timestamp', timestamp);
const text = normalizeText(secret, 256);
if (!text) return timestamped.toString();
const sign = crypto
.createHmac('sha256', text)
.update(`${timestamp}\n${text}`, 'utf8')
.digest('base64');
timestamped.searchParams.set('sign', sign);
return timestamped.toString();
}
async function requestJson(label, url, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
const raw = await response.text();
let payload = null;
try {
payload = raw ? JSON.parse(raw) : {};
} catch (_error) {
payload = { errmsg: raw.slice(0, 500) };
}
if (!response.ok) throw buildDingTalkError(label, payload, response.status);
if (payload && payload.errcode !== undefined && Number(payload.errcode) !== 0) {
throw buildDingTalkError(label, payload, response.status);
}
return payload || {};
} catch (error) {
if (error.name === 'AbortError') {
throw Object.assign(new Error(`${label}超时(${REQUEST_TIMEOUT_MS}ms)`), { code: 'TIMEOUT' });
}
throw error;
} finally {
clearTimeout(timer);
}
}
/**
* 发一条文本消息到群。
* @param {object} params
* @param {string} params.webhookUrl 群机器人 Webhook(含 access_token)
* @param {string} [params.secret] 加签密钥(安全设置选「加签」时填;选关键词/IP 时留空)
* @param {string} params.content 纯文本(支持 \n 换行)
* @param {boolean} [params.atAll] 是否 @所有人
* @param {string[]} [params.atMobiles]
* @returns {Promise<{delivered:boolean,truncated:boolean,bytes:number,content:string}>}
*/
async function sendTextToWebhook({
webhookUrl, secret, content, atAll = false, atMobiles = [],
}) {
const base = normalizeWebhookUrl(webhookUrl);
const raw = String(content ?? '');
if (!raw.trim()) {
throw Object.assign(new Error('待发送的消息内容为空'), { code: 'EMPTY_CONTENT' });
}
const limited = truncateBytes(raw);
const url = buildSignedUrl(base, secret);
const mobiles = (Array.isArray(atMobiles) ? atMobiles : [])
.map((item) => normalizeText(item, 32))
.filter((item) => /^\d{6,20}$/.test(item));
const payload = await requestJson('发送钉钉群消息', url, {
method: 'POST',
headers: { 'Content-Type': 'application/json;charset=utf-8' },
body: JSON.stringify({
msgtype: 'text',
text: { content: limited.text },
at: { isAtAll: atAll === true, atMobiles: mobiles },
}),
});
return {
delivered: true,
truncated: limited.truncated,
bytes: Buffer.byteLength(limited.text, 'utf8'),
content: limited.text,
errmsg: normalizeText(payload.errmsg, 200) || 'ok',
};
}
/**
* 「测试推送」用一句短文本探活:只验 webhook(+加签)能不能通。
* 不读取业务数据 —— 测试时不该因为数据慢而误判成通道坏。
*/
async function sendProbeText({ webhookUrl, secret, text }) {
const content = normalizeText(text, 500)
|| '【仓储中心物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。';
return sendTextToWebhook({ webhookUrl, secret, content });
}
module.exports = {
MAX_CONTENT_BYTES,
OAPI_BASE,
buildSignedUrl,
normalizeWebhookUrl,
sendProbeText,
sendTextToWebhook,
truncateBytes,
};

View File

@ -16,8 +16,6 @@ const { ensureReturnMonitorSchema } = require('./returnMonitorSchema');
const { const {
ensureReturnStockWarningSchema, ensureReturnStockWarningSchema,
} = require('./returnStockWarningSchema'); } = require('./returnStockWarningSchema');
const { ensureBinReplenishSchema } = require('./binReplenishSchema');
const { warmBinReplenishCache } = require('./binReplenishService');
const { warmReturnStockWarningCache } = require('./returnStockWarningService'); const { warmReturnStockWarningCache } = require('./returnStockWarningService');
const USER_COLUMNS = [ const USER_COLUMNS = [
@ -961,11 +959,8 @@ async function ensureLocalSchema() {
await ensureProductMonitoringSchema(); await ensureProductMonitoringSchema();
await ensureReturnMonitorSchema(); await ensureReturnMonitorSchema();
await ensureReturnStockWarningSchema(); await ensureReturnStockWarningSchema();
await ensureBinReplenishSchema();
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动 // 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
void warmReturnStockWarningCache(); void warmReturnStockWarningCache();
// 缺货补货监控(仓位库存视图单次查询 35~45s)后台预热,不阻塞启动
void warmBinReplenishCache();
await ensureSidebarRolePermissions(); await ensureSidebarRolePermissions();
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动 // 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动

View File

@ -51,18 +51,15 @@
// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。 // 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。
// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(), // · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(),
// 只打一条查询,按编码缓存 10 分钟。 // 只打一条查询,按编码缓存 10 分钟。
// ⚠️ 关联列(09-19 修正,PR #22):仓名用 `jst_warehouseInfo.wms_co_id = p.wms_co_id` // ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`;
// —— 09-19 实测该表 30,236 行 `wms_co_id` **0 NULL、14 个仓取值**。 // 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。
// ~~旧结论「该表 wms_co_id 全表 NULL,必须用 jst_warehouseInfo.Id = p.wh_id」~~ // ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱**
// 只对 09-17 的表状态成立;误用 `w.Id = p.wh_id`(wh_id 仅 6 个取值、90% 为 1) // (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、
// 会让 727 个配件的仓位仓名被统一显示成「常州简尚家居有限公司」。 // wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。
// 🔥 该表 **小时级增量刷新**,且 2026-09-18 被上游整表重建 —— // 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到
// 09-17 的「全表 85,152 行、仅 3 个箱、`bin` / `bin_group` 全表为空、与 MKH 配件 // (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。
// **零交集**」这组结论**已全部失效**,别再照抄。 // → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题;
// 09-19 实测:目标仓 794 个 SKU 命中 **727** 个(真货位如 ZT-8 / BCP01-01-1); // 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。
// 2026-09-30 实测:本配件仓(15718196)箱表有库存(bin 非空)的 SKU **734** 个。
// → 配件货位 / 箱库存数据是真实存在并持续刷新的;
// 该结论同时修正 ⑪ 的配件库存来源(箱表可作兜底,见下)。
// //
// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。 // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。
// //
@ -70,11 +67,8 @@
// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货 // 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货
// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale // · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale
// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) // (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照)
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id **仓白名单**)实时库存; // · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存;
// 🔥 2026-10-17 修正为**多仓求和**(原来是单仓 15870495):商品档案 other_3 只记 // 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①),
// 「归属仓」,实物会整批搬到同体系其它发货仓 —— 只查归属仓会把有货的算成 0。
// 实测新仓「简尚-横林二仓品牌仓」(16132681,10-13 建仓) 58 个 MKH 成品 / 525 件
// 全在它名下、归属仓为 0 → 5 个误报缺货 + 2 个误报预警。白名单见 BRAND_WMS_CO_IDS。
// 缺失时回退监控快照 jsStock // 缺失时回退监控快照 jsStock
// · 结余库存 = max(0, 现有库存 − 待发货订单) // · 结余库存 = max(0, 现有库存 − 待发货订单)
// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单** // ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单**
@ -218,28 +212,6 @@
// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的 // 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的
// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。 // MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。
// //
// ── ⑦-e 成品库存多仓合并(2026-10-17 新增)────────────────────────
// 业务原话:「MKHDNZ0409260600788 库存在横林二仓品牌仓,**重新加了一个品牌仓库存**,
// 需要同步这个仓库库存」。
// 🔥 根因:**商品档案的 `other_3` 记录的是「归属仓」,不是「实物所在仓」**。
// 归属仓仍是「简尚-崔桥工厂品牌仓」(15870495),但实物被整批搬到 10-13 新建的
// 「简尚-横林二仓品牌仓」(16132681) —— 归属仓库存为 0,新仓有货。
// 而 ⑦-d 的取数只看 `wms_co_id = 15870495` 单仓 → 该批商品库存全被算成 0。
// 实测:新仓 58 个 SKU 全是 MKH 成品(other_3 全为崔桥品牌仓),合计 525 件;
// 线上 49 个 SKU 库存值错误,其中 **5 个误报「缺货」、2 个误报「预警」**。
// 规则:`jst_inventoryInfo` / `jst_packInventoryInfo` 都按 **仓白名单 `IN (...)`**
// 取数,并 **按 SKU 多仓求和**(同一批货可能同时分散在多仓)。
// · 白名单常量:BRAND_WMS_CO_IDS(主仓 + 横林二仓品牌仓)
// · ⚠️ 原来的「按 updateTime DESC 取首条」在单仓时是去重,多仓时会**只留一个仓**,
// 必须换成 SUM + GROUP BY(本次改动同时修掉这个隐患)。
// · ⚠️ 「求和」而非「取最大」:两仓是独立仓位,同一 SKU 同批货不会重复记账
// (实测该 SKU 两仓 bin 分别为 `PP01-15-22` 与空,无同箱重复)。
// 影响面(2026-10-17 实测):58 个 SKU 受影响,其中 49 个库存值变化;
// 预警 30 → 25、缺货 21 → 16(消除 5 个误报缺货、2 个误报预警)。
// ⚠️ 新增同类「成品发货仓-真实库存」时,往 BRAND_WMS_CO_IDS 追加 wms_co_id 即可。
// ⚠️ 配件库存**不适用本口径**(配件仍只算 15718196)——新仓一个配件都没有,
// 横林三仓/沭阳仓虽有 MKH 配件但归属别的项目,并入会让「配件不足」漏报。
//
// ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // ── ⑧ 在途加工单(2026-09-17 业务确认口径)────────────────────────
// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警**
// (工厂已经在做了,不重复提示补货),改标为「已排产」。 // (工厂已经在做了,不重复提示补货),改标为「已排产」。
@ -331,22 +303,13 @@
// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件 // ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件
// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集: // 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集:
// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) // 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
// · 配件现有库存 **两级来源**(2026-09-30 修正,与配件监控页 `buildRealtimeStockSql` 同口径): // · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓
// ① rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓(wms_co_id = 15718196) // (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。
// —— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。 // 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**,
// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**, // 跨仓正库存为 0,所以不跨仓汇总。
// 跨仓正库存为 0,所以不跨仓汇总。
// ② 同仓 rpt.jst_packInventoryInfo 的 **bin 非空** 行合计 —— **仅当 ① 为 0 / 缺行时启用**。
// 🔥 **推翻旧结论**(旧版此处写「不能用 jst_packInventoryInfo 当配件库存 ——
// 与 MKH 配件零交集」,那是 09-17 的表状态,09-18 上游重建后已失效,见 ⑤)。
// 漏掉这一级会让「库存表滞后停在 0、箱表已有货」的配件被误判不足 ——
// 2026-09-30 案例 MKHDNZ0409260600849 的配件 DNZ040947N1440V01:
// inventoryInfo 停在 09-28 值 0,箱表当日 10:30 已记 bin=ZT-30 qty=200;
// 配件监控页显示 200(判「正常」),MKH-BOM 却因 0 判「配件不足」。
// 当日 30 行配件不足预警里 **7 行**属此类误判(可组装数 0 → 30~46)。
// ⚠️ 必须过滤 `bin` 为空的行 —— 那是装箱记录不是库存(见 ⑤);
// 且**只补 0、不覆盖正库存**(箱表可能只记部分装箱量,覆盖会压低有效库存)。
// · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。 // · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。
// ⚠️ 不能用 `jst_packInventoryInfo` 当配件库存 —— 它是「箱及仓位库存」,
// 与 MKH 配件零交集(见 ⑤),仓位口径专用。
// · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。 // · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。
// 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0 // 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0
// (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。 // (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。
@ -435,17 +398,8 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%'
)`; )`;
// ── 补货预警参数(见文件头 ⑦)──────────────────────────────────── // ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
// 成品库存仓白名单(rpt.jst_warehouseInfo 查得)。**多仓求和**: // 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得)
// 商品档案的 other_3 只记录「归属仓」(仍是崔桥工厂品牌仓),但实物会成批搬到 const BRAND_WMS_CO_ID = 15870495;
// 同体系的其它发货仓,此时库存表里只有新仓有数、归属仓为 0 —— 只查归属仓会把
// 有货的商品算成 0(实测 2026-10-17:新仓「简尚-横林二仓品牌仓」10-13 建仓,
// 58 个 MKH 成品 / 525 件全部只在它名下,导致 5 个误报缺货 + 2 个误报预警)。
// · 15870495 简尚-崔桥工厂品牌仓(归属主仓)
// · 16132681 简尚-横林二仓品牌仓(2026-10-13 新增,成品发货仓-真实库存)
// ⚠️ 新增同类「成品发货仓-真实库存」时把 wms_co_id 追加进来即可,别再写死单仓。
const BRAND_WMS_CO_IDS = Object.freeze([15870495, 16132681]);
// 兼容旧引用:主仓仍作为「仓库名」文案与监控快照的 warehouse 过滤值
const BRAND_WMS_CO_ID = BRAND_WMS_CO_IDS[0];
// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据, // 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据,
// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。 // 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。
// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列, // ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列,
@ -571,10 +525,8 @@ const normalizeCode = (value) => {
// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。 // 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。
const REPLENISH_PARAMS = Object.freeze({ const REPLENISH_PARAMS = Object.freeze({
brandWarehouse: DESIGNATED_WAREHOUSE, brandWarehouse: DESIGNATED_WAREHOUSE,
brandWmsCoId: BRAND_WMS_CO_ID,
}); });
// 成品库存仓白名单的 SQL 片段(纯代码常量插值,不含外部输入)。
// 用 `IN (...)` 一条查完多仓,再由应用层按 SKU **求和**分仓结果。
const BRAND_WMS_CO_ID_LIST_SQL = BRAND_WMS_CO_IDS.join(',');
// 商品集合是主数据、监控快照为日更,10 分钟缓存足够 // 商品集合是主数据、监控快照为日更,10 分钟缓存足够
const MKH_CACHE_TTL_MS = 10 * 60 * 1000; const MKH_CACHE_TTL_MS = 10 * 60 * 1000;
let mkhProductCache = null; let mkhProductCache = null;
@ -1793,23 +1745,21 @@ async function loadReplenishIndex() {
REPLENISH_PARAMS, REPLENISH_PARAMS,
{ requestTimeout: 180000 } { requestTimeout: 180000 }
), ),
// 实时库存(见文件头 ⑦-d):仓白名单多仓,**按 SKU 求和**(同一批货可能分散在多仓)。 // 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条
// ⚠️ 不能再用「按 updateTime DESC 取首条」——那会只留一个仓的数,正好丢掉新仓。
cloudDatabase.query( cloudDatabase.query(
`SELECT i.sku_id AS skuId, SUM(i.qty) AS qty `SELECT i.sku_id AS skuId, i.qty AS qty
FROM dbo.jst_inventoryInfo i FROM dbo.jst_inventoryInfo i
WHERE i.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND i.sku_id LIKE N'MKH%' WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%'
GROUP BY i.sku_id`, ORDER BY i.sku_id ASC, i.updateTime DESC`,
REPLENISH_PARAMS, REPLENISH_PARAMS,
{ requestTimeout: 180000 } { requestTimeout: 180000 }
), ),
// 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到, // 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到,
// 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。 // 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。
// 同样按仓白名单多仓求和(见 BRAND_WMS_CO_IDS 注释)。
cloudDatabase.query( cloudDatabase.query(
`SELECT p.sku_id AS skuId, SUM(p.qty) AS qty `SELECT p.sku_id AS skuId, SUM(p.qty) AS qty
FROM dbo.jst_packInventoryInfo p FROM dbo.jst_packInventoryInfo p
WHERE p.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND p.sku_id LIKE N'MKH%' WHERE p.wms_co_id = @brandWmsCoId AND p.sku_id LIKE N'MKH%'
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
GROUP BY p.sku_id`, GROUP BY p.sku_id`,
REPLENISH_PARAMS, REPLENISH_PARAMS,
@ -1895,10 +1845,6 @@ async function loadReplenishIndex() {
/** /**
* 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。 * 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。
* · 按基础配件编码精确匹配,SUM(qty) 汇总 * · 按基础配件编码精确匹配,SUM(qty) 汇总
* · **两级来源**(与配件监控页 buildRealtimeStockSql 同口径):
* ① jst_inventoryInfo 有正库存 → 以它为准
* ② 为 0 / 缺行 → 用同仓 jst_packInventoryInfo 的 **bin 非空** 行合计兜底
* (2026-09-30 修正:旧版只查 ①,导致「库存表滞后为 0、箱表已有货」的配件被误判不足)
* · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存) * · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存)
* · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标), * · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标),
* 且失败的分片不写缓存,下次调用重试 * 且失败的分片不写缓存,下次调用重试
@ -1926,42 +1872,25 @@ async function loadComponentStock(codes = []) {
params[`cs${index}`] = code; params[`cs${index}`] = code;
return `@cs${index}`; return `@cs${index}`;
}); });
const codeFilter = `IN (${placeholders.join(', ')})`;
// 一条语句同时取两张表(UNION ALL + src 标记),避免多一次往返、也保证两个来源同一时点。
// 库存表不筛 bin;箱表**必须**筛 bin 非空 —— bin 为空的是装箱记录,不是库存(见文件头 ⑤)。
// eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池 // eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池
const rows = await cloudDatabase.query( const rows = await cloudDatabase.query(
`SELECT N'inv' AS src, i.sku_id AS code, SUM(i.qty) AS qty `SELECT i.sku_id AS code, SUM(i.qty) AS qty
FROM dbo.jst_inventoryInfo i WITH (NOLOCK) FROM dbo.jst_inventoryInfo i WITH (NOLOCK)
WHERE i.wms_co_id = @componentStockWmsCoId AND i.sku_id ${codeFilter} WHERE i.wms_co_id = @componentStockWmsCoId
GROUP BY i.sku_id AND i.sku_id IN (${placeholders.join(', ')})
UNION ALL GROUP BY i.sku_id`,
SELECT N'pack' AS src, p.sku_id AS code, SUM(p.qty) AS qty
FROM dbo.jst_packInventoryInfo p WITH (NOLOCK)
WHERE p.wms_co_id = @componentStockWmsCoId AND p.sku_id ${codeFilter}
AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL
GROUP BY p.sku_id`,
params, params,
{ requestTimeout: 120000 } { requestTimeout: 120000 }
); );
const inventoryQty = new Map(); const found = new Map();
const packQty = new Map();
rows.forEach((row) => { rows.forEach((row) => {
const code = toText(row.code); const code = toText(row.code);
if (!code) return; if (code) found.set(code, toNumber(row.qty) ?? 0);
const qty = toNumber(row.qty) ?? 0;
if (toText(row.src) === 'pack') packQty.set(code, qty);
else inventoryQty.set(code, qty);
}); });
const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS; const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS;
const writable = epoch === cacheEpoch; const writable = epoch === cacheEpoch;
chunk.forEach((code) => { chunk.forEach((code) => {
// 只补 0、不覆盖正库存:库存表有正数就认它,否则看箱表(箱表可能只记部分装箱量) const qty = found.get(code) ?? 0;
let qty = inventoryQty.get(code) ?? 0;
if (qty <= 0) {
const packStock = packQty.get(code) ?? 0;
if (packStock > 0) qty = packStock;
}
// 本批结果仍要返回给调用方(stockMap),只是不再写缓存 // 本批结果仍要返回给调用方(stockMap),只是不再写缓存
if (writable) componentStockCache.set(code, { qty, expiresAt }); if (writable) componentStockCache.set(code, { qty, expiresAt });
stockMap.set(code, qty); stockMap.set(code, qty);
@ -2720,9 +2649,6 @@ module.exports = {
COMPONENT_STOCK_WAREHOUSE, COMPONENT_STOCK_WAREHOUSE,
COMPONENT_STOCK_CHUNK_SIZE, COMPONENT_STOCK_CHUNK_SIZE,
COMPONENT_SHORTAGE_MAX_LIST, COMPONENT_SHORTAGE_MAX_LIST,
// ⑦-d 成品库存仓白名单(多仓求和口径)
BRAND_WMS_CO_IDS,
BRAND_WMS_CO_ID,
parseFlag, parseFlag,
resolveScope, resolveScope,
filterRows, filterRows,

View File

@ -1,243 +0,0 @@
'use strict';
/**
* 拣选库位动销分析 —— 业务库取数
*
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty(仓位为空的行不参与)
* 范围:只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);
* 箱记录(pack_type=Pack)与存货位(bin_group=存货)不纳入
* 销量(4 个来源分支,均按 send_date 窗口):
* 1) rpt.jst_Orders × jst_OrderItem 普通行(sku_type<>combine),排除 Delete/Cancelled
* 2) rpt.jst_Orders 组合装行(sku_id 为 ZH-... 拼接串)→ JOIN jst_comboProductItem 展开为子件×数量
* 3) rpt.jst_qmcust_orderlist(全民客户 B2B 分销)普通行,排除 Delete/Cancelled/Split/Question
* (Split 是拆单父单,与拆出的子单重复,必须排除)
* 4) rpt.jst_qmcust_orderlist 组合装行 → 同样按映射表展开
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用
*
* 【日销与动销等级口径】按「商品日销」判定,不做仓位分摊:
* 商品日销 = 该商品在窗口内的整档销量 ÷ 窗口天数
* 同一件商品无论占用几个仓位,各行显示的销量 / 日销 / 动销等级完全一致 ——
* 不会因为货放得散就被算成滞销。
*
* 附带输出「分摊销量」= 商品销量 ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景:
* 恒等式(已实测校验,差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
*/
const { PICKING_BIN_GRADE_RULES, BIN_TYPE_PICKING } = require('../config/pickingBinSalesAnalysis');
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
function buildInClause(values, prefix) {
const params = {};
const clause = values
.map((v, i) => {
const key = `${prefix}${i}`;
params[key] = v;
return `@${key}`;
})
.join(',');
return { clause, params };
}
/**
* 日销分档表达式:从高档往低档依次命中,最后一档「销售=0」由 salesQty <= 0 兜底。
* 传入的 salesExpr 是窗口销量(商品整档销量),除以天数得到日销。
*/
function buildGradeCase(salesExpr = 'ISNULL(s.salesQty, 0)', daysParam = '@days') {
const branches = PICKING_BIN_GRADE_RULES
.filter((rule) => rule.lower > 0)
.map((rule) => {
const daily = `(${salesExpr} / ${daysParam})`;
const lower = `${daily} >= ${rule.lower}`;
const upper = rule.upper == null ? null : `${daily} < ${rule.upper}`;
const cond = upper ? `${lower} AND ${upper}` : lower;
return `WHEN ${cond} THEN '${rule.key}'`;
});
return `
CASE
${branches.join('\n ')}
WHEN ${salesExpr} > 0 THEN 'slow2'
ELSE 'zero'
END`;
}
/**
* 拉取「仓位 × 商品编码」粒度明细(列表与导出共用)。
*
* - stockQty:该商品在此仓位的实际库存
* - salesQty:该商品在此仓库的窗口销量(整档,不按仓位分摊),动销等级的判定依据
* - dailySales:salesQty / 窗口天数 = 商品日销
* - allocatedSalesQty:salesQty ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景
* - skuCount:该仓位内存放的商品数(仓位粒度,同一仓位多行重复)
*
* @param {object} cloudDb 业务库实例
* @param {string[]} wmsCoIds 仓库编码
* @param {{shipFrom:string, shipTo:string, days:number}} options
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string, skuId:string,
* stockQty:number, skuCount:number, salesQty:number, allocatedSalesQty:number,
* dailySales:number, gradeKey:string}>>}
*/
async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
if (!cloudDb || typeof cloudDb.query !== 'function') {
throw new TypeError('fetchPickingBinSkuRows 的第一个参数必须是业务库实例');
}
const { shipFrom, shipTo, days } = options;
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
if (!shipFrom || !shipTo || !days) return [];
const binIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
// 统计范围固定为真实拣选仓位:pack_type=Bin 且 bin_group 为空。
// 仅过滤 bin_group 会把同样为空的 Pack 箱记录误标为拣选位。
const sqlText = `
WITH bin AS (
SELECT p.wms_co_id AS wmsCoId,
LTRIM(RTRIM(p.bin)) AS binName,
N'${BIN_TYPE_PICKING}' AS binType,
p.sku_id AS skuId,
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binSkuQty
FROM rpt.jst_packInventoryInfo p
WHERE p.wms_co_id IN (${binIn.clause})
AND ISNULL(p.bin, '') <> ''
AND ISNULL(p.qty, 0) <> 0
AND p.pack_type = N'Bin'
AND ISNULL(p.bin_group, '') = ''
GROUP BY p.wms_co_id,
LTRIM(RTRIM(p.bin)),
p.sku_id
),
binSpan AS (
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
FROM bin
GROUP BY wmsCoId, skuId, binType
),
ship AS (
-- 1) 线上店铺订单:普通商品行(sku_type<>combine,组合装行在下一分支展开)
SELECT o.wms_co_id AS wmsCoId,
oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id IN (${shipIn.clause})
AND o.send_date >= @shipFrom
AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
GROUP BY o.wms_co_id, oi.sku_id
UNION ALL
-- 2) 线上店铺订单:组合装行(sku_id 为 ZH-子件A-N-子件B-M 拼接串)→ 按映射表展开为子件
SELECT o.wms_co_id AS wmsCoId,
ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18, 2))) AS salesQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
WHERE o.wms_co_id IN (${shipIn.clause})
AND o.send_date >= @shipFrom
AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
AND oi.sku_type = 'combine'
GROUP BY o.wms_co_id, ci.src_sku_id
UNION ALL
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
SELECT o.wms_co_id AS wmsCoId,
oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
FROM rpt.jst_qmcust_orderlist_item oi
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
WHERE o.wms_co_id IN (${shipIn.clause})
AND o.send_date >= @shipFrom
AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
GROUP BY o.wms_co_id, oi.sku_id
UNION ALL
-- 4) 全民客户订单:组合装行展开为子件
SELECT o.wms_co_id AS wmsCoId,
ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18, 2))) AS salesQty
FROM rpt.jst_qmcust_orderlist_item oi
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
WHERE o.wms_co_id IN (${shipIn.clause})
AND o.send_date >= @shipFrom
AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
AND oi.sku_type = 'combine'
GROUP BY o.wms_co_id, ci.src_sku_id
),
shipAgg AS (
-- 同一个 仓库+商品 可能同时命中多个来源分支(线上普通/线上组合装/全民客户普通/全民客户组合装),
-- 必须先按 仓库+商品 汇总成一行再 join,否则一个仓位行会被拆成多行、每行只带某分支的销量。
SELECT wmsCoId,
skuId,
SUM(CAST(salesQty AS DECIMAL(18, 2))) AS salesQty
FROM ship
GROUP BY wmsCoId, skuId
),
binAgg AS (
SELECT wmsCoId,
binName,
binType,
COUNT(DISTINCT skuId) AS skuCount
FROM bin
GROUP BY wmsCoId, binName, binType
)
SELECT b.wmsCoId,
b.binName,
b.binType,
b.skuId,
CAST(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS DECIMAL(18, 2)) AS stockQty,
CAST(a.skuCount AS INT) AS skuCount,
CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
CAST(ISNULL(s.salesQty, 0) / NULLIF(sp.binCount, 0) AS DECIMAL(18, 4)) AS allocatedSalesQty,
CAST(ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
${buildGradeCase('ISNULL(s.salesQty, 0)')} AS gradeKey
FROM bin b
JOIN binSpan sp
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
JOIN binAgg a
ON a.wmsCoId = b.wmsCoId AND a.binName = b.binName AND a.binType = b.binType
LEFT JOIN shipAgg s
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId, a.skuCount, s.salesQty, sp.binCount
HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001
ORDER BY b.wmsCoId, b.binType, b.binName, b.skuId`;
// 该查询 union 了 4 个销量来源(含 qmcust 全渠道扫描),全仓窗口可能超过默认 60s;
// mssql 12.x 请求级 timeout 不生效,必须走池级放宽的慢查询入口。
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
const rows = await runQuery(
sqlText,
{
...binIn.params,
...shipIn.params,
shipFrom,
shipTo,
days,
},
{ requestTimeout: 120000 }
);
return rows.map((r) => ({
wmsCoId: String(r.wmsCoId),
binName: r.binName || '',
binType: r.binType || '',
skuId: r.skuId == null ? '' : String(r.skuId).trim(),
stockQty: Number(r.stockQty) || 0,
skuCount: Number(r.skuCount) || 0,
salesQty: Number(r.salesQty) || 0,
allocatedSalesQty: Number(r.allocatedSalesQty) || 0,
dailySales: Number(r.dailySales) || 0,
gradeKey: r.gradeKey || 'zero',
}));
}
module.exports = {
buildGradeCase,
buildInClause,
fetchPickingBinSkuRows,
};

View File

@ -14,20 +14,13 @@
* · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对; * · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对;
* · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。 * · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。
* *
* 仓位名 → 库存位 对照(2026-09-27 复测,条件 ISNULL(col,0) > 0): * 仓位名 → 库存位 对照(2026-09-25 复测,条件 ISNULL(col,0) > 0):
* 箱及仓位库存 · 简尚-崔桥工厂仓 · 崔桥工厂客退临时上架仓 customize_qty_1 349 编码 / 507 件 * 箱及仓位库存 · 简尚-崔桥工厂仓 · 崔桥工厂客退临时上架仓 customize_qty_1 2 编码 / 2 件 ⚠
* 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 customize_qty_1 75 编码 / 362 件 * 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 customize_qty_1 75 编码 / 362 件
* 箱及仓位库存 · 简尚-睢宁仓 · 睢宁客退上架仓 customize_qty_1 98 编码 / 243 件 * 箱及仓位库存 · 简尚-睢宁仓 · 睢宁客退上架仓 customize_qty_1 98 编码 / 243 件
* 箱及仓位库存 · 简尚-山东青岗集仓 · 青岗集客退上架仓 customize_qty_2 403 编码 / 912 件 * 箱及仓位库存 · 简尚-山东青岗集仓 · 青岗集客退上架仓 customize_qty_2 403 编码 / 912 件
* 箱及仓位库存 · 简尚-河南洛宁仓 · 河南洛宁客退上架仓 三位全为 0(无数据) * 箱及仓位库存 · 简尚-河南洛宁仓 · 河南洛宁客退上架仓 三位全为 0(无数据)
* *
* ── 2026-09-27 重要更新:崔桥工厂仓的客退库存已经同步进副本了 ──
* 09-25 首次切换时该仓客退位只有 2 编码 / 2 件(当时判定「未同步进副本」),
* 09-26 起 IT 侧补上了同步:09-26 当天写入 317 编码 / 460 件,09-27 又补 31 编码 / 46 件,
* 09-27 10:11 实测 349 编码 / 507 件(= 副本 c1 合计,页面与副本完全对得上)。
* ⚠ 所以下面「崔桥必然近乎空白 / 只能靠导入」的旧结论已经作废,不要再拿它当依据。
* 现在这张看板能正常出数,**不需要**用导入功能兜底。
*
* ⚠ 崔桥工厂仓(15038426)是 2026-09-25 用户指定替换横林二仓的。复测结论: * ⚠ 崔桥工厂仓(15038426)是 2026-09-25 用户指定替换横林二仓的。复测结论:
* 副本里该仓客退位只有 2 编码 / 2 件,但退货单量很大(ERP 售后单 3,115 条 + * 副本里该仓客退位只有 2 编码 / 2 件,但退货单量很大(ERP 售后单 3,115 条 +
* 奇门退款单 1,812 条,warehouse='简尚-崔桥工厂仓销退仓')——即「退货有,客退上架库存 * 奇门退款单 1,812 条,warehouse='简尚-崔桥工厂仓销退仓')——即「退货有,客退上架库存
@ -65,8 +58,10 @@ const WAREHOUSE_LIST = [
returnBinName: '崔桥工厂客退临时上架仓', returnBinName: '崔桥工厂客退临时上架仓',
// 客退临时上架仓占用的自定义库存位 // 客退临时上架仓占用的自定义库存位
returnSlot: 'customize_qty_1', returnSlot: 'customize_qty_1',
// 2026-09-27 复测:客退位已同步进副本 —— 349 编码 / 507 件(c1 合计), // ⚠ 2026-09-25 实测:该仓在副本 jst_inventoryInfo 里客退位只有 2 个编码 / 2 件
// 退货单口径:ERP 售后单 3,115 条 + 奇门退款单 1,812 条(warehouse='简尚-崔桥工厂仓销退仓')。 //(qty 总库存 92,818 件;return_qty / c2 / c3 / sale_refund_qty 全为 0)。
// 退货单是有的(ERP 售后单 3,115 条 + 奇门退款单 1,812 条),
// 说明是「客退上架仓」这一仓位没同步进副本,不是业务上没有退货。
hasReturnStock: true, hasReturnStock: true,
}, },
{ {

View File

@ -64,10 +64,6 @@
const cloudDb = require('../config/cloudDatabase'); const cloudDb = require('../config/cloudDatabase');
const database = require('../config/database'); const database = require('../config/database');
const {
loadSupplierExitDates,
EMPTY_EXIT_DATES,
} = require('./supplierExitDateProvider');
const { const {
WAREHOUSE_LIST, WAREHOUSE_LIST,
RETURN_SLOT_COLUMNS, RETURN_SLOT_COLUMNS,
@ -246,7 +242,6 @@ const buildKnownGaps = (warehouse = DEFAULT_WAREHOUSE) => {
'单价 / 总金额:单价取商品资料 jst_sku 的成本价(cost_price),成本价缺失或为 0 时回退采购价(purchase_price);总金额 = 单价 × 总库存(即「客退临时上架仓」的库存金额)。商品资料里两个价格都没有的编码(多为虚拟编码,如 HBZBF)单价与总金额显示「--」,且不计入顶部「库存总金额」,页面会单独提示这类编码的数量。', '单价 / 总金额:单价取商品资料 jst_sku 的成本价(cost_price),成本价缺失或为 0 时回退采购价(purchase_price);总金额 = 单价 × 总库存(即「客退临时上架仓」的库存金额)。商品资料里两个价格都没有的编码(多为虚拟编码,如 HBZBF)单价与总金额显示「--」,且不计入顶部「库存总金额」,页面会单独提示这类编码的数量。',
'单价预警:按商品单价单独分级,默认「单价 > 300 元 → 重点关注」「200 ~ 300 元 → 关注」「< 200 元 → 正常」,阈值可在页面上直接调。它与库龄预警是两个互补维度——库龄看「货压了多久」,单价看「每件压了多少钱」,两者叠加才能看出真正占资金的那批编码。', '单价预警:按商品单价单独分级,默认「单价 > 300 元 → 重点关注」「200 ~ 300 元 → 关注」「< 200 元 → 正常」,阈值可在页面上直接调。它与库龄预警是两个互补维度——库龄看「货压了多久」,单价看「每件压了多少钱」,两者叠加才能看出真正占资金的那批编码。',
'退厂时限:单价 > 重点关注线(默认 300 元)的编码,要求入库后 7 天内完成库存退厂(时限可在页面调)。页面按「在库天数」对比时限,给出「已超期 N 天 / 剩余 N 天」,并在顶部单独提示这批编码的处置要求。「已退厂」的判定是「该编码已从当天库存中消失」(不再依赖出库表),一旦退厂完成,这条编码自然就不会再出现在报表里。', '退厂时限:单价 > 重点关注线(默认 300 元)的编码,要求入库后 7 天内完成库存退厂(时限可在页面调)。页面按「在库天数」对比时限,给出「已超期 N 天 / 剩余 N 天」,并在顶部单独提示这批编码的处置要求。「已退厂」的判定是「该编码已从当天库存中消失」(不再依赖出库表),一旦退厂完成,这条编码自然就不会再出现在报表里。',
'最晚退厂时间(当前「待接入」):业务上由工作平台对「不再合作」的供应商给出最晚退厂日期,看板按供应商名匹配后与「供应商是否合作」列并排展示。它不在报表副本里——数据属于工作平台库 WorkbenchDataShared(同实例另有 WorkbenchIdentity、DataPlatformControl),而 2026-09-28 取证时副本账号 cangku 访问该库被拒(The server principal "cangku" is not able to access the database "WorkbenchDataShared" under the current security context),系统设置里也没有它的连接配置,.env 的 JIAN_SUN_DB_* / WORK_ORDER_* 全为空;副本库内 5,496 个字段中没有任何「退厂」字段,最接近的 fys_supplier.NoCoopTime 是不可用的停更快照(崔桥仓 8 个不合作供应商里只有 1 个有值,且那 1 条自身还标着「合作」)。所以该列先以「待接入」占位,不影响其他列。要出数只需二选一:① 让 IT 给副本账号开通 WorkbenchDataShared 只读;② 让 IT 把工作平台「供应商不合作」表同步进 JSReportReplica。之后在 backend/src/services/supplierExitDateProvider.js 里填上查询语句即可自动出数,页面与导出都无需再改。',
]; ];
if (!warehouse.hasReturnStock) { if (!warehouse.hasReturnStock) {
gaps.unshift( gaps.unshift(
@ -372,16 +367,10 @@ const buildFullSql = (createdAtBasis = DEFAULT_CREATED_AT_BASIS, warehouse = DEF
]; ];
if (useFirstInbound) selectColumns.push('f.first_inbound_time AS [firstInboundTime]'); if (useFirstInbound) selectColumns.push('f.first_inbound_time AS [firstInboundTime]');
selectColumns.push("ISNULL(sk.supplier_name, N'') AS [supplier]"); selectColumns.push("ISNULL(sk.supplier_name, N'') AS [supplier]");
// 供应商是否合作:取自简尚供应商主数据 js_supplier.coopersgn(1=合作 / 2=不合作)
selectColumns.push('sp.coopersgn AS [supplierCoopSign]');
// 单价取自商品资料 jst_sku:成本价优先,缺失/为 0 时回退采购价(见 pickUnitPrice) // 单价取自商品资料 jst_sku:成本价优先,缺失/为 0 时回退采购价(见 pickUnitPrice)
selectColumns.push('sk.cost_price AS [costPrice]'); selectColumns.push('sk.cost_price AS [costPrice]');
selectColumns.push('sk.purchase_price AS [purchasePrice]'); selectColumns.push('sk.purchase_price AS [purchasePrice]');
const joins = ['LEFT JOIN dbo.jst_sku sk ON sk.sku_id = s.product_code']; const joins = ['LEFT JOIN dbo.jst_sku sk ON sk.sku_id = s.product_code'];
// ⚠ 关联键必须是 scmname(供应商简称),与 jst_sku.supplier_name 是同一套命名(2026-09-28 实测
// 匹配 100/109);tradebody(工商全称)只能匹配 1 条,别用。scmname 在 js_supplier 里唯一
// (1,497 行 / 1,497 个不同值),所以 LEFT JOIN 不会放大行数。
joins.push('LEFT JOIN dbo.js_supplier sp ON sp.scmname = sk.supplier_name');
if (useFirstInbound) joins.push('LEFT JOIN first_in f ON f.product_code = s.product_code'); if (useFirstInbound) joins.push('LEFT JOIN first_in f ON f.product_code = s.product_code');
return ` return `
@ -403,13 +392,7 @@ const buildStockSyncedSql = (warehouse = DEFAULT_WAREHOUSE) => `
FROM dbo.jst_inventoryInfo FROM dbo.jst_inventoryInfo
WHERE wms_co_id = ${warehouse.wmsCoId}`; WHERE wms_co_id = ${warehouse.wmsCoId}`;
// 默认 5 分钟:副本侧 ETL 约 10 分钟一批增量(2026-09-27 实测), const CACHE_TTL_MS = 10 * 60 * 1000;
// 这里再压一层缓存只是防止多人反复翻页把副本打爆;想更快用 env 调,或前端点「刷新数据」强制回源。
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
const resolveCacheTtlMs = () => {
const raw = Number.parseInt(process.env.RETURN_STOCK_WARNING_CACHE_TTL_MS, 10);
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_CACHE_TTL_MS;
};
// 缓存键 = 仓库 + 创建时间口径(两者都会改变 SQL / 取到的行) // 缓存键 = 仓库 + 创建时间口径(两者都会改变 SQL / 取到的行)
const buildCacheKey = (warehouse, basis) => `${warehouse.key}|${basis}`; const buildCacheKey = (warehouse, basis) => `${warehouse.key}|${basis}`;
const rowCache = new Map(); const rowCache = new Map();
@ -425,7 +408,7 @@ async function loadReplicaRows({
const basis = resolveCreatedAtBasis(createdAtBasis); const basis = resolveCreatedAtBasis(createdAtBasis);
const key = buildCacheKey(warehouse, basis); const key = buildCacheKey(warehouse, basis);
const cached = rowCache.get(key); const cached = rowCache.get(key);
if (!refresh && cached && cached.rows && Date.now() - cached.at < resolveCacheTtlMs()) { if (!refresh && cached && cached.rows && Date.now() - cached.at < CACHE_TTL_MS) {
return cached.rows; return cached.rows;
} }
const running = inflightCache.get(key); const running = inflightCache.get(key);
@ -454,7 +437,7 @@ async function loadReplicaRows({
/** 副本库存表最近一次同步时间(YYYY-MM-DD HH:mm:ss),页面「库存更新时间」用 */ /** 副本库存表最近一次同步时间(YYYY-MM-DD HH:mm:ss),页面「库存更新时间」用 */
async function loadStockSyncedAt({ refresh = false, warehouse = DEFAULT_WAREHOUSE } = {}) { async function loadStockSyncedAt({ refresh = false, warehouse = DEFAULT_WAREHOUSE } = {}) {
const state = stockSyncedAtState.get(warehouse.key); const state = stockSyncedAtState.get(warehouse.key);
if (!refresh && state?.value && Date.now() - state.at < resolveCacheTtlMs()) { if (!refresh && state?.value && Date.now() - state.at < CACHE_TTL_MS) {
return state.value; return state.value;
} }
try { try {
@ -584,24 +567,6 @@ const resolveReturnLimitDays = (value) => {
* 其余 → pending,returnRemainDays = 剩余天数(时限 − 在库天数) * 其余 → pending,returnRemainDays = 剩余天数(时限 − 在库天数)
* 拿不到创建时间(算不出在库天数)时按 pending 处理,剩余天数留空,页面提示「待补创建时间」。 * 拿不到创建时间(算不出在库天数)时按 pending 处理,剩余天数留空,页面提示「待补创建时间」。
*/ */
// ---------------------------------------------------------------------------
// 供应商是否合作(2026-09-28 新增)
// 依据:简尚供应商主数据 dbo.js_supplier.coopersgn —— 1 = 合作中,2 = 不合作。
// 语义已交叉验证:与 dbo.fys_supplier.IsCoop('合作'/'不合作')比对,
// coopersgn=2 ↔ 不合作 823 条一致、coopersgn=1 ↔ 合作 270 条一致。
// 两边冲突的 262 条以 js_supplier 为准 —— fys_supplier 是旧快照(DownloadTime 停在
// 2025-11,样本多为 2023/2024),而 js_supplier 的 local_updated 已到 2026-09。
// 匹配不上(供应商没进主数据、或该编码没有供应商)一律按「未知」展示,不要当「不合作」。
// ---------------------------------------------------------------------------
const SUPPLIER_COOP_LABELS = { cooperative: '合作', ended: '不合作', unknown: '未知' };
const resolveSupplierCoop = (sign) => {
if (sign === null || sign === undefined || sign === '') return 'unknown';
const n = Number(sign);
if (n === 1) return 'cooperative';
if (n === 2) return 'ended';
return 'unknown';
};
const resolveReturnDeadline = ({ unitPriceLevel, stockDays, isOutbound, returnLimitDays }) => { const resolveReturnDeadline = ({ unitPriceLevel, stockDays, isOutbound, returnLimitDays }) => {
if (unitPriceLevel !== 'high') return { status: 'none', remainDays: null }; if (unitPriceLevel !== 'high') return { status: 'none', remainDays: null };
if (isOutbound) return { status: 'done', remainDays: null }; if (isOutbound) return { status: 'done', remainDays: null };
@ -830,8 +795,6 @@ function composeRows({
replicaRows, overrides, qtyBasis, createdAtBasis, today, scope, focusDays, alertDays, priceMap, replicaRows, overrides, qtyBasis, createdAtBasis, today, scope, focusDays, alertDays, priceMap,
priceFocusMin = DEFAULT_PRICE_FOCUS_MIN, priceHighMin = DEFAULT_PRICE_HIGH_MIN, priceFocusMin = DEFAULT_PRICE_FOCUS_MIN, priceHighMin = DEFAULT_PRICE_HIGH_MIN,
returnLimitDays = DEFAULT_RETURN_LIMIT_DAYS, warehouse = DEFAULT_WAREHOUSE, returnLimitDays = DEFAULT_RETURN_LIMIT_DAYS, warehouse = DEFAULT_WAREHOUSE,
// 供应商「最晚退厂时间」(工作平台数据,见 supplierExitDateProvider;未接入时是空 Map)
exitDates = EMPTY_EXIT_DATES,
}) { }) {
const hasImport = overrides.size > 0; const hasImport = overrides.size > 0;
const rows = []; const rows = [];
@ -858,11 +821,6 @@ function composeRows({
const deadline = resolveReturnDeadline({ const deadline = resolveReturnDeadline({
unitPriceLevel, stockDays, isOutbound, returnLimitDays, unitPriceLevel, stockDays, isOutbound, returnLimitDays,
}); });
// 供应商是否合作(js_supplier.coopersgn)
const supplierCoop = resolveSupplierCoop(row.supplierCoopSign);
// 最晚退厂时间(工作平台「供应商不合作」,按供应商名匹配;未接入时为空)
const supplierName = String(row.supplier || '').trim();
const latestExitDate = exitDates.bySupplier.get(supplierName) || '';
if (scope === 'imported' && !override) return; if (scope === 'imported' && !override) return;
if (qty <= 0) return; if (qty <= 0) return;
@ -893,11 +851,6 @@ function composeRows({
stockDays, stockDays,
warningLevel: resolveWarningLevel(stockDays, focusDays, alertDays), warningLevel: resolveWarningLevel(stockDays, focusDays, alertDays),
isOutbound, isOutbound,
supplierCoop,
supplierCoopLabel: SUPPLIER_COOP_LABELS[supplierCoop] || '',
latestExitDate,
latestExitDateLabel: latestExitDate,
latestExitDateAvailable: exitDates.available,
source: override ? 'jst' : 'replica', source: override ? 'jst' : 'replica',
noInboundRecord: !createdAtRaw, noInboundRecord: !createdAtRaw,
}); });
@ -917,9 +870,6 @@ function composeRows({
const deadline = resolveReturnDeadline({ const deadline = resolveReturnDeadline({
unitPriceLevel, stockDays, isOutbound, returnLimitDays, unitPriceLevel, stockDays, isOutbound, returnLimitDays,
}); });
// 这条是「导入里有、副本里没有」补进来的编码,没走副本 join,拿不到 coopersgn → 标「未知」
const supplierCoop = 'unknown';
const supplierExitDate = exitDates.bySupplier.get(String(override.supplier || '').trim()) || '';
rows.push({ rows.push({
productCode: code, productCode: code,
createdAt: formatDateTime(override.createdAt), createdAt: formatDateTime(override.createdAt),
@ -945,11 +895,6 @@ function composeRows({
stockDays, stockDays,
warningLevel: resolveWarningLevel(stockDays, focusDays, alertDays), warningLevel: resolveWarningLevel(stockDays, focusDays, alertDays),
isOutbound, isOutbound,
supplierCoop,
supplierCoopLabel: SUPPLIER_COOP_LABELS[supplierCoop] || '',
latestExitDate: supplierExitDate,
latestExitDateLabel: supplierExitDate,
latestExitDateAvailable: exitDates.available,
source: 'jst', source: 'jst',
noInboundRecord: !override.createdAt, noInboundRecord: !override.createdAt,
}); });
@ -1112,11 +1057,10 @@ async function getReturnStockWarningList(options = {}) {
const priceLevel = resolvePriceLevelFilter(options.priceLevel); const priceLevel = resolvePriceLevelFilter(options.priceLevel);
const today = formatLocalDate(); const today = formatLocalDate();
const [replicaRows, overrides, latestImport, exitDates] = await Promise.all([ const [replicaRows, overrides, latestImport] = await Promise.all([
loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }), loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }),
loadImportOverrides(warehouse), loadImportOverrides(warehouse),
loadLatestImportBatch(warehouse), loadLatestImportBatch(warehouse),
loadSupplierExitDates(),
]); ]);
const priceMap = await loadPriceMapForOverrides(replicaRows, overrides); const priceMap = await loadPriceMapForOverrides(replicaRows, overrides);
@ -1135,7 +1079,6 @@ async function getReturnStockWarningList(options = {}) {
priceHighMin, priceHighMin,
returnLimitDays, returnLimitDays,
warehouse, warehouse,
exitDates,
}); });
const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier)); const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier));
@ -1186,12 +1129,6 @@ async function getReturnStockWarningList(options = {}) {
summary, summary,
import: latestImport, import: latestImport,
hasImport: composed.hasImport, hasImport: composed.hasImport,
// 「最晚退厂时间」数据源状态(页面用它渲染「待接入」提示与列 Tooltip)
exitDateMeta: {
available: exitDates.available,
sourceLabel: exitDates.sourceLabel,
message: exitDates.message,
},
gaps: buildKnownGaps(warehouse), gaps: buildKnownGaps(warehouse),
}; };
} }
@ -1211,8 +1148,7 @@ const buildExportColumns = (warehouse = DEFAULT_WAREHOUSE) => [
{ key: 'amount', title: '总金额' }, { key: 'amount', title: '总金额' },
{ key: 'unitPriceLevelLabel', title: '单价预警' }, { key: 'unitPriceLevelLabel', title: '单价预警' },
{ key: 'returnDeadlineLabel', title: '退厂时限' }, { key: 'returnDeadlineLabel', title: '退厂时限' },
{ key: 'supplierCoopLabel', title: '供应商是否合作' }, { key: 'isOutboundLabel', title: '是否出库' },
{ key: 'latestExitDateLabel', title: '最晚退厂时间' },
{ key: 'returnQty', title: '退货仓库存(return_qty)' }, { key: 'returnQty', title: '退货仓库存(return_qty)' },
{ key: 'returnSlotQty', title: `客退临时上架库存(${warehouse.returnSlot})` }, { key: 'returnSlotQty', title: `客退临时上架库存(${warehouse.returnSlot})` },
{ key: 'sourceLabel', title: '数据来源' }, { key: 'sourceLabel', title: '数据来源' },
@ -1238,10 +1174,9 @@ async function getReturnStockWarningExportRows(options = {}) {
const priceLevel = resolvePriceLevelFilter(options.priceLevel); const priceLevel = resolvePriceLevelFilter(options.priceLevel);
const today = formatLocalDate(); const today = formatLocalDate();
const [replicaRows, overrides, exitDates] = await Promise.all([ const [replicaRows, overrides] = await Promise.all([
loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }), loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }),
loadImportOverrides(warehouse), loadImportOverrides(warehouse),
loadSupplierExitDates(),
]); ]);
const priceMap = await loadPriceMapForOverrides(replicaRows, overrides); const priceMap = await loadPriceMapForOverrides(replicaRows, overrides);
@ -1260,7 +1195,6 @@ async function getReturnStockWarningExportRows(options = {}) {
priceHighMin, priceHighMin,
returnLimitDays, returnLimitDays,
warehouse, warehouse,
exitDates,
}); });
const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier)); const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier));
@ -1274,10 +1208,7 @@ async function getReturnStockWarningExportRows(options = {}) {
warningLabel: WARNING_LABELS[row.warningLevel] || WARNING_LABELS.normal, warningLabel: WARNING_LABELS[row.warningLevel] || WARNING_LABELS.normal,
unitPriceLevelLabel: PRICE_WARNING_LABELS[row.unitPriceLevel] || '', unitPriceLevelLabel: PRICE_WARNING_LABELS[row.unitPriceLevel] || '',
returnDeadlineLabel: formatReturnDeadlineLabel(row.returnDeadlineStatus, row.returnRemainDays), returnDeadlineLabel: formatReturnDeadlineLabel(row.returnDeadlineStatus, row.returnRemainDays),
supplierCoopLabel: row.supplierCoopLabel || SUPPLIER_COOP_LABELS.unknown, isOutboundLabel: row.isOutbound ? '是' : '否',
// 工作平台未接入时导出显示「待接入」,避免空列被误读成「没有退厂时间」
latestExitDateLabel: row.latestExitDateLabel
|| (row.latestExitDateAvailable ? '—' : '待接入'),
sourceLabel: row.createdAtSourceLabel || '', sourceLabel: row.createdAtSourceLabel || '',
createdAtRaw: undefined, createdAtRaw: undefined,
})); }));

View File

@ -139,12 +139,7 @@ async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
* 「销售=0」档的 SKU 级明细(导出用)。 * 「销售=0」档的 SKU 级明细(导出用)。
* 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero', * 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero',
* 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。 * 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。
* * @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, stockQty:number, salesQty:number}>>}
* 系统仓位取自 rpt.jst_packInventoryInfo.bin(jst_sku.bin 全为空、库存表无仓位字段)。
* 每个仓位独立一行,库存数量按该仓位的实际 qty 统计(各仓位行之和 = SKU 总库存):
* - 有仓位数据的 SKU:按 (wms_co_id, sku_id, bin) 展开,qty=0 的仓位行剔除;
* - 无仓位数据的 SKU(仓位表未覆盖的仓库):保留一行,仓位为空、库存取 SKU 总库存,避免丢数。
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, binName:string, stockQty:number, salesQty:number}>>}
*/ */
async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) { async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
if (!cloudDb || typeof cloudDb.query !== 'function') { if (!cloudDb || typeof cloudDb.query !== 'function') {
@ -158,17 +153,7 @@ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days
const gradeCase = buildGradeCase(); const gradeCase = buildGradeCase();
const sqlText = ` const sqlText = `
WITH bin AS ( WITH inv AS (
SELECT p.wms_co_id AS wmsCoId,
p.sku_id AS skuId,
LTRIM(RTRIM(p.bin)) AS b,
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binQty
FROM rpt.jst_packInventoryInfo p
WHERE ISNULL(p.bin, '') <> ''
AND ISNULL(p.qty, 0) <> 0
GROUP BY p.wms_co_id, p.sku_id, LTRIM(RTRIM(p.bin))
),
inv AS (
SELECT i.wms_co_id AS wmsCoId, SELECT i.wms_co_id AS wmsCoId,
i.sku_id AS skuId, i.sku_id AS skuId,
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
@ -213,14 +198,12 @@ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days
SELECT g.wmsCoId, SELECT g.wmsCoId,
g.skuId, g.skuId,
ISNULL(s.name, '') AS skuName, ISNULL(s.name, '') AS skuName,
ISNULL(bn.b, '') AS binName, CAST(g.stockQty AS DECIMAL(18, 2)) AS stockQty,
CAST(COALESCE(bn.binQty, g.stockQty) AS DECIMAL(18, 2)) AS stockQty,
CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty
FROM graded g FROM graded g
LEFT JOIN rpt.jst_sku s ON s.sku_id = g.skuId LEFT JOIN rpt.jst_sku s ON s.sku_id = g.skuId
LEFT JOIN bin bn ON bn.wmsCoId = g.wmsCoId AND bn.skuId = g.skuId
WHERE g.gradeKey = 'zero' WHERE g.gradeKey = 'zero'
ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId, bn.b`; ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId`;
const rows = await cloudDb.query(sqlText, { const rows = await cloudDb.query(sqlText, {
...invIn.params, ...invIn.params,
@ -234,7 +217,6 @@ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days
wmsCoId: String(r.wmsCoId), wmsCoId: String(r.wmsCoId),
skuId: String(r.skuId ?? ''), skuId: String(r.skuId ?? ''),
skuName: r.skuName == null ? '' : String(r.skuName), skuName: r.skuName == null ? '' : String(r.skuName),
binName: r.binName == null ? '' : String(r.binName),
stockQty: Number(r.stockQty) || 0, stockQty: Number(r.stockQty) || 0,
salesQty: Number(r.salesQty) || 0, salesQty: Number(r.salesQty) || 0,
})); }));

View File

@ -1,140 +0,0 @@
'use strict';
/**
* 「最晚退厂时间」取数(2026-09-28 新增)
*
* 业务口径:供应商不再合作时,工作平台会给该供应商一个「最晚退厂时间」,
* 即它放在仓里的货最后必须拉走的日期,看板上跟「供应商是否合作」列并排展示。
*
* ── 数据在哪 ──────────────────────────────────────────────────────────────
* 工作平台库。副本实例(218.93.9.21:1433)上共有 9 个库:
* master / model / msdb / tempdb / SSISDB / JSReportReplica(副本,我们只读它)/
* DataPlatformControl / WorkbenchDataShared / WorkbenchIdentity。
* 「工作平台」= WorkbenchDataShared(配套身份库 WorkbenchIdentity,控制库 DataPlatformControl)。
*
* ── 当前为什么是空的(2026-09-28 已逐条取证,别再重复排查)───────────────
* ① 副本账号 cangku 访问 WorkbenchDataShared / WorkbenchIdentity / DataPlatformControl
* **一律被拒**:The server principal "cangku" is not able to access the database
* "WorkbenchDataShared" under the current security context.
* ② 系统设置(database_configs)里只有 cloud_database_public(JSReportReplica)与
* product_information_database(PostgreSQL),没有工作平台库。
* ③ 本机 .env 与生产 /opt/cangku/shared/backend.env 里的 JIAN_SUN_DB_HOST/USER/PASSWORD、
* WORK_ORDER_* 全部为空(只留了端口/库名占位)。
* ④ 副本库内部没有任何「退厂」字段:5,496 个字段全量搜过,COLUMN_NAME 含「退厂」的为 0;
* 最接近的 fys_supplier.NoCoopTime(不合作时间)**不能拿来顶替** —— 它是停更快照
* (DownloadTime 多在 2023~2024),崔桥仓 8 个不合作供应商里只有 1 个有值,
* 而且那 1 条自身还标着 IsCoop='合作',自相矛盾。
*
* ── IT 开通后怎么接(只需改本文件)────────────────────────────────────────
* 二选一,改完即生效,页面与导出都会自动出数:
* A. 给副本账号授只读:让 IT 执行 `USE WorkbenchDataShared; GRANT SELECT TO [cangku];`
* (或建同义词/视图),然后把下面 EXIT_DATE_QUERY 填成跨库查询,例如:
* SELECT SupplierName AS supplierName, CONVERT(varchar(10), LatestExitDate, 120) AS exitDate
* FROM WorkbenchDataShared.dbo.<供应商不合作表>
* B. 让 IT 把「供应商不合作」表同步进 JSReportReplica(像 js_supplier / fys_supplier 那样),
* 再把 EXIT_DATE_QUERY 指向副本里的表名。
* 查询只需要返回两列:supplierName(供应商名,口径同 js_supplier.scmname / jst_sku.supplier_name,
* 即简称)与 exitDate(最晚退厂日期,字符串 'YYYY-MM-DD' 或 datetime 均可)。
*
* 未接入时本模块恒返回 available=false,页面该列显示「待接入」,不会报错、不影响其他列。
*/
const cloudDb = require('../config/cloudDatabase');
const SOURCE_LABEL = '工作平台(WorkbenchDataShared)· 供应商不合作';
const UNAVAILABLE_MESSAGE = '工作平台库尚未授权,待 IT 开通后自动出数';
// ---- 接入点(IT 开通后配这两个环境变量即可,不必改代码)--------------------
// SUPPLIER_EXIT_DATE_ENABLED=true
// SUPPLIER_EXIT_DATE_SQL=<查询语句,需返回 supplierName / exitDate 两列>
// 也可以在下面 CODE_* 常量里直接写死(env 优先)。
const CODE_EXIT_DATE_ENABLED = false;
const CODE_EXIT_DATE_QUERY = '';
const ENABLED = process.env.SUPPLIER_EXIT_DATE_ENABLED === 'true' || CODE_EXIT_DATE_ENABLED;
const EXIT_DATE_QUERY = process.env.SUPPLIER_EXIT_DATE_SQL || CODE_EXIT_DATE_QUERY;
// ---------------------------------------------------------------------------
const CACHE_TTL_MS = Number.parseInt(process.env.SUPPLIER_EXIT_DATE_CACHE_TTL_MS, 10) >= 0
? Number.parseInt(process.env.SUPPLIER_EXIT_DATE_CACHE_TTL_MS, 10)
: 30 * 60 * 1000;
let cache = null; // { at, payload }
const formatExitDate = (value) => {
if (value === null || value === undefined || value === '') return '';
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '';
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
const text = String(value).trim();
if (!text) return '';
const match = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
if (!match) return text;
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
};
/**
* 取「供应商 → 最晚退厂时间」映射。
* 返回 { available, sourceLabel, message, bySupplier: Map<string, string> }
*/
async function loadSupplierExitDates() {
const describe = () => ({
available: false,
sourceLabel: SOURCE_LABEL,
message: UNAVAILABLE_MESSAGE,
bySupplier: new Map(),
});
if (!ENABLED || !EXIT_DATE_QUERY) return describe();
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.payload;
try {
const rows = await cloudDb.query(EXIT_DATE_QUERY, {}, { requestTimeout: 120000 });
const bySupplier = new Map();
(rows || []).forEach((row) => {
const name = String(row.supplierName || '').trim();
const date = formatExitDate(row.exitDate);
if (!name || !date) return;
const prev = bySupplier.get(name);
// 同一供应商多条时取最晚的日期
if (!prev || date > prev) bySupplier.set(name, date);
});
const payload = {
available: true,
sourceLabel: SOURCE_LABEL,
message: '',
bySupplier,
};
cache = { at: Date.now(), payload };
return payload;
} catch (error) {
// 取不到不算致命:该列留空、页面提示待接入,其余列照常
console.warn('[SupplierExitDate] 最晚退厂时间取数失败:', error.message);
const payload = {
available: false,
sourceLabel: SOURCE_LABEL,
message: `工作平台库读取失败(${error.message}),该列暂空`,
bySupplier: new Map(),
};
cache = { at: Date.now(), payload };
return payload;
}
}
const EMPTY_EXIT_DATES = Object.freeze({
available: false,
sourceLabel: SOURCE_LABEL,
message: UNAVAILABLE_MESSAGE,
bySupplier: new Map(),
});
module.exports = {
loadSupplierExitDates,
formatExitDate,
EMPTY_EXIT_DATES,
EXIT_DATE_SOURCE_LABEL: SOURCE_LABEL,
EXIT_DATE_UNAVAILABLE_MESSAGE: UNAVAILABLE_MESSAGE,
};

View File

@ -1,318 +0,0 @@
'use strict';
/**
* 「仓储中心物流转运异常」每天定点推文本到钉钉群 —— 配置与状态存取
* ────────────────────────────────────────────────────────────────────────────
* 配置分两层存(与统一通知中心同一套做法,见 yydNotificationCenterSettings):
* · 普通字段 → 本地库 `system_config`,key = warehouse_logistics_exception_notify_config
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
*
* 通道是「钉钉群自定义机器人 webhook」(2026-10-22 业务定:不发 .xlsx 附件,
* 群里直接看文本),所以只有两个凭据字段:
* · webhookUrl 群机器人地址(含 access_token;完整回显,业务要能核对)
* · webhookSecret 加签密钥(SEC 开头,只回掩码;安全设置选「关键词/IP」时留空)
*
* 推送时刻用页面上的 HH:mm(默认 09:10,核验 09:00 之后),**按北京时间解释**:
* 生产容器是 UTC,绝不能拿容器本地时间比。
*/
const database = require('../config/database');
const {
getResilientSecret,
saveResilientSecret,
} = require('./resilientSecretStore');
const CONFIG_KEY = 'warehouse_logistics_exception_notify_config';
const SECRET_KEY = 'warehouse_logistics_exception_notify_webhook_secret';
const STATE_KEY = 'warehouse_logistics_exception_notify_state';
const DEFAULT_SEND_TIME = '09:10';
// 明细条数:text 消息上限 20000 字节,30 条中文明细约 9~12KB,留足余量
const DEFAULT_MAX_DETAIL_ROWS = 30;
const MAX_DETAIL_ROWS_LIMIT = 50;
// 页面地址(消息里给「查看全部」链接)。生产是带 8443 的域名,别写成裸域名
const DEFAULT_PAGE_URL = process.env.WLE_PAGE_URL
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-logistics-exception';
const CACHE_TTL_MS = 30 * 1000;
let cachedConfig = null;
let cachedAt = 0;
function normalizeText(value, maxLength = 512) {
return String(value ?? '').trim().slice(0, maxLength);
}
function safeJsonParse(value, fallback = {}) {
if (value && typeof value === 'object') return value;
try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; }
}
function maskSecret(value) {
const text = normalizeText(value);
if (!text) return '';
if (text.length <= 8) return `${text.slice(0, 2)}******`;
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
}
/** HH:mm(00:00 ~ 23:59);非法输入退回默认 09:10,不抛错(避免把定时任务配挂) */
function normalizeSendTime(value) {
const text = normalizeText(value, 5);
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
return DEFAULT_SEND_TIME;
}
/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */
function normalizeMaxDetailRows(value) {
const num = Number(value);
if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS;
return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num)));
}
function normalizePageUrl(value) {
const text = normalizeText(value, 1024);
if (!text) return DEFAULT_PAGE_URL;
try {
const url = new URL(text);
if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL;
return url.toString();
} catch (_error) {
return DEFAULT_PAGE_URL;
}
}
function normalizeBoolean(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const text = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
if (['0', 'false', 'no', 'off'].includes(text)) return false;
return fallback;
}
function splitSendTime(sendTime) {
const [hour, minute] = normalizeSendTime(sendTime).split(':').map((part) => Number(part));
return { hour, minute };
}
async function upsertConfigRow(value) {
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[CONFIG_KEY]
);
const text = JSON.stringify(value);
if (rows[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[text, 'json', CONFIG_KEY]
);
} else {
await database.local.insert('system_config', {
config_key: CONFIG_KEY,
config_value: text,
config_type: 'json',
});
}
}
async function readConfigRow() {
try {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[CONFIG_KEY]
);
return safeJsonParse(rows[0]?.configValue, {});
} catch (error) {
console.warn('[WleNotify] 读取推送配置失败:', error.message);
return {};
}
}
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
async function getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh = false } = {}) {
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig };
const stored = await readConfigRow();
const secret = await getResilientSecret(SECRET_KEY, {
legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)],
fallbackValues: [normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
});
cachedConfig = {
enabled: normalizeBoolean(stored.enabled, false),
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|| normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK, 1024),
webhookSecret: secret.value,
webhookSecretSource: secret.source,
sendTime: normalizeSendTime(stored.sendTime || stored.send_time),
maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows),
atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false),
pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url),
source: Object.keys(stored).length > 0 ? 'database' : 'default',
};
cachedAt = Date.now();
return { ...cachedConfig };
}
/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/
function missingFields(config = {}) {
const missing = [];
if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址');
return missing;
}
function isWarehouseLogisticsExceptionNotifyReady(config = {}) {
return missingFields(config).length === 0;
}
function toAdminConfig(config = {}) {
return {
enabled: config.enabled === true,
webhookUrl: config.webhookUrl || '',
webhookSecretConfigured: Boolean(config.webhookSecret),
webhookSecretPreview: maskSecret(config.webhookSecret),
webhookSecretSource: config.webhookSecretSource || 'unconfigured',
sendTime: normalizeSendTime(config.sendTime),
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
atAll: config.atAll === true,
pageUrl: normalizePageUrl(config.pageUrl),
ready: isWarehouseLogisticsExceptionNotifyReady(config),
missingFields: missingFields(config),
source: config.source || 'default',
};
}
async function getWarehouseLogisticsExceptionNotifyAdminConfig() {
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
return toAdminConfig(config);
}
/**
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
*/
async function saveWarehouseLogisticsExceptionNotifyConfig(input = {}) {
const current = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512);
const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true;
const config = {
enabled: normalizeBoolean(input.enabled, current.enabled),
webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl,
webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret),
sendTime: input.sendTime === undefined && input.send_time === undefined
? normalizeSendTime(current.sendTime)
: normalizeSendTime(input.sendTime ?? input.send_time),
maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined
? normalizeMaxDetailRows(current.maxDetailRows)
: normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows),
atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true),
pageUrl: input.pageUrl === undefined && input.page_url === undefined
? normalizePageUrl(current.pageUrl)
: normalizePageUrl(input.pageUrl ?? input.page_url),
};
if (config.enabled) {
const missing = missingFields(config);
if (missing.length) {
throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 });
}
}
if (config.webhookUrl) {
// 这里只做「像不像」的浅校验;真正的连通性由「测试推送」按钮验证
const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender');
try {
config.webhookUrl = normalizeWebhookUrl(config.webhookUrl);
} catch (error) {
throw Object.assign(new Error(error.message), { status: 400 });
}
}
if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret);
await upsertConfigRow({
enabled: config.enabled,
webhookUrl: config.webhookUrl,
sendTime: config.sendTime,
maxDetailRows: config.maxDetailRows,
atAll: config.atAll,
pageUrl: config.pageUrl,
updatedAt: new Date().toISOString(),
});
cachedConfig = {
...config,
webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')),
source: 'database',
};
cachedAt = Date.now();
return toAdminConfig(cachedConfig);
}
/** 上次推送结果(页面展示 + 定时任务用来判断「今天是不是已经推过」) */
async function loadWarehouseLogisticsExceptionNotifyState() {
try {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[STATE_KEY]
);
const state = safeJsonParse(rows[0]?.configValue, {});
return state && typeof state === 'object' ? state : {};
} catch (error) {
console.warn('[WleNotify] 读取推送状态失败:', error.message);
return {};
}
}
async function saveWarehouseLogisticsExceptionNotifyState(next = {}) {
const value = JSON.stringify(next && typeof next === 'object' ? next : {});
try {
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[STATE_KEY]
);
if (rows[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[value, 'json', STATE_KEY]
);
} else {
await database.local.insert('system_config', {
config_key: STATE_KEY,
config_value: value,
config_type: 'json',
});
}
} catch (error) {
console.warn('[WleNotify] 保存推送状态失败:', error.message);
}
}
function clearNotifyConfigCache() {
cachedConfig = null;
cachedAt = 0;
}
module.exports = {
CONFIG_KEY,
DEFAULT_MAX_DETAIL_ROWS,
DEFAULT_PAGE_URL,
DEFAULT_SEND_TIME,
MAX_DETAIL_ROWS_LIMIT,
SECRET_KEY,
STATE_KEY,
clearNotifyConfigCache,
getWarehouseLogisticsExceptionNotifyAdminConfig,
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
isWarehouseLogisticsExceptionNotifyReady,
loadWarehouseLogisticsExceptionNotifyState,
maskSecret,
missingFields,
normalizeMaxDetailRows,
normalizePageUrl,
normalizeSendTime,
saveWarehouseLogisticsExceptionNotifyConfig,
saveWarehouseLogisticsExceptionNotifyState,
splitSendTime,
toAdminConfig,
};

View File

@ -1,388 +0,0 @@
'use strict';
/**
* 仓储中心物流转运异常(2026-10-21 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-21):
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
* 下属 12 个发货仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
* 山东青岗集唯品仓 / 河南洛宁仓
* 核验规则:**当日 09:00 核验前一日已发货订单的物流记录** ——
* 昨天发了货,到今天早上 9 点物流还没有正常轨迹(没揽收 / 没物流单 / 快递报异常 /
* 被退回 / 揽收后长时间无新轨迹),就是「物流转运异常」,按仓归口。
*
* ── 数据源 ────────────────────────────────────────────────────────────────
* `rpt.jst_order_express_details`(聚水潭订单状态镜像,402 万行,视图)
* 这是一张「订单 + 物流」合并的宽表,一单多包裹会有多行(按 logistic_id 区分)。
* 本页用到的列:
* send_time 发货时间(聚水潭回传)
* logistic_status_name 物流状态:已签收 / 运输中 / 派送中 / 已揽收 /
* 未揽收 / 无物流单 / 异常 / 拒收 / 其他
* logistic_time 最后一条物流轨迹时间
* last_logistic_info 最后一条轨迹描述(nvarchar 6000,SQL 里截 300 字)
* query_time ETL 快照时间(每行最后一次被刷新的时刻)
* ⚠️ 别用 `logistic_time_to_now_h` 判「停滞」:那是相对**查询时刻**算的,
* 而本页的核验基准是**核验日的 09:00**(回看历史批次时会飘),统一在 Node 侧按
* `logistic_time → deadline` 重算。
*
* ── 异常判定(2026-10-21 定版 · 二次收敛)────────────────────────────────
* 业务口径:「统计的是**未揽收**和**已揽收、但没有下一步中转记录**的」,
* 「有『运输』字样的,往后中转记录不考核」。
* `NO_TRACKING` 无物流单 —— 发了货但没生成运单号(打单环节漏了)
* `NOT_PICKED` 未揽收 —— 到核验时刻快递还没揽收
* `PICKED_NO_TRANSIT` 已揽收未中转 —— 揽收后没有下一步中转/运输记录,卡在网点
* `CARRIER_EXCEPTION` 快递报异常 —— 状态=异常(寄件人取消寄件、异常签收等)
* `RETURNED` 退回 —— 状态=拒收,或「其他」(实测基本是「换单退回」)
* 已签收 / 运输中 / 派送中 → 正常,不进列表。
* 🔥 原来那套「揽收后 24h 无新轨迹 = 运输停滞」已**废弃**:
* 干线与中转轨迹本来就不回传聚水潭,按 24h 判全是假异常。
*
* ── 仓库归属 ──────────────────────────────────────────────────────────────
* 与「24 小时发货时效」同一套:按仓库名关键词**最长匹配**归主仓,子仓自动并入;
* 「京仓-横林二仓京东云仓」业务指定排除(与发货时效页保持一致)。
* 山东青岗集唯品仓的关键词必须比「山东青岗集」更长,否则会被青岗集仓抢走。
*
* ── 时区 ──────────────────────────────────────────────────────────────────
* 🔥 生产容器是 `Etc/UTC`,DB 的 `GETDATE()` 是北京时间 —— 详见
* warehouseShipTimelinessRegistry.beijingNow() 的说明。
* 本模块一律用 `beijingNow()` 取「今天」,**不要**直接 `new Date()`。
*
* ── 批次 ──────────────────────────────────────────────────────────────────
* 「核验批次」= 核验日 + 发货日(核验日的前一天)。
* 默认:现在已过 09:00 → 今天这批(核验昨天发货);未到 09:00 → 昨天那批。
* 可回看近 30 天。
* ⚠️ 历史批次是**按当前物流快照重算**的 —— 昨天是异常、今天已经签收的单,
* 回看时会显示成正常。页面会明确标注,别拿它当「当时的留档」。
*/
const BEIJING_OFFSET_MINUTES = 480;
// ── 12 个发货仓(业务给定,顺序即展示顺序)────────────────────────────────
const CENTERS = Object.freeze([
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] },
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌'] },
// 🔥 关键词要比「山东青岗集」长,否则「简尚-山东青岗集仓唯品仓」会被青岗集仓抢走
{ key: 'SD_QGJ_VIP', name: '山东青岗集唯品仓', keywords: ['山东青岗集仓唯品', '山东青岗集唯品'] },
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] },
]);
// 业务指定排除:京仓(平台仓)不算自营发货仓的考核范围(与发货时效页一致)
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze(['京仓-横林二仓京东云仓']);
// ── 不参与考核的承运商(业务 2026-10-21 指定)──────────────────────────────
// 判据 = 状态表 `logistic_company_name` **包含**关键词(不区分大小写)。
// 🔥 「武进安能」**不能**泛化成「安能」:12 仓另有「常州安能新」3383 单/30 天,
// 那是要正常考核的承运商,泛化会把整片正常单误剔。
// 🔥 「中铁」「壹米滴答」这类专线物流**轨迹不回传聚水潭**,实测近 30 天
// 中铁物流 104 单 100% 报「异常」、壹米滴答 33 单 100% 报「异常」、
// 唯品顺丰 2510 单 2500 单报「异常」(99.6%)—— 全是假异常,必须剔。
// 「自提」「线下」当前 12 仓实测 0 单(历史/其它仓才有),保留判定防止以后出现。
// 🔥 「京喜京东」(含 `京喜京东快递` / `京喜京东快运`)是京喜自营那条线,
// 实测山东青岗集仓的京喜单揽收后轨迹全停在站点、不回传聚水潭,
// 近 30 天 62 单「已揽收未中转」全出自这里 —— 业务 2026-10-21 指定剔除。
// 只按「京喜京东」前缀剔:`京东自营快递亿家达` / `崔桥京东特惠` / `曹县京东快运`
// 是另外的账号,正常回传轨迹,不能连带剔掉。
// ⚠️ 2026-10-22 业务一度要求「凡是带京东快递、京喜的都踢出」,随即**撤回该要求** ——
// 所以这里维持只剔「京喜京东」,别把关键词放大成「京东」。
const EXCLUDED_CARRIERS = Object.freeze([
// 🔥 顺丰系**全部剔除**(业务 2026-10-22 定版):
// 实测 12 仓近 30 天顺丰系共 7 个账号 —— `唯品顺丰` / `顺丰干配`(洛宁、山东、苏北大小件)/
// `山东顺丰大件` / `景瓴十方顺丰大件-莒南`,轨迹都不按常规回传聚水潭
// (唯品顺丰 99.6% 报异常、顺丰干配 52% 报未揽收、山东顺丰大件 67% 报未揽收)。
// 所以这里直接用「顺丰」一个关键词全覆盖,**不再逐个列账号**。
// 保留 `唯品顺丰` 在更前面:命中时 reason 更具体,页脚能看出是唯品那条线。
'唯品顺丰',
'顺丰',
'武进安能',
'中铁',
'壹米滴答',
'自提',
'线下',
'商家配送',
// 京东系只剔「京喜京东」(京喜自营那条线,揽收后轨迹停在站点不回传)。
// ⚠️ 别放大成「京东」:`京东快递` / `京东自营快递亿家达` / `京东自营快递朴美` /
// `崔桥京东特惠` / `曹县京东快运` 等 16 个账号业务确认仍要正常考核
// (2026-10-22 曾短暂要求全剔,随即撤回)。
'京喜京东',
]);
// ── 有条件剔除:同一承运商,只在某些仓算正常(业务 2026-10-21 追加)────────
// 「睢宁中通」是全公司唯一的中通面单账号,开在睢宁网点。
// · 睢宁仓自己用 → 正常揽收、轨迹完整,**照常考核**;
// · 山东品牌仓 / 河南洛宁仓 / 崔桥工厂仓 异地用它打单 → 揽收不畅,
// 近 30 天非睢宁仓 490 单里 163 单「未揽收」(33%),全是账号异地揽收造成的假异常。
// 所以:`keepCenterKeys` 里的仓照常考核,其它仓命中即剔除。
const CONDITIONAL_EXCLUDED_CARRIERS = Object.freeze([
{
keyword: '睢宁中通',
keepCenterKeys: ['SN'],
reason: '睢宁中通(非睢宁仓异地揽收)',
},
]);
// ── 已终止的订单不算转运异常(业务 2026-10-21 指定)──────────────────────
// ① 订单状态:状态表 `order_status_name` 实测只有「已发货 / 已取消」两个值,
// 「已取消」在近 30 天 12 仓里 8 单,其中 6 单会误报未揽收/无物流单。
const EXCLUDED_ORDER_STATUS_KEYWORDS = Object.freeze(['取消', '作废']);
// ② 订单标签:`order_labels`(JSON 数组字符串)
// · 「发货后取消」= 客户已申请退款取消,货在路上但不用再跟物流
// · 「作废」= 单据作废
// · 「拦截」= 买家/客服要求拦截(实测标签为「留言备注拦截」)
// ⚠️ 别把「取消」直接当关键词匹配标签:会连带「取消预售」等无关标签。
const EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze([
'发货后取消',
'作废',
'拦截',
]);
// ── 刷单 / 信誉单不算转运异常(业务 2026-10-21 追加)──────────────────────
// 判据 = 状态表 `order_labels` 里的**订单类型标签**(不是状态、不是备注):
// · 含「刷单」 = 刷单待处理 → 假发货,本来就不会真揽收
// · 含「转空包」= 信誉单(信誉订单仓那批单都带这个标签,发空包不走真货)
// 实测(近 30 天 12 仓):带「刷单」411 单,其中 148 单报未揽收 —— 全是假异常;
// 带「转空包」0 单(都在「简尚-信誉订单仓」,那本来不在 12 个考核仓里)。
// ⚠️ 别用「特殊单」当判据:它和补差价/配件单混在一起,剔了会漏掉真异常。
const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
{ keyword: '刷单', label: '刷单' },
{ keyword: '转空包', label: '信誉单' },
]);
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
const SORTED_KEYWORDS = CENTERS
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
.sort((a, b) => b.kw.length - a.kw.length);
// ── 核验时刻 ──────────────────────────────────────────────────────────────
const VERIFY_HOUR = 9;
const LOOKBACK_DAYS = 30;
// ── 异常类型(2026-10-21 二次定版:只看「揽收前」的两个卡点)───────────────
// 业务原话:「这里的物流异常统计的是未揽收和已揽收、但是没有下一步中转记录的」,
// 以及「有『运输』字样的,往后中转记录不考核,不属于物流异常」。
// 所以:运输中 / 派送中 / 已签收 **一律正常**,不再用「24h 无新轨迹」判停滞
// (干线/中转轨迹本来就不回传,按 24h 判只会刷屏)。
// label 必须**自带结论**(业务要求「写上是什么异常」),不要只写「物流异常」这种笼统词。
const EXCEPTION_TYPES = Object.freeze([
{
key: 'NO_TRACKING',
label: '无物流单',
tone: 'red',
desc: '已发货但连运单号都没有 —— 打单/推单环节就没生成物流单',
},
{
key: 'NOT_PICKED',
label: '未揽收',
tone: 'orange',
desc: '有运单号,到核验时刻快递还没上门揽收',
},
{
key: 'PICKED_NO_TRANSIT',
label: '已揽收未中转',
tone: 'gold',
desc: '快递已揽收,但之后没有下一步中转/运输记录 —— 卡在网点没发出去',
},
{
key: 'CARRIER_EXCEPTION',
label: '快递报异常',
tone: 'red',
desc: '快递回传「异常」(寄件人取消寄件、异常签收等),需要人工介入',
},
{
key: 'RETURNED',
label: '退回',
tone: 'volcano',
desc: '拒收,或包裹已「换单退回」',
},
]);
// 「已经动起来了」的轨迹关键词:状态停在「已揽收」但最后一条轨迹里已经出现
// 运输/中转/发往等字样 —— 说明有下一步中转记录,按业务口径**不算异常**。
const TRANSIT_INFO_KEYWORDS = Object.freeze(['运输', '中转', '发往', '已发出', '分拨', '干线']);
const TYPE_MAP = new Map(EXCEPTION_TYPES.map((t) => [t.key, t]));
function pad2(n) {
return String(n).padStart(2, '0');
}
/**
* 取「按北京时间读出来」的当前时刻(容器可能跑在 UTC)。
* @param {Date} [base]
* @returns {Date} 本地 getter 读出来正好是北京时间的 Date
*/
function beijingNow(base = new Date()) {
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
}
function formatDate(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
/** 'YYYY-MM-DD' → 当日 00:00 的 Date(本地,不做时区换算) */
function parseDate(text) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(String(text || '').trim());
if (!m) return null;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0);
return Number.isNaN(d.getTime()) ? null : d;
}
function addDays(date, days) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days, 0, 0, 0);
}
/** 仓库名 → 主仓;被排除或无法归属返回 null */
function resolveCenter(warehouseName) {
if (!warehouseName) return null;
const name = String(warehouseName);
for (const kw of EXCLUDED_WAREHOUSE_KEYWORDS) {
if (name.includes(kw)) return null;
}
for (const item of SORTED_KEYWORDS) {
if (name.includes(item.kw)) return item.center;
}
return null;
}
/**
* 承运商是否不参与考核。
* @param {string} companyName 状态表 `logistic_company_name`
* @param {string} [centerKey] 该行所属仓(CONDITIONAL_EXCLUDED_CARRIERS 用);
* 不传时**保守不剔** —— 宁可少剔,不能把本仓正常单误剔
* @returns {string|null} 命中原因/关键词;不剔除返回 null
*/
function matchExcludedCarrier(companyName, centerKey) {
const name = String(companyName || '');
if (!name) return null;
// ① 无条件剔除
for (const kw of EXCLUDED_CARRIERS) {
if (name.includes(kw)) return kw;
}
// ② 有条件剔除:只在该承运商「本仓」正常时才留(如睢宁中通 → 睢宁仓)
for (const rule of CONDITIONAL_EXCLUDED_CARRIERS) {
if (!name.includes(rule.keyword)) continue;
if (!centerKey) return null;
if (rule.keepCenterKeys.includes(centerKey)) return null;
return rule.reason;
}
return null;
}
/**
* 订单是否已终止(已取消 / 作废 / 拦截),终止的不用跟物流。
* @param {string} orderStatus 状态表 `order_status_name`
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
* @returns {string|null} 命中原因(人话),不剔除返回 null
*/
function matchVoidedOrder(orderStatus, orderLabels) {
const status = String(orderStatus || '');
for (const kw of EXCLUDED_ORDER_STATUS_KEYWORDS) {
if (status.includes(kw)) return `订单${status}`;
}
const labels = String(orderLabels || '');
if (!labels) return null;
for (const kw of EXCLUDED_ORDER_LABEL_KEYWORDS) {
if (labels.includes(kw)) return `标签含「${kw}」`;
}
return null;
}
/**
* 刷单 / 信誉单 → 不是真实发货,不用跟物流。
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
* @returns {string|null} 命中的人话原因;不剔除返回 null
*/
function matchNonRealOrder(orderLabels) {
const labels = String(orderLabels || '');
if (!labels) return null;
for (const item of EXCLUDED_ORDER_TYPE_LABELS) {
if (labels.includes(item.keyword)) {
return item.label === '刷单' ? '刷单' : '信誉单(转空包)';
}
}
return null;
}
/**
* 解析「核验批次」。
*
* @param {string} [dateInput] 核验日 'YYYY-MM-DD';不传按当前时刻自动判定
* @param {Date} [nowBase] 当前时刻(真实时刻,内部会转北京时间)
* @returns {object} 批次描述
*/
function resolveBatch(dateInput, nowBase = new Date()) {
const now = beijingNow(nowBase);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
let verifyDay = null;
if (dateInput) {
const parsed = parseDate(dateInput);
if (parsed) verifyDay = parsed;
}
if (!verifyDay) {
// 未到 09:00 时,今天的批次还没跑 → 默认看昨天那批(核验前日发货)
const beforeCheckpoint = now.getHours() < VERIFY_HOUR;
verifyDay = beforeCheckpoint ? addDays(today, -1) : today;
}
// 护栏:不能晚于今天,也不能早于回看下限
if (verifyDay > today) verifyDay = today;
const earliest = addDays(today, -LOOKBACK_DAYS + 1);
if (verifyDay < earliest) verifyDay = earliest;
const shipDay = addDays(verifyDay, -1);
const verifyDate = formatDate(verifyDay);
const shipDate = formatDate(shipDay);
const deadline = `${verifyDate} ${pad2(VERIFY_HOUR)}:00:00`;
const nowText = `${formatDate(now)} ${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
return {
verifyDate,
shipDate,
deadline,
windowStart: `${shipDate} 00:00:00`,
windowEnd: `${verifyDate} 00:00:00`,
label: `${verifyDate.slice(5)} 09:00 核验 · ${shipDate.slice(5)} 发货`,
started: nowText >= deadline,
isToday: verifyDate === formatDate(today),
lookback: verifyDate !== formatDate(today),
lookbackDays: Math.round((today - verifyDay) / 86400000),
};
}
module.exports = {
CENTERS,
EXCLUDED_WAREHOUSE_KEYWORDS,
EXCLUDED_CARRIERS,
CONDITIONAL_EXCLUDED_CARRIERS,
EXCLUDED_ORDER_STATUS_KEYWORDS,
EXCLUDED_ORDER_LABEL_KEYWORDS,
EXCLUDED_ORDER_TYPE_LABELS,
EXCEPTION_TYPES,
TYPE_MAP,
TRANSIT_INFO_KEYWORDS,
VERIFY_HOUR,
LOOKBACK_DAYS,
resolveCenter,
matchExcludedCarrier,
matchVoidedOrder,
matchNonRealOrder,
resolveBatch,
beijingNow,
formatDate,
parseDate,
addDays,
pad2,
};

View File

@ -1,493 +0,0 @@
'use strict';
/**
* 仓储中心物流转运异常 —— 服务层
* ────────────────────────────────────────────────────────────────────────────
* 口径见 warehouseLogisticsExceptionRegistry.js 文件头。这里只讲实现要点:
*
* ① 一趟查询把「核验日前一天发货」的单全部取回来(窄列 + 轨迹截 300 字),
* Node 侧按仓库名归主仓、按物流状态判异常。实测:5400 行冷启动 ~20s、
* 热缓存 ~4s(视图谓词下推一般,走 querySlow 给足超时)。
* ② 一单多包裹会有多行(按 logistic_id 区分)——
* · 明细按 (内部单号 + 运单号) 去重,仓库能看到是哪个包裹出的问题;
* · 汇总按**内部单号**去重,否则 3 个包裹的单会被算成 3 单,异常率虚高。
* ③ 只判「揽收前」的两个卡点(未揽收 / 已揽收未中转),见 classify()。
* 运输中、派送中一律不考核,不再算 24h 停滞。
* ④ 结果缓存 5 分钟:页面 10 分钟自动刷一次,点「刷新」带 force=1 绕过。
*
* ── 剔除(业务 2026-10-21 定版)──────────────────────────────────────────────
* 剔掉的单**既不算异常、也不算发货分母**(否则异常率会被稀释/虚高):
* a. 承运商 ∈ EXCLUDED_CARRIERS(顺丰系全部 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 /
* 商家配送 / 京喜京东)—— 轨迹不回传,留着全是假异常(顺丰系各账号 52%~99.6% 假异常,
* 中铁、壹米滴答近 30 天 100%)。「武进安能」必须精确,别泛化成「安能」。
* a2. 有条件剔除 CONDITIONAL_EXCLUDED_CARRIERS:**「睢宁中通」只在睢宁仓考核**,
* 其它仓异地用它打单的剔(近 30 天非睢宁仓 490 单里 163 单未揽收,全是账号异地揽收)。
* b. 订单已终止:order_status_name 含「取消/作废」,或 order_labels 含
* 「发货后取消 / 作废 / 拦截」—— 单据都终止了,不用再跟物流。
* c. 刷单(标签含「刷单」)/ 信誉单(标签含「转空包」)—— 单据本身不是真实发货。
* 各类剔除量通过 `exclusions.*` 原样返回,页面页脚展示,方便业务核对口径。
*/
const cloudDatabase = require('../config/cloudDatabase');
const registry = require('./warehouseLogisticsExceptionRegistry');
const DEFAULT_DETAIL_LIMIT = 500;
// 列表上限 2000 条;导出单独放开到 5000(异常包裹一天最多几百,留足余量)
const MAX_DETAIL_LIMIT = 2000;
const EXPORT_DETAIL_LIMIT = 5000;
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
const SNAPSHOT_STALE_MINUTES = 120;
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
// 页面按 1000 条取、导出按 5000 条取,单槽会互相踢掉对方 —— 导出一次,
// 页面下次自动刷新就得重查库(20~40s)。多键缓存把这个问题消掉。
const CACHE_MAX_ENTRIES = 8;
const resultCache = new Map();
function clearLogisticsExceptionCache() {
resultCache.clear();
}
/** datetime / Date → 'YYYY-MM-DD HH:mm:ss'(东八区墙钟,不做时区换算) */
function formatDateTime(value) {
if (!value) return '';
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '';
const p = (n) => String(n).padStart(2, '0');
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}`
+ ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`;
}
const s = String(value);
if (!s) return '';
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
}
/** 两个时间文本相差多少分钟(to - from) */
function diffMinutes(fromText, toText) {
const a = formatDateTime(fromText);
const b = formatDateTime(toText);
if (!a || !b) return null;
const pa = new Date(a.replace(' ', 'T')).getTime();
const pb = new Date(b.replace(' ', 'T')).getTime();
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return null;
return Math.round((pb - pa) / 60000);
}
/**
* 按物流状态判异常类型(口径 2026-10-21 二次定版)。
*
* 只有两种「真异常」,其余状态一律不考核:
* · 没揽收 —— 状态停在「未揽收」(或压根没物流单)
* · 揽收了但没下一步中转 —— 状态停在「已揽收」,最后轨迹里也没有运输/中转字样
*
* 🔥 业务原话:「有『运输』字样的,往后中转记录不考核」——
* 所以 `运输中` / `派送中` **直接算正常**;不再用「距核验时刻 ≥24h 无新轨迹」
* 判停滞(干线、中转轨迹本就不回传聚水潭,按 24h 判全是假异常)。
*
* @returns {string|null} EXCEPTION_TYPES[].key;正常返回 null
*/
function classify(row) {
const status = String(row.logistic_status_name || '').trim();
const trackingNo = String(row.logistic_id || '').trim();
// 状态为空:有运单号但没轨迹 = 未揽收;连运单号都没有 = 无物流单
if (!status) return trackingNo ? 'NOT_PICKED' : 'NO_TRACKING';
if (status === '已签收' || status === '签收') return null;
if (status.includes('无物流')) return 'NO_TRACKING';
if (status.includes('未揽收')) return 'NOT_PICKED';
if (status.includes('拒收')) return 'RETURNED';
// 「其他」实测全是「包裹已换单退回」,和拒收同性质
if (status === '其他') return 'RETURNED';
if (status.includes('异常')) return 'CARRIER_EXCEPTION';
if (status.includes('已揽收')) {
// 揽收之后如果最后一条轨迹已经出现运输/中转/发往字样,就算「有下一步中转」
const info = String(row.last_info || row.last_logistic_info || '');
const moved = registry.TRANSIT_INFO_KEYWORDS.some((kw) => info.includes(kw));
return moved ? null : 'PICKED_NO_TRANSIT';
}
// 运输中 / 派送中(以及任何带「运输」字样的状态)= 已经动起来了,不考核
return null;
}
/** 拼 SQL 的仓库名 LIKE 条件(关键词取自 CENTERS,避免手写两套名单) */
function buildWarehouseFilters() {
const keywords = [];
for (const center of registry.CENTERS) {
for (const kw of center.keywords) {
if (!keywords.includes(kw)) keywords.push(kw);
}
}
const params = {};
const clauses = keywords.map((kw, i) => {
params[`w${i}`] = `%${kw}%`;
return `e.warehouse_name LIKE @w${i}`;
});
return { clauses, params, keywords };
}
async function loadShippedPackages(batch) {
const { clauses, params } = buildWarehouseFilters();
const rows = await cloudDatabase.querySlow(
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
e.logistic_id, e.logistic_company_name, e.logistic_status_name,
e.order_status_name, e.order_labels,
e.send_time, e.logistic_time, e.query_time,
LEFT(ISNULL(e.last_logistic_info, ''), 300) AS last_info
FROM dbo.jst_order_express_details e
WHERE e.send_time >= @shipStart AND e.send_time < @shipEnd
AND (${clauses.join(' OR ')})`,
{ ...params, shipStart: batch.windowStart, shipEnd: batch.windowEnd },
// 视图谓词下推一般,冷启动 20s;普通池 60s 池级超时 + 重试会把耗时翻倍
{ connectRequestTimeoutMs: 300000 }
);
return rows || [];
}
function emptySummary() {
return {
shippedOrders: 0,
packageCount: 0,
exceptionOrders: 0,
exceptionPackages: 0,
exceptionRate: 0,
centerCount: 0,
byType: {},
};
}
/**
* 剔除口径(前端页脚直接展示,别在页面里再写一份)。
* 两处返回(正常 / 未到核验时刻)共用,避免口径漂移。
*/
function exclusionMeta() {
return {
carriers: registry.EXCLUDED_CARRIERS,
// 有条件剔除:「睢宁中通」只在睢宁仓考核,其它仓异地打单的剔
conditionalCarriers: registry.CONDITIONAL_EXCLUDED_CARRIERS.map((r) => ({
keyword: r.keyword,
keepCenters: r.keepCenterKeys.map((key) => {
const center = registry.CENTERS.find((c) => c.key === key);
return center ? center.name : key;
}),
reason: r.reason,
})),
orderStatusKeywords: registry.EXCLUDED_ORDER_STATUS_KEYWORDS,
orderLabelKeywords: registry.EXCLUDED_ORDER_LABEL_KEYWORDS,
// 刷单 / 信誉单的标签判据
orderTypeLabels: registry.EXCLUDED_ORDER_TYPE_LABELS.map((t) => t.label),
};
}
async function getLogisticsExceptions(options = {}) {
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
const limitCap = Math.min(
Math.max(Number(options.maxLimit) || MAX_DETAIL_LIMIT, 1),
EXPORT_DETAIL_LIMIT
);
const limit = Math.min(
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
limitCap
);
const force = options.force === true || options.force === '1' || options.force === 1;
const generatedAt = formatDateTime(registry.beijingNow());
// 未到 09:00:本批次还没跑,不拿当前快照冒充「09:00 的结论」
if (!batch.started) {
return {
batch,
notStarted: true,
resolution: '09:00',
summary: emptySummary(),
centers: registry.CENTERS.map((c) => ({
key: c.key, name: c.name, shippedOrders: 0, exceptionOrders: 0, exceptionRate: 0, byType: {},
})),
types: registry.EXCEPTION_TYPES,
exclusions: {
...exclusionMeta(),
carrierPackages: 0,
carrierOrders: 0,
carrierReasons: {},
voidedPackages: 0,
voidedOrders: 0,
voidedReasons: {},
nonRealPackages: 0,
nonRealOrders: 0,
nonRealReasons: {},
},
orders: [],
detailLimit: limit,
truncated: false,
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
generatedAt,
cached: false,
cacheAgeSec: 0,
};
}
const cacheKey = `${batch.verifyDate}|${limit}`;
const cachedEntry = resultCache.get(cacheKey);
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
return {
...cachedEntry.data,
cached: true,
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
};
}
const rows = await loadShippedPackages(batch);
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
const centerStat = new Map();
for (const center of registry.CENTERS) {
centerStat.set(center.key, {
key: center.key,
name: center.name,
shippedOrders: new Set(),
exceptionOrders: new Set(),
byType: {},
packages: 0,
});
}
const seenPackage = new Set();
const exceptions = [];
let packageCount = 0;
const shippedOrderIds = new Set();
let snapshotAt = '';
// 剔除统计(业务 2026-10-21:不考核的承运商 + 已取消/作废/拦截 + 刷单/信誉单)
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
for (const row of rows) {
const queryTime = formatDateTime(row.query_time);
if (queryTime && queryTime > snapshotAt) snapshotAt = queryTime;
const center = registry.resolveCenter(row.warehouse_name);
if (!center) continue;
const oId = String(row.inner_order_id || '');
const trackingNo = String(row.logistic_id || '');
const packageKey = `${oId}|${trackingNo || row.send_time || ''}`;
if (seenPackage.has(packageKey)) continue;
seenPackage.add(packageKey);
// ① 不参与考核的承运商(唯品顺丰 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 / 京喜京东)
// 这些要么轨迹不回传聚水潭、要么根本没有快递轨迹,留着只会刷假异常。
// 「睢宁中通」是有条件的:睢宁仓照常考核,其它仓异地打单的剔(传 center.key 区分)。
const carrierHit = registry.matchExcludedCarrier(row.logistic_company_name, center.key);
if (carrierHit) {
skippedByCarrier.packages += 1;
skippedByCarrier.orders.add(oId);
skippedByCarrier.reasons[carrierHit] = (skippedByCarrier.reasons[carrierHit] || 0) + 1;
continue;
}
// ② 已取消 / 作废 / 拦截的订单 —— 单据已终止,不用再跟物流
const voidReason = registry.matchVoidedOrder(row.order_status_name, row.order_labels);
if (voidReason) {
skippedByVoid.packages += 1;
skippedByVoid.orders.add(oId);
skippedByVoid.reasons[voidReason] = (skippedByVoid.reasons[voidReason] || 0) + 1;
continue;
}
// ③ 刷单 / 信誉单 —— 订单本身不是真实发货,揽不揽收不代表仓库的物流能力
const nonRealReason = registry.matchNonRealOrder(row.order_labels);
if (nonRealReason) {
skippedByType.packages += 1;
skippedByType.orders.add(oId);
skippedByType.reasons[nonRealReason] = (skippedByType.reasons[nonRealReason] || 0) + 1;
continue;
}
const stat = centerStat.get(center.key);
stat.shippedOrders.add(oId);
stat.packages += 1;
packageCount += 1;
shippedOrderIds.add(oId);
const type = classify(row);
if (!type) continue;
const sendAt = formatDateTime(row.send_time);
const logisticAt = formatDateTime(row.logistic_time);
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
stat.exceptionOrders.add(oId);
stat.byType[type] = (stat.byType[type] || 0) + 1;
exceptions.push({
oId,
soId: String(row.so_id || ''),
shopName: String(row.shop_name || ''),
warehouseName: String(row.warehouse_name || ''),
centerKey: center.key,
centerName: center.name,
carrier: String(row.logistic_company_name || ''),
trackingNo,
logisticStatus: String(row.logistic_status_name || ''),
exceptionType: type,
exceptionLabel: typeMeta.label,
exceptionDesc: typeMeta.desc || '',
tone: typeMeta.tone,
sendTime: sendAt,
lastLogisticTime: logisticAt,
lastLogisticInfo: String(row.last_info || '').trim(),
// 发货 → 核验时刻(小时,1 位小数):仓库看到「发了多久还没动静」
pendingHours: pendingMinutes === null ? null : Math.round((pendingMinutes / 60) * 10) / 10,
// 最后一条轨迹 → 核验时刻(小时):负数表示这条轨迹是核验之后才出现的
staleHours: staleMinutes === null ? null : Math.round((staleMinutes / 60) * 10) / 10,
});
}
// 异常单最久未处理的排前面
exceptions.sort((a, b) => (b.pendingHours || 0) - (a.pendingHours || 0));
// ── 汇总 ────────────────────────────────────────────────────────────
const summary = emptySummary();
const allExceptionOrders = new Set();
summary.shippedOrders = shippedOrderIds.size;
summary.packageCount = packageCount;
const centers = registry.CENTERS.map((center) => {
const stat = centerStat.get(center.key);
const byType = { ...stat.byType };
for (const oid of stat.exceptionOrders) allExceptionOrders.add(oid);
return {
key: center.key,
name: center.name,
shippedOrders: stat.shippedOrders.size,
exceptionOrders: stat.exceptionOrders.size,
exceptionRate: stat.shippedOrders.size
? Math.round((stat.exceptionOrders.size / stat.shippedOrders.size) * 1000) / 10
: 0,
byType,
};
});
summary.exceptionOrders = allExceptionOrders.size;
summary.exceptionPackages = exceptions.length;
summary.exceptionRate = summary.shippedOrders
? Math.round((summary.exceptionOrders / summary.shippedOrders) * 1000) / 10
: 0;
summary.centerCount = centers.filter((c) => c.exceptionOrders > 0).length;
for (const item of exceptions) {
summary.byType[item.exceptionType] = (summary.byType[item.exceptionType] || 0) + 1;
}
// ── 新鲜度 ──────────────────────────────────────────────────────────
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
let hint = '';
if (coversDeadline === false) {
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
} else if (stale) {
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)}(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新),`
+ '状态可能滞后,请以聚水潭实时物流为准。';
}
const payload = {
batch,
notStarted: false,
resolution: '09:00',
summary,
centers,
// 异常类型字典(前端图例/筛选器直接用,避免两边各写一套文案)
types: registry.EXCEPTION_TYPES,
// 业务指定的剔除口径(前端原样展示,别在页面里硬编码一份)
exclusions: {
...exclusionMeta(),
// 本批实际剔掉了多少(包裹 / 订单 / 原因明细)
carrierPackages: skippedByCarrier.packages,
carrierOrders: skippedByCarrier.orders.size,
carrierReasons: skippedByCarrier.reasons,
voidedPackages: skippedByVoid.packages,
voidedOrders: skippedByVoid.orders.size,
voidedReasons: skippedByVoid.reasons,
nonRealPackages: skippedByType.packages,
nonRealOrders: skippedByType.orders.size,
nonRealReasons: skippedByType.reasons,
},
orders: exceptions.slice(0, limit),
detailLimit: limit,
truncated: exceptions.length > limit,
freshness: {
snapshotAt,
ageMinutes,
stale,
coversDeadline,
hint,
},
generatedAt,
cached: false,
cacheAgeSec: 0,
};
resultCache.set(cacheKey, { at: Date.now(), data: payload });
// 超过上限时淘汰最旧的一条(条目很少,排序开销可忽略)
if (resultCache.size > CACHE_MAX_ENTRIES) {
let oldestKey = '';
let oldestAt = Infinity;
for (const [key, entry] of resultCache.entries()) {
if (entry.at < oldestAt) { oldestAt = entry.at; oldestKey = key; }
}
if (oldestKey) resultCache.delete(oldestKey);
}
return payload;
}
/**
* 导出用:返回**不分页**的异常明细(口径与页面完全一致,只是把明细上限放开)。
*
* 页面列表默认只取 500 条(够看),导出必须拿全 —— 否则业务拿到的 Excel
* 和数据对不上,是最容易被投诉的点。这里复用 `getLogisticsExceptions`,
* 不另写一套查询,避免「导出和页面两套口径」。
*
* @returns {Promise<{rows:Array,total:number,truncated:boolean,batch:object}>}
*/
async function getLogisticsExceptionExportRows(options = {}) {
const data = await getLogisticsExceptions({
...options,
limit: EXPORT_DETAIL_LIMIT,
maxLimit: EXPORT_DETAIL_LIMIT,
});
const rows = Array.isArray(data.orders) ? data.orders : [];
const total = Number(data.summary?.exceptionPackages) || rows.length;
return {
rows,
total,
// 异常包裹数超过上限时如实告知,页面会提示「结果已截断」
truncated: rows.length < total,
detailLimit: EXPORT_DETAIL_LIMIT,
batch: data.batch,
notStarted: data.notStarted,
summary: data.summary,
centers: data.centers,
types: data.types,
exclusions: data.exclusions,
freshness: data.freshness,
generatedAt: data.generatedAt,
};
}
module.exports = {
getLogisticsExceptions,
getLogisticsExceptionExportRows,
clearLogisticsExceptionCache,
classify,
formatDateTime,
diffMinutes,
};

View File

@ -1,559 +0,0 @@
'use strict';
/**
* 仓储中心 24 小时发货时效(2026-10-20 新增)
* ────────────────────────────────────────────────────────────────────────────
* 需求(yanhui 2026-10-20):
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
* 今天 07:00 前审核的订单 → 13:00 核验;**截止今天 16:00 前审核**的单(含今天之前未发的)→ 18:00 核验;
* 昨天及更早审核、至今未发的(历史未发单)两个轮次都算。
* (2026-10-20 三次调整:
* 二次 —— 核验时刻由 12:00/16:00 改为 13:00/18:00;
* 三次 —— ① **窗口按「审核时间」判**(不是创建时间):业务指出的 9818476
* (创建 10-18、审核 10-20 13:06)这类下午才审的单,不能进 13:00 轮;
* ② **取消「满 24 小时才算超时」的门槛**:到核验时刻未发即超时;
* ③ **历史未发单一律算**,不卡「今天 00:00 起」。
* 业务原话:「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到
* 16 点审核的订单 18:00 核验;然后再看历史未发的订单」。)
* 展示口径:**只显示超时未发**(业务确认)。
*
* 业务确认的口径(2026-10-20):
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东自营仓/运营C专用仓 等)
* 例外:「京仓-横林二仓京东云仓」业务指定**排除**,不计入任何主仓(见下)
* ② 崔桥工厂半成品仓 —— 系统无同名仓,**接入「简尚-崔桥工厂半成品包装项目仓」**(15718196)
* ③ 展示结构 —— **只显示超时未发**
* ④ 明细列 —— 要同时给「内部单号」(o_id) 与「线上订单号」(so_id)
*
* ── 数据源 ────────────────────────────────────────────────────────────────
* 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头)
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
* ⚠️ 不是 `order_date`(平台下单时间);
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
* 实际下单在 10-13~10-18),拿它当创建时间会让所有单的时间被抹平;
* ⚠️ 也不是 `created/createTime/updateTime`(数仓 5 分钟批量灌库时间戳)。
* 待出库的单在出库单表里还没有记录 → 回退 `order_date` 并在接口里标注来源。
* · 已出库:销售出库单表能查到该 o_id(有 `io_date`),**或**订单 `send_date`
* 非空,**或**订单状态已是 `Sent` / `Delivering`。
* · 待出库:以上三条都不满足。
* · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓)
* ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属,
* 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。
*
* ── 仓库归属(按名称包含匹配,最长匹配优先)────────────────────────────────
* `rpt.jst_warehouseInfo` 实测(2026-10-20):
* 横林二仓 15033302 + 15301814/15835893/16040225/16132681
* ⚠️ 15197138「京仓-横林二仓京东云仓」**业务指定排除**(见 EXCLUDED_WAREHOUSE_KEYWORDS)
* 横林三仓 15043561
* 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**)
* 崔桥工厂仓 15038426 + 15301815/16040410
* 崔桥工厂品牌仓 15870495
* 崔桥工厂半成品包装项目仓 15718196(原名「简尚-崔桥工厂半成品包装项目仓」)
* 新苏北仓 15038347 + 15315031/15301812
* 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911
* 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076
* 山东品牌仓 15182705
* 河南洛宁仓 15539084
* ⟹ 仓库 ID → 主仓 的映射**在启动时从库表读取并缓存**(不用手写 ID 常量,
* 避免上游新增子仓后要改代码;新增子仓只要名字里带主仓关键词就会自动归入)。
* 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。
*
* ── 时效判定 ──────────────────────────────────────────────────────────────
* 两个时刻**必须区分**(2026-10-20 二次调整后最容易搞混的地方):
* `verifyHour` —— 几点核验(13:00 / 18:00)。判定「发了没发」「超时多久」都用它。
* `windowEndHour` —— 订单窗口上界(07:00 / 16:00)。只核验该时刻**前审核**的单。
* 取当前已生效且最晚的那一轮;未到 13:00 时无核验轮次:
* 13:00 → 核验「今天 00:00~07:00 审核」的订单 + 历史未发
* 18:00 → 核验「截止今天 16:00 前审核」的订单 + 今天之前审核仍未发的(历史未发)
* ⚠️ 不是「今天 00:00~16:00」这一个自然日切片,历史未发也在内;
* 山东两仓窗口上界 15:00、河南洛宁 13:00(核验时刻仍是 18:00)。
* 判定(2026-10-20 三次修正 —— **没有「满 24 小时」门槛**):
* · 超时未出库 = 到核验时刻仍未发货
* (出库单表查不到 + `send_time` 为空 + 状态不是 Sent/Delivering)
* 🔥 第七版错在加了「创建满 24h 才算超时」,导致 13:00 轮永远
* 看不到当天 7 点那批(才等了 6 小时),业务直接打回。
* · 已出库 = 出库单表有记录 / send_date 非空 / 状态 Sent、Delivering
* · 拆单父单(is_split=1)、被合并单(is_merge=1)**一律排除** ——
* 它们自己永不发货,货由子单/主单发出,算进来就是冤枉仓库。
* 业务只要「超时未出库」,所以列表只返回待出库的超时单。
*
* ── 时区 ──────────────────────────────────────────────────────────────────
* 订单时间列存的是**东八区本地时间**(实测 `order_date` 与 `created` 相差十几秒,
* 均为北京时间)。因此 SQL 里统一用 `CAST(GETDATE() AS date)` 取「今天」,
* 服务器与数据库都在东八区;**不要**在 Node 侧用 UTC 日期做判断。
*/
const cloudDatabase = require('../config/cloudDatabase');
// ── 主仓定义(业务给定的 11 个)──────────────────────────────────────────
// keywords:该主仓在 rpt.jst_warehouseInfo.name 里应包含的关键词(任一命中即归属)
// 顺序即展示顺序;`match` 用最长关键词优先,所以「横林二仓品牌仓」不会被「横林二仓」抢走。
const CENTERS = Object.freeze([
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
// 2026-10-20 二次调整:原先「崔桥工厂半成品仓」在仓库表里没有同名仓,只能留空占位;
// 业务指定用「简尚-崔桥工厂半成品包装项目仓」(wms_co_id=15718196, Id=198) 替代接入。
// ⚠️ 该 wms_co_id 同时也是配件仓(MKH 配件库存来源),但这里是**订单发货仓**维度,
// 统计的是「从该仓发出的订单」,与配件库存口径无关,不要混。
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
// 🔥 山东两个仓 18:00 轮的窗口更早(15:00 前,不是 16:00)——
// 业务 2026-10-20:「山东青岗集仓和山东品牌仓,这 2 个仓库时间不是 16:00 前,
// 是 15:00 前订单」。核验时刻仍是 18:00,只是卡单的范围窄一小时。
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'], windowEndHourByRound: { AFTERNOON: 15 } },
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'], windowEndHourByRound: { AFTERNOON: 15 } },
// 🔥 河南洛宁仓更早:18:00 轮只看 13:00 前创建的单(业务同一条指令)
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'], windowEndHourByRound: { AFTERNOON: 13 } },
]);
// 各主仓在某一轮的窗口上界小时。
// 返回 null 表示「用轮次默认值」(ROUNDS[].windowEndHour)。
// ⚠️ 13:00 轮没有例外,全部按 07:00;只有 18:00 轮有三处更早的上界。
function centerWindowEndHour(centerKey, roundKey) {
const center = CENTERS.find((c) => c.key === centerKey);
const override = center && center.windowEndHourByRound
? center.windowEndHourByRound[roundKey]
: undefined;
return Number.isFinite(override) ? override : null;
}
/** 该主仓在这一轮的窗口上界('YYYY-MM-DD HH:mm:00'),没有例外就用轮次默认 */
function centerWindowEnd(centerKey, round) {
const hour = centerWindowEndHour(centerKey, round.key);
if (hour === null) return round.windowEnd;
return `${round.date} ${String(hour).padStart(2, '0')}:00:00`;
}
// 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段,
// 靠关键词长度倒序匹配即可(见 resolveCenter)。
const SORTED_KEYWORDS = CENTERS
.filter((c) => c.keywords.length)
.flatMap((c) => c.keywords.map((kw) => ({ kw, center: c })))
.sort((a, b) => b.kw.length - a.kw.length);
// ── 排除仓(业务指定,2026-10-20)──────────────────────────────────────────
// 「京仓-横林二仓京东云仓」(wms_co_id=15197138, Id=129) 虽然名字里带「横林二仓」,
// 但属于京东云仓代运营,**不纳入横林二仓的时效考核**(业务明确要求排除)。
// ⚠️ 用完整片段「横林二仓京东云仓」而不是「京东云仓」:
// 仓库表里还有「京仓-崔桥工厂京东云仓」(15283032)、
// 「京仓-山东青岗集京东云仓」(15283033),写短了会把它们一起排掉;
// 也不会误伤「简尚-横林二仓京东仓」(16040225) 与「京仓-横林二仓京东自营仓」(15835893)。
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze([
'横林二仓京东云仓',
]);
// 长关键词优先,避免以后加了「XX京东云仓」被短词抢先
const SORTED_EXCLUSIONS = [...EXCLUDED_WAREHOUSE_KEYWORDS].sort((a, b) => b.length - a.length);
// ── 时效轮次(业务给定,2026-10-20 二次调整)──────────────────────────────
// verifyHour :当天几点开始核验(13:00 / 18:00)
// windowEndHour :只核验「**审核时间**不晚于该时刻」的订单(07:00 / 16:00)
// ⚠️ 两者**不是同一个东西**,别再用一个字段兼着当:
// 旧实现拿 12:00 同时当核验时刻和窗口上界,导致「12:00 轮」实际把
// 07:00~12:00 创建的单也算进来了,与页面上写的「核验 07:00 前」不一致。
// ⚠️ 也不能在 windowEnd 之后再叠「满 N 小时」门槛 —— 13:00 轮里 07:00 审核的单
// 只等了 6 小时,加门槛就永远不出现在超时列表里(第七版被打回的原因)。
const ROUNDS = Object.freeze([
{ key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEndHour: 7 },
{ key: 'AFTERNOON', label: '18:00 轮', verifyHour: 18, windowEndHour: 16 },
]);
// ── 订单窗口下界(含历史的天数,**业务可调**)────────────────────────────
// 业务口径(2026-10-20 三次纠偏):「截止某时刻创建、至今仍未发货」—— 不按自然日切,
// 昨天、前天乃至更早挂着没发的单都要算进来(滚动累积的待发池)。
//
// ⚠️ 但不能无限往前:rpt 下全是视图、没有索引,扫得越远越慢。2026-10-20 实测
// (jst_Orders + jst_qmcust 并行,含已发单的窗口全量):
// 近 7 天 → 约 5s 近 30 天 → 约 45s
// 近 90 天 → 约 130s 近 365 天 → 约 500s(基本等于页面卡死)
// 所以默认取 30 天:覆盖「今日 + 近一个月累积未发」,兼顾口径与可用性。
//
// 同时实测 28 个目标仓的**全部未发单**:共 1,228 条,其中近 7 天 257、
// 近 30 天 496、近 90 天 792、更早 436(最早 2025-10-24)。
// ⟹ 30 天能看到约 40%;要看更久的老单,把下面数字改大,代价是首屏更慢。
const HISTORY_LOOKBACK_DAYS = 30;
// 只排除取消/合并单;Question 仍计入(与 mkhBomService 待发货口径一致)
//
// 🔥 Split(拆单):**必须排除**。拆单是「父单被拆成子单」的中间态,父单本身
// 不会发货(`send_date` 永远为空),子单才是真正进仓发货的那一单。
// 2026-10-20 实测:不加这个条件,一年前的 Split 父单全被算成「超时未发」,
// 全库未发 Split 单占比极高 —— 会让页面变成上万条的垃圾列表。
// ⚠️ 与 mkhBomService 的差异:那边是「预警今天要发的货」,Split 父单不带真实
// 发货需求,被算进去也无害;这边是「考核仓库时效」,误算就要背锅,必须排除。
const EXCLUDED_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split')`;
// ── 「已发货 / 待发货」状态口径(2026-10-20 四次修正,业务反馈数据错误)────
// 🔥 之前只排除了 Cancelled/Merged/Split,靠 `send_date IS NULL` 判「未发」,
// 实测**大量已发货的单被算成超时未发**(业务一眼就看出来了)。原因有三个:
// ① `send_date` 在部分店铺/平台不回写,订单状态已经是 Sent/Delivering,
// send_date 却还是 NULL(样本 9838341:Delivering,send_date 空);
// ② 拆单父单 `status` 不是 'Split' 而是 'Delivering'(父单随子单状态走),
// `is_split=1` 才是真标记(样本 9838537 就是这样);
// ③ 合并单 `is_merge=1` 同理,被并入主单,自己永不发货。
// 订单状态实测分布(2026-10-20,近 7 天,两表合计):
// Sent 61,833 / Cancelled 22,292 / Split 4,927 / Delivering 2,567 /
// Question 1,500 / Merged 682 / WaitOuterSent 30 / WaitConfirm 12
// ⟹ 判定「已发货」= status ∈ (Sent, Delivering) **或** send_date 非空
// **或** 销售出库单表能查到该 o_id(见 OUTBOUND_TABLES)
const SHIPPED_STATUS = Object.freeze(['Sent', 'Delivering']);
// 待出库候选:把「已发货」状态一并排除。判定顺序是
// ① SQL 先粗筛(下面这个条件 + send_date IS NULL + 非拆单/合并)
// ② Node 侧再查销售出库单表精确剔除(有出库记录 = 其实已出库)
// ③ Node 侧再把 Question(疑问单)分流到「业务挂起」,**不计超时**
// —— 注意 Question 必须留在候选里,否则统计不出挂起单数;
// 它只是不进 orders(超时明细),走 heldCount / heldOrders。
const PENDING_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split', N'Sent', N'Delivering')`;
// 拆单父单 / 被合并单:自己**永不发货**,货由子单或主单发出。
// 只排 status 不够(父单状态会跟着子单变成 Delivering),必须看标记位。
// ⚠️ is_split / is_merge 是 bit 且可能为 NULL —— 必须 ISNULL 兜底,
// 否则 `o.is_split = 0` 会把 NULL 行全部丢掉(实测会少掉大半候选)。
const NOT_SPLIT_MERGED_SQL = `ISNULL(o.is_split, 0) = 0 AND ISNULL(o.is_merge, 0) = 0`;
// ── 销售出库单表(聚水潭「销售出库单」页面的数据来源)──────────────────────
// 业务明确要求(2026-10-20):「已出库 / 待出库」与「创建时间」都以聚水潭
// **销售出库单**页面为准,不再用订单表的 send_date 猜。
//
// 两张表与订单表同构、店铺互斥:
// rpt.jst_salesOutboundInfo ← jst_Orders(京东/拼多多/抖音/得物/唯品会),103.7 万行
// rpt.jst_qmcust_salesoutbound ← jst_qmcust_orderlist(天猫/淘宝/1688),39.8 万行
// 字段口径(2026-10-20 实测确认):
// · `created` —— **出库单创建时间**(业务在聚水潭出库单页面看到的那个),
// 时点分布自然(8~23 点均匀,凌晨 4 点有一波补录),可用;
// · `io_date` —— **出库时间**(真正发货那一刻);
// · `modified` —— ⚠️ 会被数仓每天凌晨批量刷新(实测全被刷成 02:2x / 04:3x),
// 与订单表的 modified 一样**不可当作创建时间**;
// · **两张表都只含「已出库」的单**:103.7 万行里 io_date 为 0 条空值。
// ⟹ 「待出库」无法从出库单表直接查,只能靠「查不到 = 还没出库」反推。
const OUTBOUND_TABLES = Object.freeze({
jst_Orders: 'jst_salesOutboundInfo',
jst_qmcust_orderlist: 'jst_qmcust_salesoutbound',
});
// ── 聚水潭「订单实时状态」表(2026-10-20 五次修正:按审核时间核算)──────────
// 🔥 前面三版的根因都在这里:`jst_Orders.status` / `jst_qmcust_orderlist.status`
// 是**下单那一刻的快照**,之后聚水潭侧取消、售后、审核都不会回写。
// 实测(2026-10-20):业务反馈「聚水潭查不到」的 6 单,订单表里
// status 全是 `WaitConfirm`,而在本表里真实状态是 **已取消**(发货前售后);
// 另有 1 单真实状态是 **异常**。上一版把它们当成「待出库超时」报了出来。
//
// `rpt.jst_order_express_details`(402 万行,query_time 准实时,最近一次今天 11:55)
// 是聚水潭订单状态的准实时镜像,字段语义(近 30 天实测覆盖率):
// · `order_status_name` —— 聚水潭真实订单状态
// 已发货 269,991(send_time 100%)|已取消 64,440|异常 6,669
// 发货中 2,448(first_oa 99.96%、send_time 0)|已支付待审核 833
// · `first_oa_confirm_time` —— **订单审核时间**(首次 OA 确认)= 业务要的起算点
// · `last_oa_confirm_time` —— 最后一次审核(重新审核会刷新)
// · `send_time` —— **发货时间**(已发货单 100% 有值,未发单必为空)
// · `warehouse_name` —— 发货仓名称(可直接套 CENTERS 关键词匹配)
// · `logistic_status_name` —— 物流状态(无物流单 / 未揽收 / 运输中 …)
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
const ORDER_STATE_TABLE = 'jst_order_express_details';
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
const SHIPPING_STATUS = '发货中';
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
const UNAUDITED_STATUS = '已支付待审核';
// 「异常」= 订单异常(缺货/地址/风控等),有审核时间的单列「异常待处理」,不计仓库超时
const ABNORMAL_STATUS = '异常';
// 不进考核也不展示的状态:已发货(已发)、已取消(业务侧取消/发货前售后)、
// 等供销商|外仓发货(不是本仓发)
// 🔥 「已取消」(业务口头叫**作废单**)必须排除(2026-10-20 业务定:「作废订单不用考核」):
// 这类单是客户付款后取消 / 发货前售后(标签带「发货前售后」),仓库根本不会发货。
// 2026-10-20 实测:近 30 天已取消 64,440 单,量级远大于真正该考核的「发货中」2,448 单,
// 漏掉这个排除,整个页面会被作废单淹没。
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
//
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补:
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51),
// 是**同步落库时间**,用了会把起算点整体推后几小时。
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
// 要换掉。
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
// 这才是聚水潭订单列表里「创建时间」那一列。
//
// 性能(2026-10-20 实测 9,676 个候选):
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
const CREATED_BACKFILL_BATCH = 200;
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
// 业务 2026-10-20 反馈 so_id 5127276315127080023(内部单号 9486414)是作废单,
// 却出现在超时未发货里。四张表对照后的真相:
// · 状态表 jst_order_express_details:order_status_name=**发货中**、send_time 空
// ⟹ 只信状态表的话,这单就是「超时未发」—— 我们就是这么误判的;
// · 订单表 jst_qmcust_orderlist:status=**Merged**、send_date=2026-09-22 06:54:49;
// · 销售出库单:两张表**都有**出库记录(io_date 2026-09-22 10:59:55)。
// ⟹ 根因:状态表的 `send_time` 对**被合并单 / 拆单父单不回写**,
// 单子早随主单发走了,状态表还停在「发货中」。
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
const SHIP_TIMELINESS_HOURS = 24;
const WAREHOUSE_CACHE_TTL_MS = 60 * 60 * 1000;
let warehouseCache = { at: 0, rows: [] };
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function pad2(n) {
return String(n).padStart(2, '0');
}
// 🔥 北京时间(UTC+8)偏移,分钟
const BEIJING_OFFSET_MINUTES = 480;
/**
* 取「按北京时间读出来」的当前时刻。
*
* 🔥 为什么不能直接 `new Date()`(2026-10-21 生产实测踩坑):
* 生产容器 `/etc/timezone = Etc/UTC`(date = UTC),而 MSSQL `GETDATE()` 返回**北京时间**,
* mssql 驱动 `useUTC:false` 又把 DB 值构造成「墙钟当 UTC」的 Date。
* 于是「服务器 now」比「库里的时间」慢 8 小时 —— **北京时间每天 00:00~08:00,
* 服务端取到的「今天」是昨天**,轮次窗口 / 核验时刻整体错位
* (实测 08:04 跑 13:00 轮得 overdueUnshipped=3895,本地北京时区同一轮只有 6 单)。
*
* 做法:把真实时刻平移成一个「本地 getter 读出来正好是北京时间」的 Date,
* 这样 `getHours()/getDate()/formatDateTime()` 与库里的字符串可以直接比较。
* 本机已是东八区时平移量为 0,行为不变(测试无需改)。
*
* @param {Date} [base] 真实时刻,默认 `new Date()`
* @returns {Date} 本地 getter = 北京时间的 Date
*/
function beijingNow(base = new Date()) {
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
}
/**
* 用「关键词最长匹配」把仓库名归到主仓。
*
* ⚠️ **先判排除、再判归属**:被排除的仓(如「京仓-横林二仓京东云仓」)
* 即使名字里带主仓关键词,也直接返回 null —— 它的订单不计入任何主仓。
*
* @returns {object|null} CENTERS 里的元素;无法归属或被排除返回 null
*/
function resolveCenter(warehouseName) {
if (!warehouseName) return null;
const name = String(warehouseName);
for (const kw of SORTED_EXCLUSIONS) {
if (name.includes(kw)) return null;
}
for (const item of SORTED_KEYWORDS) {
if (name.includes(item.kw)) return item.center;
}
return null;
}
/**
* 拉取仓库表并建立「主仓 → wms_co_id 列表」映射。
* 缓存 1 小时;仓库表变更不频繁,且新增子仓会自动被关键词捞到。
*/
async function loadCenterWarehouseMap(force = false) {
const fresh = Date.now() - warehouseCache.at < WAREHOUSE_CACHE_TTL_MS;
if (!force && fresh && warehouseCache.rows.length) {
return warehouseCache.rows;
}
// ⚠️ cloudDatabase.query() 直接返回 recordset 数组,不是 {recordset} 对象
const rows = (await cloudDatabase.query(
`SELECT Id, name, wms_co_id
FROM dbo.jst_warehouseInfo`,
{},
{ requestTimeout: 60000 }
)) || [];
const byCenter = new Map(CENTERS.map((c) => [c.key, []]));
const unmatched = [];
const excluded = [];
for (const row of rows) {
const name = String(row.name || '');
// 被业务排除的仓单独记录,方便日后核对「是不是排多了/排少了」
if (SORTED_EXCLUSIONS.some((kw) => name.includes(kw))) {
excluded.push({ wmsCoId: Number(row.wms_co_id), name });
continue;
}
const center = resolveCenter(name);
if (center && byCenter.has(center.key)) {
byCenter.get(center.key).push({
wmsCoId: Number(row.wms_co_id),
warehouseId: Number(row.Id),
name,
});
} else {
unmatched.push({ wmsCoId: Number(row.wms_co_id), name });
}
}
const list = CENTERS.map((c) => ({
key: c.key,
name: c.name,
placeholder: Boolean(c.placeholder),
warehouses: byCenter.get(c.key) || [],
}));
warehouseCache = {
at: Date.now(),
rows: list,
unmatchedCount: unmatched.length,
excluded,
};
return list;
}
function clearWarehouseCache() {
warehouseCache = { at: 0, rows: [] };
}
/**
* 计算「当前已生效」的核验轮次。
*
* 13:00 之前 → **未到核验时间,返回 null**。
* 业务明确要求(2026-10-20 确认):未到核验时刻不做核验,
* 前端显示「未到核验时间」,**不要回填昨天的数据** ——
* 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。
* 13:00~17:59 → 用 13:00 轮(今天 00:00~07:00 **审核** + 历史未发)
* 18:00 之后 → 用 18:00 轮(截止 16:00 前**审核** + 今天之前未发的历史单)
*
* 🔥 「含历史」是 2026-10-20 业务第三次纠偏:
* 原实现把下界卡在「今天 00:00」,只统计当天新单,把昨天及更早挂着没发的
* 单全漏掉了。业务口径是「……然后再看历史未发的订单」——
* 这是一个滚动累积的待发池,不按自然日切。
*
* @param {Date} now 北京时间(默认 `beijingNow()`,容器是 UTC 时也安全)
* @returns {object|null} null 表示当前不在任何核验时段内
*/
function resolveActiveRound(now = beijingNow()) {
const hour = now.getHours();
const minute = now.getMinutes();
const minutes = hour * 60 + minute;
let active = null;
for (const round of ROUNDS) {
if (minutes >= round.verifyHour * 60) active = round;
}
// 13:00 前:首轮核验还没开始,不做判定
if (!active) return null;
const y = now.getFullYear();
const m = pad2(now.getMonth() + 1);
const d = pad2(now.getDate());
// 护栏起点(不是业务下界,见 HISTORY_LOOKBACK_DAYS)
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
return {
...active,
// deadline = 核验时刻:判「发了没发 / 超时多久」都用它
deadline: `${y}-${m}-${d} ${pad2(active.verifyHour)}:00:00`,
windowStart: floorStr,
// windowEnd = 订单窗口上界:SQL 的取数上界,**不是** deadline
windowEnd: `${y}-${m}-${d} ${pad2(active.windowEndHour)}:00:00`,
// 含历史未发单 —— 前端据此把「统计窗口 00:00~07:00」改成「截止 07:00 前(含历史)」
includeHistory: true,
date: `${y}-${m}-${d}`,
settled: false,
isPreviousDay: false,
};
}
/**
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
*/
function describeRounds(now = beijingNow()) {
const y = now.getFullYear();
const m = pad2(now.getMonth() + 1);
const d = pad2(now.getDate());
const date = `${y}-${m}-${d}`;
const minutes = now.getHours() * 60 + now.getMinutes();
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
return ROUNDS.map((r) => ({
key: r.key,
label: r.label,
date,
verifyHour: r.verifyHour,
deadline: `${date} ${pad2(r.verifyHour)}:00:00`,
windowStart: floorStr,
windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`,
includeHistory: true,
active: minutes >= r.verifyHour * 60,
}));
}
/**
* 下一轮的核验时刻。
*
* ⚠️ 2026-10-20 第九版起**不再使用**:轮次归属本身就是按审核时间卡的,
* 「审核晚于 13:00 的单」根本进不了 13:00 轮,会自然落到 18:00 轮,
* 不需要额外顺延。函数保留是因为「按下一轮核验时刻算」这个业务表述
* 未来若用于别的口径(例如按创建时间分轮),逻辑还是这一套。
*
* @param {object} round resolveActiveRound / describeRounds 的返回项(需带 date/key)
* @returns {string} 'YYYY-MM-DD HH:mm:00'
*/
function nextRoundDeadline(round) {
const idx = ROUNDS.findIndex((r) => r.key === round.key);
if (idx >= 0 && idx + 1 < ROUNDS.length) {
return `${round.date} ${pad2(ROUNDS[idx + 1].verifyHour)}:00:00`;
}
// 当天最后一轮 → 次日首轮
const [y, m, d] = String(round.date).split('-').map(Number);
const next = new Date(y, (m || 1) - 1, (d || 1) + 1);
return `${next.getFullYear()}-${pad2(next.getMonth() + 1)}-${pad2(next.getDate())}`
+ ` ${pad2(ROUNDS[0].verifyHour)}:00:00`;
}
module.exports = {
CENTERS,
ROUNDS,
HISTORY_LOOKBACK_DAYS,
EXCLUDED_WAREHOUSE_KEYWORDS,
resolveCenter,
centerWindowEndHour,
centerWindowEnd,
nextRoundDeadline,
loadCenterWarehouseMap,
clearWarehouseCache,
getWarehouseCache: () => warehouseCache,
resolveActiveRound,
describeRounds,
beijingNow,
BEIJING_OFFSET_MINUTES,
EXCLUDED_STATUS_SQL,
SHIPPED_STATUS,
PENDING_STATUS_SQL,
NOT_SPLIT_MERGED_SQL,
OUTBOUND_TABLES,
ORDER_STATE_TABLE,
SHIPPING_STATUS,
UNAUDITED_STATUS,
ABNORMAL_STATUS,
IGNORED_STATES,
CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH,
VOID_ORDER_STATUS,
SHIP_TIMELINESS_HOURS,
toNumber,
};

View File

@ -1,775 +0,0 @@
'use strict';
/**
* 仓储中心 24 小时发货时效 —— 服务层
*
* ═══════════════════════════════════════════════════════════════════════════
* 口径(2026-10-20 第九次修正,业务最终定版)
* ═══════════════════════════════════════════════════════════════════════════
* 业务原话(按时间顺序,最后一条为准):
* 17:12「当日 7 点前订单(7 点前订单**按照审核时间算**),13:00 核验未发就算超时」
* 17:5x「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 16 点审核的订单 18:00 核验;
* 然后再看历史未发的订单」
*
* ⟹ 三条铁律:
* ① **轮次归属看「审核时间」**(`last_oa_confirm_time`,聚水潭显示值):
* 今天 00:00~07:00 审核 → 13:00 轮
* **截止今天 16:00 前审核**的单 → 18:00 轮
* (业务 2026-10-20 补正:18:00 核验的是 16:00 前**所有**审核过的单,
* 不是「今天 00:00~16:00」这一个自然日切片 —— 含今天之前审核、
* 至今没发的历史未发单,也含 7 点前审核到 13:00 还没发出的;
* 山东两仓 15:00、河南洛宁 13:00 是仓级例外,见 registry)
* ⚠️ **不看创建时间**:业务亲自指出的 9818476(创建 10-18 12:49 / 审核 10-20 13:06)
* 和 9823980(创建 10-18 20:40 / 审核 10-20 09:15)都是下午/上午才审的,
* 按创建时间会被错算进 13:00 轮 —— 第八版就是这么错的。
* ② **历史未发的单一律算**:昨天及更早审核、至今没发货的,两个轮次都要看到
* (它们早就该发了,不该等今天的分段窗口)。
* ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。
* 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。
* ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充):
* 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」),
* 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。
*
* 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。
* 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)——
* 这就是聚水潭订单列表「审核时间」那一列显示的值,业务拿它对账。
* 之前取首次审核,业务抽查 3 单(5127368691073091434 等)立刻发现不一致:
* 首次 10-18 13:15 vs 聚水潭 10-20 13:38。**别再改成 first_oa**。
* 因为窗口也按审核时间卡,「审核 13:06 的单」只会进 18:00 轮,
* 已等自然用 18:00 算,不会出现「0 分」。
* 明细列表并列「审核时间 / 发货时间」—— 业务 2026-10-20 要求**去掉创建时间**,
* 改成看各订单的发货时间(未发的显示「未发货」),对账时直接和聚水潭发货时间比。
*
* 🔥 「已发 / 未发」还是以聚水潭「销售订单」的发货状态为准(= 状态表 send_time)。
* **不要**改用运单号(logistic_id)判已发:实测同一店铺里,
* 「已发货」的单独有运单号 + 未揽收,「发货中」的单也有运单号 + 未揽收 —— 判不出来。
* 运单号只用来在明细里标「已打单 · 未揽收」,给业务一个「仓库干到哪一步」的线索。
*
* 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行)
*
* 🔥 时区(2026-10-21 生产实测踩坑,别再裸用 `new Date()`):
* · 生产容器 `/etc/timezone = Etc/UTC`,而 MSSQL `GETDATE()` 返回**北京时间**,
* mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
* · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**,
* 轮次日期 / 核验窗口整体错位(实测 08:04 跑 13:00 轮得 3895 单,本地仅 6 单)。
* · 统一用 `registry.beijingNow()` 取「现在」(本机已是东八区时平移量为 0,行为不变)。
* **不要**去改容器 TZ —— 会波及全部既有定时任务。
*
* · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值;
* 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。
* · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created`
* 🔥 状态表里**没有**这一列,必须回订单表按 `o_id = inner_order_id` 补:
* · `createdAt`(状态表那个 datetime2)= 同步落库时间,晚 1.5~3.5h,**不能用**
* · `created` 才是聚水潭订单列表里的「创建时间」(比下单晚 15~30 秒)
* · 已发货 = `send_time` 非空(已发货单 100% 有值)
* · 待发货 = `send_time` 为空 且 `order_status_name = 发货中`
* · 仓库 = `warehouse_name`(直接套 CENTERS 关键词归属主仓)
*
* ── 为什么弃用 jst_Orders.status(前三版全栽在这)──────────────────────────
* 订单表的 `status` 是**下单那一刻的快照**,之后聚水潭侧取消、发货前售后、
* 审核通过都不会回写。实测(2026-10-20)业务反馈「聚水潭查不到」的 6 单:
* 订单表 status 全是 `WaitConfirm`(看起来像待发货),
* 状态表真实状态是 **已取消**(标签带「发货前售后」),另 1 单是 **异常**。
* ⟹ 所以「状态 / 是否已发」一律以状态表为准,**订单表只用来补创建时间**。
*
* 时间列语义(已逐一实测,别再猜):
* · `order_date` / `pay_date` —— 平台下单 / 付款时间(客户那边的时间)
* · `created`(订单表) —— ✅ **聚水潭创建时间**(展示用,比下单晚 15~30 秒)
* · `createTime`/`updateTime` —— 数仓灌库时间(带毫秒、批量、滞后约 5 分钟)
* · `modified` —— ⚠️ 最后修改时间,每天凌晨被批量刷新,不可用
* · `first_oa_confirm_time` —— 首次审单时间(审核时间缺失时的第二顺位回退)
* · `last_oa_confirm_time` —— ✅ **最新审核时间(本版考核基准)**
* · `send_time` —— **发货时间**
* · `query_time` —— 本表快照时间(判数据新鲜度)
*
* ── 三态分流(互斥)─────────────────────────────────────────────────────
* 超时未发货 = 发货中 + send_time 空 + 审核时间已过本轮窗口上界(07:00/16:00)
* → orders(考核仓库)
* 不计考核 = 已支付待审核(没审单)/ 异常(订单异常) → heldOrders
* 不展示 = 已发货 / 已取消(作废)/ 等供销商|外仓发货 → 直接丢弃
* 🔥 作废单(已取消)不考核:近 30 天 64,440 单,是「发货中」2,448 单的 26 倍,
* 不排除的话整页都是客户取消掉的单。
*
* ── 核验轮次(业务给定)──────────────────────────────────────────────────
* `verifyHour` —— 几点核验(13:00 / 18:00),判「超时多久」用它
* `windowEndHour` —— 只看该时刻**前审核**的单(07:00 / 16:00,仓级例外见 registry)
* ⚠️ 两者不是同一个东西,别合成一个字段
* ⚠️ 也**不要**再引入「满 N 小时才算超时」的门槛(第七版踩过,会被业务打回)
* ⚠️ 窗口**要**卡审核时间(第九版定版)。第六版曾因「卡审核时间 13:00 轮只剩 2 单」
* 退回创建时间,结果 9818476 / 9823980 这类下午才审的单被错算进 13:00 轮 →
* 业务亲自纠正:「7 点前订单**按照审核时间算**」。审核时间少是因为 13:00 轮本来就
* 只该有早上那批,不是数据不对。
*
* ── 性能(2026-10-20 实测,四个坑都踩过)──────────────────────────────────
* ① **不要 SELECT 宽列**。同一 WHERE 只取 2 列 0.7s,取 15 列(含
* `order_labels` / `buyer_message` / `remark`)**54s** —— 视图没法做
* 覆盖扫描,多取一列就是几万次基表回表。本表只投影 10 个窄列。
* ② **不要回订单表补金额**。`o_id IN (400 个)` 打两张订单宽视图,单条实测
* **38.7s**。发货时效不需要金额,直接不做。
* (但补 `created` **可以**:只取两列时 9,676 个候选只要 14.5s + 2.9s。)
* ③ **不要额外查新鲜度**。`MAX(send_time)` 又是一次 402 万行扫描(21.1s)。
* 快照时间直接从主查询的 `query_time` 在 Node 里取 MAX。
* ④ `SELECT *` 更是灾难 —— 单 form 单号查一次 **5 分钟**不返回。
* ⑤ 走 `querySlow`,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。
*
* ── 时区 ─────────────────────────────────────────────────────────────────
* 所有时间列都是**东八区本地时间**;服务与库都在东八区。
* **不要**在 Node 侧用 UTC 日期判断。
*/
const cloudDatabase = require('../config/cloudDatabase');
const registry = require('./warehouseShipTimelinessRegistry');
const {
ORDER_STATE_TABLE,
SHIPPING_STATUS,
UNAUDITED_STATUS,
ABNORMAL_STATUS,
IGNORED_STATES,
CREATED_SOURCE_TABLES,
CREATED_BACKFILL_BATCH,
VOID_ORDER_STATUS,
resolveCenter,
} = registry;
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
const DEFAULT_DETAIL_LIMIT = 500;
const MAX_DETAIL_LIMIT = 2000;
// 状态表快照超过这个分钟数没刷新 → 页面提示「数据滞后」。
// 正常 ETL 每小时至少刷一轮(每小时约 1000 行有变化),90 分钟足够容错。
const STATE_STALE_MINUTES = 90;
/**
* 不参与发货时效考核的快递(业务 2026-10-20 指定):
* 自提 —— 客户自己来拉,仓库不负责派送
* 中铁 —— 中铁物流(大件专线)
* 壹米滴答 —— 专线物流(大件)
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
* 只影响「超时未发」判定,不影响「已发」判定。
*/
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答'];
/**
* 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。
*
* 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」——
* 销售订单页面显示「已发货」= status `Sent`。`send_date` 很多店铺不回写
* (实测 103 单里 103 单都是空),所以必须把 status 也当依据。
* ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」,
* 正是本页要考核的对象;算进来整页就空了。
*/
const ORDER_SHIPPED_STATUS = ['Sent'];
/**
* 结果缓存。
*
* 这张视图单次查询实测 10~60 秒(取决于缓存温度与库负载),页面又是
* `AUTO_REFRESH_MS` 定时轮询 —— 不缓存的话每轮轮询都会重压一次库,
* 页面也会一直转圈。缓存 key 含「轮次 + 日期 + limit」,
* 业务点「刷新」时前端带 `force=1` 绕过缓存强制重查。
*/
const CACHE_TTL_MS = Number(process.env.CANGKU_WST_CACHE_TTL_MS) || 5 * 60 * 1000;
let resultCache = { key: '', at: 0, data: null };
/** 清缓存(切轮次 / 手工排查时用) */
function clearShipTimelinessCache() {
resultCache = { key: '', at: 0, data: null };
}
/** 把 datetime / Date / 字符串统一成 'YYYY-MM-DD HH:mm:ss'(东八区,不做时区换算) */
function formatDateTime(value) {
if (!value) return '';
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '';
const p = (n) => String(n).padStart(2, '0');
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}`
+ ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`;
}
const s = String(value);
if (!s) return '';
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
}
/** 两个时间文本相差多少分钟(to - from);任一为空返回 0 */
function diffMinutes(fromText, toText) {
const a = formatDateTime(fromText);
const b = formatDateTime(toText);
if (!a || !b) return 0;
const pa = new Date(a.replace(' ', 'T')).getTime();
const pb = new Date(b.replace(' ', 'T')).getTime();
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return 0;
return Math.floor((pb - pa) / 60000);
}
/**
* 取「聚水潭订单状态」里所有未发货的单(发货中 / 待审核 / 异常)。
*
* 为什么不用 wms_co_id 过滤:本表只有 `warehouse_name`,没有仓 ID。
* 近 30 天三类合计约 1 万行,一次取回在 Node 侧按仓库名归属主仓,代价可接受。
*
* ⚠️ 只投影 11 个**窄列**:`order_labels`/`buyer_message`/`remark` 这类长文本
* 会把 0.7s 的查询拖到 54s(视图无法覆盖扫描,每行都要回基表)。
* ⚠️ `send_time IS NULL` 是关键:已发货单在这里 100% 有值。
*
* 🔥 2026-10-20 第八版:**不再把「创建 ≤ 核验−24h」下推到 SQL**。
* 本版口径是「到核验时刻未发即超时」,`send_time IS NULL` 就是候选;
* 窗口上界改在 Node 侧按**审核时间**判。SQL 里那两个时间列只是用来
* 判断窗口归属,不再参与「等了多少小时」的门槛。
*/
async function loadPendingStates(round) {
const rows = await cloudDatabase.querySlow(
// 🔥 时间列直接取原始 datetime,**不要**在 SQL 里 CONVERT 成字符串:
// CONVERT 会作用到扫描到的每一行(谓词下推不到视图里),实测把点查拖到 40s+;
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
// 就是库里存的北京时间,不会偏移 8 小时。
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
e.order_status_name, e.logistic_status_name,
e.logistic_id, e.logistic_company_name,
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
e.send_time, e.query_time
FROM dbo.${ORDER_STATE_TABLE} e
WHERE e.send_time IS NULL
AND e.pay_time >= @windowStart
AND e.order_status_name IN (@shipStatus, @st1, @st2)`,
{
shipStatus: SHIPPING_STATUS,
st1: UNAUDITED_STATUS,
st2: ABNORMAL_STATUS,
windowStart: round.windowStart,
},
// 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s)
{ connectRequestTimeoutMs: 300000 }
);
return rows || [];
}
/** 并发跑任务,最多 `limit` 个同时在飞(不把连接池打满) */
async function mapLimit(items, limit, worker) {
const out = [];
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const i = cursor;
cursor += 1;
if (i >= items.length) return;
out[i] = await worker(items[i], i);
}
});
await Promise.all(runners);
return out;
}
/**
* 回订单表补「起算点 + 作废事实」。
*
* 状态表 `jst_order_express_details` 里没有聚水潭创建时间(`createdAt` 是同步落库
* 时间,比付款晚 1.5~3.5 小时),只能回两张订单表按 `o_id = inner_order_id` 取
* `created`(稳定比下单晚 15~30 秒,即聚水潭订单列表里的「创建时间」)。
*
* 🔥 同一趟把**作废事实**也取回来(业务 2026-10-20 反馈作废单被算成超时):
* 状态表的 `send_time` 对**被合并单 / 拆单父单不回写** —— 单子早随主单发走了,
* 状态表却还是「发货中 + send_time 空」。所以必须再看订单表的
* `status` / `send_date` / `is_merge` / `is_split`(见 registry.VOID_ORDER_STATUS)。
*
* ⚠️ 只投影这 6 个**窄列**。实测 9,676 个候选:窄列 14.5s + 2.9s,
* 一旦连 pay_amount / labels 一起取,单批 400 个就要 38.7s。
*
* @param {number[]} oids 内部单号
* @returns {Promise<Map<number, {createdAt:string, status:string, sendDate:string, void:boolean, shipped:boolean}>>}
*/
async function backfillOrderFacts(oids) {
const map = new Map();
if (!oids.length) return map;
let remaining = oids;
for (const table of CREATED_SOURCE_TABLES) {
if (!remaining.length) break;
const batches = [];
for (let i = 0; i < remaining.length; i += CREATED_BACKFILL_BATCH) {
batches.push(remaining.slice(i, i + CREATED_BACKFILL_BATCH));
}
const results = await mapLimit(batches, 4, async (batch) => {
try {
return await cloudDatabase.querySlow(
`SELECT o_id, created, status, send_date, is_merge, is_split
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
{},
{ connectRequestTimeoutMs: 300000 }
);
} catch (e) {
// 补不到不致命:调用方会回退到付款时间,不整页失败
return [];
}
});
for (const rows of results) {
for (const r of rows || []) {
const id = Number(r.o_id);
if (!Number.isFinite(id)) continue;
const status = String(r.status || '');
const sendDate = formatDateTime(r.send_date);
map.set(id, {
createdAt: formatDateTime(r.created),
status,
sendDate,
// 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货
void: VOID_ORDER_STATUS.includes(status)
|| Number(r.is_merge) === 1
|| Number(r.is_split) === 1,
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
});
}
}
remaining = remaining.filter((n) => !map.has(n));
}
return map;
}
function emptySummary() {
return {
// 超时未发货(考核仓库的部分)
overdueUnshipped: 0,
overdueCenterCount: 0,
oldestDays: 0,
// 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任
heldCount: 0,
// 已剔除的作废 / 已发单(页面只作说明,不进考核)
voidCount: 0,
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答)
carrierSkipped: 0,
};
}
function buildEmptyCenters(centerList) {
return centerList.map((c) => ({
key: c.key,
name: c.name,
placeholder: c.placeholder,
warehouseCount: c.warehouses.length,
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
overdueUnshipped: 0,
oldestDays: 0,
heldCount: 0,
}));
}
async function getShipTimeliness(options = {}) {
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
const now = options.now instanceof Date ? options.now : registry.beijingNow();
const limit = Math.min(
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
MAX_DETAIL_LIMIT
);
// ① 主仓清单(用于展示「含子仓 N 个」;归属判定走 warehouse_name 关键词)
const centerList = await registry.loadCenterWarehouseMap();
const pad2 = (n) => String(n).padStart(2, '0');
// ② 决定本轮截止时刻
const rounds = registry.describeRounds(now);
let round = null;
if (options.round) {
const target = registry.ROUNDS.find((r) => r.key === String(options.round).toUpperCase());
if (!target) {
const err = new Error(`未知的核验轮次: ${options.round}`);
err.statusCode = 400;
throw err;
}
round = rounds.find((r) => r.key === target.key);
if (!round) {
const err = new Error(`无法构造核验窗口: ${options.round}`);
err.statusCode = 500;
throw err;
}
} else {
round = registry.resolveActiveRound(now);
}
// 🔥 13:00 前没有轮次生效(业务口径):直接返回「未到核验时间」,不查库
if (!round) {
const nextRound = rounds.find((r) => !r.active) || rounds[0];
return {
generatedAt: formatDateTime(now),
date: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`,
round: null,
notStarted: true,
isPreviousDay: false,
rounds,
nextRoundAt: nextRound ? nextRound.deadline : '',
nextWindowEnd: nextRound ? nextRound.windowEnd : '',
deadline: '',
window: null,
summary: emptySummary(),
centers: buildEmptyCenters(centerList),
orders: [],
heldOrders: [],
truncated: false,
detailLimit: limit,
detailDegraded: false,
freshness: null,
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
};
}
// ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时
// 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用
const force = options.force === true
|| ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase());
const cacheKey = `${round.key}|${round.date}|${limit}`;
if (!force && resultCache.data && resultCache.key === cacheKey
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
return {
...resultCache.data,
cached: true,
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
};
}
let stateRows = [];
try {
stateRows = await loadPendingStates(round);
} catch (e) {
const err = new Error(`聚水潭订单状态表查询失败:${e.message}`);
err.statusCode = 502;
throw err;
}
// ③-b 补起算点「聚水潭创建时间」+ 作废事实(状态表两样都没有,回订单表取)
const oids = [...new Set(
stateRows.map((r) => Number(r.inner_order_id)).filter((n) => Number.isFinite(n))
)];
const orderFacts = await backfillOrderFacts(oids);
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
let voidCount = 0;
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账
let carrierSkipped = 0;
// ④ 分桶
const acc = new Map(centerList.map((c) => [c.key, {
pendingCount: 0,
oldestDays: 0,
heldCount: 0,
}]));
const pendingRows = [];
const heldRows = [];
const seenSo = new Set();
let snapshotAt = '';
for (const row of stateRows) {
// 快照时间:状态表最后一次刷新(顺带算,不再单独扫一遍 402 万行表)
const qt = formatDateTime(row.query_time);
if (qt > snapshotAt) snapshotAt = qt;
const center = resolveCenter(row.warehouse_name);
if (!center) continue; // 非 11 主仓(代发仓/未指定仓/POP 仓…)
const bucket = acc.get(center.key);
if (!bucket) continue;
const state = String(row.order_status_name || '');
// 🔥 作废单不考核 —— 业务 2026-10-20 明确「作废订单不用考核」。
// 状态表的 send_time 对**被合并单 / 拆单父单不回写**(案例 5127276315127080023:
// 状态表「发货中 + send_time 空」,订单表却是 Merged 且 send_date 有值、
// 出库单也早就生成了),所以必须回订单表再判一次,光信状态表会被冤枉。
const fact = orderFacts.get(Number(row.inner_order_id));
if (fact && (fact.void || fact.shipped)) {
voidCount += 1;
continue;
}
// SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉
if (IGNORED_STATES.includes(state)) continue;
// 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 ——
// 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
// 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。
// 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。
const carrier = String(row.logistic_company_name || '').trim();
const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k));
if (hitCarrier) {
carrierSkipped += 1;
continue;
}
// ── 窗口归属 = **审核时间**(业务 2026-10-20 最终定版,两次纠偏后的结论)────
// 业务原话:「当日 7 点前订单(7 点前订单**按照审核时间算**)……」
// 实测案例(业务亲自指出的两单):
// · 9818476:创建 10-18 12:49,但**审核 10-20 13:06** → 不是 7 点前的单,13:00 轮不能出现
// · 9823980:创建 10-18 20:40,**审核 10-20 09:15** → 同理,归 18:00 轮
// ⟹ 「7 点前/16 点前」卡的是**审核时间**:审核 ≤ 07:00 归 13:00 轮,
// 审核 ≤ 16:00 归 18:00 轮(山东两仓 15:00、河南洛宁 13:00,见 registry)。
// ⚠️ 别看创建时间:客户凌晨下单、仓库上午才审的单,按创建时间会提前算超时。
// ⚠️ 审核时间取**最后一次**(last_oa),见下面 458 行开始的说明。
const createdAt = fact ? fact.createdAt : '';
const payAt = formatDateTime(row.pay_time);
const auditFirst = formatDateTime(row.first_oa_confirm_time);
const auditLast = formatDateTime(row.last_oa_confirm_time);
// 🔥 取**最后一次**审核(= 聚水潭订单列表里「审核时间」那一列显示的值)。
// 业务 2026-10-20 20:0x 抽查 3 单指出「审核时间跟系统里的不一致」,
// 根因就是我们之前取错了列(first_oa = 首次审核):
// 5127368691073091434 首次 10-18 13:15 / 聚水潭显示 10-20 13:38
// 261019-670795046400872 首次 10-19 08:48 / 聚水潭显示 10-20 09:31
// 261019-558387771763179 首次 10-19 11:21 / 聚水潭显示 10-20 09:11
// ⟹ 页面必须跟聚水潭对齐,否则业务一对账就说「跑错了」。
const auditAt = auditLast || auditFirst;
const windowAt = auditAt || createdAt || payAt;
const benchmarkAt = windowAt;
const benchmarkSource = auditAt
? (auditLast ? 'last_audit' : 'first_audit')
: (createdAt ? 'created' : 'pay');
// 同一 so_id 可能因拆包出现多行(实测有),按 so_id + 窗口时间 去重
const dedupeKey = `${row.so_id}|${windowAt}`;
if (seenSo.has(dedupeKey)) continue;
seenSo.add(dedupeKey);
// ── 本轮核验范围(业务 2026-10-20 最终定版)────────────────────────────
// ① **今天的单按审核时间分段**:
// 今天 00:00 ~ 07:00 审核 → 13:00 轮核验
// **截止今天 16:00 前审核**的单 → 18:00 轮核验
// (= 16:00 前所有审核过的单,含 7 点前没发出的;不是「07:00~16:00」那一段。
// 山东两仓 15:00、河南洛宁 13:00 为仓级例外)
// ⟹ 今天 09:15 审核的单(9823980)、13:06 审核的单(9818476)
// **不进 13:00 轮**,只在 18:00 轮出现(业务亲自指出的两单)
// ② **历史未发的单一律算**(昨天及更早审核、至今没发货)—— 它们已经超时,
// 两个轮次都该看到,不受「今天 00:00 起」的限制。
// ③ 30 天护栏只为查询性能,不是业务边界。
const windowEndAt = registry.centerWindowEnd(center.key, round);
const todayStart = `${round.date} 00:00:00`;
if (windowAt && windowAt < round.windowStart) continue; // 超出 30 天护栏
if (windowAt && windowAt >= todayStart && windowAt > windowEndAt) continue;
const base = {
row,
center,
state,
createdAt,
payAt,
auditAt,
windowAt,
benchmarkAt,
benchmarkSource,
warehouseName: String(row.warehouse_name || ''),
};
// ── 不计考核:没审单(已支付待审核)或订单异常,都不该算仓库超时 ──────
if (state === UNAUDITED_STATUS || state === ABNORMAL_STATUS) {
bucket.heldCount += 1;
heldRows.push({
...base,
holdReason: state === UNAUDITED_STATUS ? '待审核' : '订单异常',
});
continue;
}
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
if (!benchmarkAt) {
bucket.heldCount += 1;
heldRows.push({ ...base, holdReason: '缺审核时间' });
continue;
}
// waited = 审核时间 → 本轮核验时刻(13:00 / 18:00)
// ⚠️ 因为窗口本身按审核时间卡(审核 ≤ 07:00 / 16:00),benchmark 必然 ≤ deadline,
// 这里不需要再处理「审核晚于核验」的情况 —— 那种单会自然落到下一轮:
// 审核 13:06 的单进不了 13:00 轮(07:00 上界),只在 18:00 轮出现,已等 = 18:00 − 13:06。
const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline));
bucket.pendingCount += 1;
const days = Math.floor(waited / 1440);
if (days > bucket.oldestDays) bucket.oldestDays = days;
pendingRows.push({ ...base, waited });
}
// ⑤ 排序:最久的排前面(业务一眼看最急的)
pendingRows.sort((a, b) => {
if (a.windowAt < b.windowAt) return -1;
if (a.windowAt > b.windowAt) return 1;
return 0;
});
heldRows.sort((a, b) => {
const x = a.windowAt || '';
const y = b.windowAt || '';
if (x < y) return -1;
if (x > y) return 1;
return 0;
});
const detailRows = pendingRows.slice(0, limit);
const heldDetail = heldRows.slice(0, limit);
const orders = detailRows.map((x) => {
const r = x.row;
return {
oId: String(r.inner_order_id || ''),
soId: String(r.so_id || ''),
shopName: String(r.shop_name || ''),
// 创建时间 = 聚水潭订单创建时间(业务 2026-10-20 已从明细列去掉,仅保留字段做回退基准)
createdAt: x.createdAt,
// 发货时间:本列表只取 send_time IS NULL 的单,所以这里恒为空 → 前端显示「未发货」
sendTime: formatDateTime(r.send_time),
// 付款时间(并列展示,方便对账)
payTime: x.payAt,
// 审核时间 = **考核基准**:决定进 13:00 轮还是 18:00 轮,已等时长也从它算
auditTime: x.auditAt,
// 基准时间实际取值(审核缺失时回退创建/付款)+ 来源标记
benchmarkTime: x.benchmarkAt,
benchmarkSource: x.benchmarkSource,
state: x.state,
logisticStatus: String(r.logistic_status_name || ''),
// 运单号 / 快递公司:有值说明仓库**已打单**(但还没揽收/回传发货时间)。
// 业务 2026-10-20 报「聚水潭显示已发货、页面显示未发货」,靠这两列能一眼看出卡在哪一步。
logisticId: String(r.logistic_id || '').trim(),
logisticCompany: String(r.logistic_company_name || '').trim(),
centerKey: x.center.key,
centerName: x.center.name,
warehouseName: x.warehouseName,
// 已等时长(小时,保留 1 位)与分钟(排序都用分钟)
waitedHours: Math.round((x.waited / 60) * 10) / 10,
overdueMinutes: x.waited,
};
});
const heldOrders = heldDetail.map((x) => {
const r = x.row;
const from = x.benchmarkAt || '';
return {
oId: String(r.inner_order_id || ''),
soId: String(r.so_id || ''),
shopName: String(r.shop_name || ''),
createdAt: x.createdAt,
sendTime: formatDateTime(r.send_time),
payTime: x.payAt,
auditTime: x.auditAt,
benchmarkTime: x.benchmarkAt,
benchmarkSource: x.benchmarkSource,
state: x.state,
holdReason: x.holdReason,
logisticStatus: String(r.logistic_status_name || ''),
logisticId: String(r.logistic_id || '').trim(),
logisticCompany: String(r.logistic_company_name || '').trim(),
centerKey: x.center.key,
centerName: x.center.name,
warehouseName: x.warehouseName,
heldDays: from ? Math.floor(diffMinutes(from, round.deadline) / 1440) : 0,
};
});
const centers = centerList.map((c) => {
const b = acc.get(c.key) || { pendingCount: 0, oldestDays: 0, heldCount: 0 };
return {
key: c.key,
name: c.name,
placeholder: c.placeholder,
warehouseCount: c.warehouses.length,
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
overdueUnshipped: b.pendingCount,
oldestDays: b.oldestDays,
heldCount: b.heldCount,
// 该仓在本轮的窗口上界(可能是仓级例外,如山东两仓 15:00、河南洛宁 13:00)
windowEnd: registry.centerWindowEnd(c.key, round),
};
});
// 本轮有「仓级例外窗口」的仓(给前端提示用;没有就是空数组)
const windowOverrides = centerList
.filter((c) => registry.centerWindowEndHour(c.key, round.key) !== null)
.map((c) => ({
key: c.key,
name: c.name,
hour: registry.centerWindowEndHour(c.key, round.key),
windowEnd: registry.centerWindowEnd(c.key, round),
}));
const summary = centers.reduce((a, c) => {
a.overdueUnshipped += c.overdueUnshipped;
if (c.overdueUnshipped > 0) a.overdueCenterCount += 1;
if (c.oldestDays > a.oldestDays) a.oldestDays = c.oldestDays;
a.heldCount += c.heldCount || 0;
return a;
}, emptySummary());
summary.voidCount = voidCount;
summary.carrierSkipped = carrierSkipped;
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
// 🔥 2026-10-20 业务报「已发货的单还显示未发货」时才发现这里曾写死 stale:false ——
// 状态表是**准实时镜像**,上游 ETL 一停(实测当天 15:48 之后 0 行刷新),
// 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。
// 必须把滞后暴露到页面上,否则业务只会以为判定写错。
const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null;
const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES;
// 🔥 更狠的一条:快照**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
// 实测 2026-10-20 18:54:快照停在 15:48,18:00 核验时早已发出的单全被判「未发货」,
// 业务连续报了 3 单都是这个原因。这种时候数字必然偏大,必须红字告警。
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
const freshness = {
stateMaxQuery: snapshotAt,
ageMinutes: snapshotAgeMin,
stale,
coversDeadline,
hint: [
stale
? `聚水潭订单状态镜像最新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。`
: '',
!coversDeadline
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
+ '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。'
: '',
].filter(Boolean).join(' '),
};
const payload = {
generatedAt: formatDateTime(now),
date: round.date,
round: {
key: round.key,
label: round.label,
verifyHour: round.verifyHour,
windowEnd: round.windowEnd,
},
notStarted: false,
isPreviousDay: false,
rounds,
deadline: round.deadline,
// boundary='audit':轮次归属、已等时长都按**首次审核时间**算
window: {
start: round.windowStart,
end: round.windowEnd,
includeHistory: true,
boundary: 'audit',
measuredBy: 'audit',
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
overrides: windowOverrides,
},
summary,
centers,
orders,
// 不考核的快递(前端文案直接用,避免两边各写一份)
excludedCarriers: EXCLUDED_CARRIERS,
// 不计考核清单(待审核 / 异常)—— 与 orders 互斥
heldOrders,
truncated: pendingRows.length > orders.length,
detailLimit: limit,
detailDegraded: false,
freshness,
cached: false,
cacheAgeSec: 0,
note: '',
};
resultCache = { key: cacheKey, at: Date.now(), data: payload };
return payload;
}
module.exports = {
getShipTimeliness,
clearShipTimelinessCache,
DEFAULT_DETAIL_LIMIT,
MAX_DETAIL_LIMIT,
};

View File

@ -525,74 +525,6 @@ test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)
} }
}); });
// ⑦-e 成品库存多仓合并(2026-10-17):
// 实物搬到「简尚-横林二仓品牌仓」(16132681) 时,归属仓 (15870495) 为 0,
// 只查归属仓会把有货的成品算成 0(本次 5 个误报缺货 + 2 个误报预警)。
test('replenish index: sums stock across brand warehouse whitelist (⑦-e)', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const originalGetPool = cloudDatabase.getPool;
const captured = [];
cloudDatabase.query = async (sql) => {
captured.push(sql);
if (sql.includes('jst_packInventoryInfo')) {
// 箱表:同样的「老仓 0 / 新仓 10」格局
return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }];
}
if (sql.includes('jst_inventoryInfo')) {
// 关键:SUM 之后返回的是**多仓合计**。旧口径只查单仓时这里拿不到这笔货。
return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }];
}
if (sql.includes('pm_stockMonitor')) {
return [{
skuId: 'MKHDNZ0409260600788',
fifteenAvgSale: 1,
monitorStock: 0,
safetyDays: 3,
suggestStock: 0,
monitorDate: new Date('2026-10-17T00:00:00Z'),
}];
}
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) return [];
if (sql.includes('jst_qmcust_orderlist')) return [];
return [];
};
cloudDatabase.getPool = async () => ({
request: () => ({ input() {}, query: async () => ({ recordset: [] }) }),
});
try {
const index = await svc._private.loadReplenishIndex();
const row = index.bySku.get('MKHDNZ0409260600788');
assert.equal(row.stock, 10, '多仓求和后必须看到新仓的 10 件,不能是 0');
// SQL 层必须真的按白名单多仓取数,而不是退回单仓
const invSql = captured.find((s) => s.includes('jst_inventoryInfo'));
assert.ok(invSql.includes('IN (15870495,16132681)'),
`实时库存必须按仓白名单取数,实际: ${invSql}`);
assert.ok(invSql.includes('GROUP BY') && invSql.includes('SUM('),
'实时库存必须多仓 SUM + GROUP BY(旧版是 ORDER BY updateTime DESC 取首条)');
const packSql = captured.find((s) => s.includes('jst_packInventoryInfo'));
assert.ok(packSql.includes('IN (15870495,16132681)'),
`箱表必须按同一份仓白名单取数,实际: ${packSql}`);
} finally {
cloudDatabase.query = originalQuery;
cloudDatabase.getPool = originalGetPool;
svc.clearMkhBomCache();
}
});
// 白名单常量本身要被导出(新增同类仓时改这一处即可)
test('replenish index: brand warehouse whitelist contains main + Henglin No.2 brand warehouse', () => {
const svc = require('../src/services/mkhBomService');
const ids = svc._private.BRAND_WMS_CO_IDS;
assert.ok(Array.isArray(ids), 'BRAND_WMS_CO_IDS 必须是数组');
assert.ok(ids.includes(15870495), '必须含崔桥工厂品牌仓(归属主仓)');
assert.ok(ids.includes(16132681), '必须含横林二仓品牌仓(2026-10-13 新增)');
});
test('order index: reads both channel tables, merges and dedupes by o_id', async () => { test('order index: reads both channel tables, merges and dedupes by o_id', async () => {
const cloudDatabase = require('../src/config/cloudDatabase'); const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService'); const svc = require('../src/services/mkhBomService');
@ -1354,117 +1286,6 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn
} }
}); });
// ─────────────────────────────────────────────────────────────────────────────
// 配件库存的箱表兜底(2026-09-30 修正)
//
// 症状:配件监控页显示该配件有 200 件(货位 ZT-30、正常),MKH-BOM 却把成品
// MKHDNZ0409260600849 判成「配件不足」、可组装数 0。
// 成因:配件监控页是「库存表 → 同仓箱表 → 快照」三级兜底,而 MKH-BOM 的
// loadComponentStock **只查 jst_inventoryInfo**。上游库存表滞后停在 0 时,
// 箱表(小时级)已经有货 → 两个页面结论相反。
// 口径:与配件监控页 buildRealtimeStockSql 对齐 —— 库存表有正数就认它,
// 为 0 / 缺行才用同仓 jst_packInventoryInfo 的 bin 非空行合计兜底。
// ─────────────────────────────────────────────────────────────────────────────
test('配件库存兜底:库存表为 0 / 缺行时用同仓箱表补,但正库存不被覆盖', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const ONLY_PACK = 'DNZTESTPACK01'; // 库存表 0、箱表 200 → 必须取 200(误判就是这里)
const HAS_INV = 'DNZTESTPACK02'; // 库存表 5、箱表 300 → 必须取 5(箱表只记部分装箱量)
const PACK_ZERO = 'DNZTESTPACK03'; // 库存表 0、箱表 0 → 0
const NEITHER = 'DNZTESTPACK04'; // 两表都没有 → 0
const INV_ROWS = [ // 库存表:滞后停在旧值
{ code: ONLY_PACK, qty: 0 },
{ code: HAS_INV, qty: 5 },
{ code: PACK_ZERO, qty: 0 },
];
const PACK_ROWS = [ // 同仓箱表(bin 非空):小时级,已反映真实到货
{ code: ONLY_PACK, qty: 200 },
{ code: HAS_INV, qty: 300 },
{ code: PACK_ZERO, qty: 0 },
];
cloudDatabase.query = async (sql) => {
if (!sql.includes('jst_inventoryInfo')) return [];
// 按 SQL 形状分流:旧实现只查库存表(SQL 里没有箱表名)→ 拿不到箱表数据,
// 兜底失效、ONLY_PACK 被判 0(对照组 = 修复前必然失败)。
if (!sql.includes('jst_packInventoryInfo')) return INV_ROWS;
return [
...INV_ROWS.map((row) => ({ src: 'inv', ...row })),
...PACK_ROWS.map((row) => ({ src: 'pack', ...row })),
];
};
try {
svc.clearMkhBomCache();
const { stockMap, degraded } = await svc._private.loadComponentStock([
ONLY_PACK, HAS_INV, PACK_ZERO, NEITHER,
]);
assert.equal(degraded, false);
assert.equal(stockMap.get(ONLY_PACK), 200,
'库存表为 0 时必须用箱表兜底,否则配件被误判不足(本问题的根因)');
assert.equal(stockMap.get(HAS_INV), 5,
'库存表有正库存时必须以它为准,不能被箱表覆盖');
assert.equal(stockMap.get(PACK_ZERO), 0, '箱表也是 0 时不能补出正数');
assert.equal(stockMap.get(NEITHER), 0, '两表都查不到 = 该仓无库存,仍按 0');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('配件库存兜底:单条查询取两张表,且箱表必须过滤 bin 为空的行(防腐化)', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const seen = [];
cloudDatabase.query = async (sql) => {
if (!sql.includes('jst_inventoryInfo')) return [];
seen.push(sql);
return [];
};
try {
svc.clearMkhBomCache();
await svc._private.loadComponentStock(['DNZTESTPACK05']);
assert.equal(seen.length, 1, '一个分片只应打一条查询(避免多一次往返与快照时点差异)');
const sql = seen[0];
assert.match(sql, /jst_packInventoryInfo/, '必须查箱表,否则兜底不存在');
assert.match(sql, /NULLIF\(LTRIM\(RTRIM\(p\.bin\)\), N''\)\s+IS NOT NULL/,
'箱表必须过滤 bin 为空的行 —— 那是装箱记录,不是库存');
assert.match(sql, /p\.wms_co_id = @componentStockWmsCoId/,
'箱表必须限定配件归属仓,不能跨仓汇总');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('配件库存兜底:查询失败仍降级为「不做判定」,不得当成 0', async () => {
const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService');
const originalQuery = cloudDatabase.query;
const CODE = 'DNZTESTPACK06';
cloudDatabase.query = async (sql) => {
if (!sql.includes('jst_inventoryInfo')) return [];
throw new Error('模拟连接抖动');
};
try {
svc.clearMkhBomCache();
const { stockMap, degraded } = await svc._private.loadComponentStock([CODE]);
assert.equal(degraded, true, '查询失败必须降级,让调用方放弃配件不足判定(宁可少标不误标)');
assert.equal(stockMap.get(CODE), undefined, '失败时不得把未知库存当作 0 返回');
} finally {
cloudDatabase.query = originalQuery;
svc.clearMkhBomCache();
}
});
test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => { test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => {
const cloudDatabase = require('../src/config/cloudDatabase'); const cloudDatabase = require('../src/config/cloudDatabase');
const svc = require('../src/services/mkhBomService'); const svc = require('../src/services/mkhBomService');

View File

@ -1,171 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildGradeCase,
buildInClause,
fetchPickingBinSkuRows,
} = require('../src/services/pickingBinSalesAnalysis');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_BIN_TYPES,
} = require('../src/config/pickingBinSalesAnalysis');
test('buildInClause 按位展开参数', () => {
const { clause, params } = buildInClause([101, 102], 'w');
assert.equal(clause, '@w0,@w1');
assert.deepEqual(params, { w0: 101, w1: 102 });
});
test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => {
const expr = buildGradeCase();
PICKING_BIN_GRADE_RULES.filter((rule) => rule.lower > 0).forEach((rule) => {
assert.ok(expr.includes(`THEN '${rule.key}'`), `${rule.key} 未出现在分档表达式中`);
});
assert.ok(expr.includes("THEN 'slow2'"));
assert.ok(expr.includes("ELSE 'zero'"));
});
test('等级按商品销量判定(默认表达式取 s.salesQty,不含仓位汇总列)', () => {
const expr = buildGradeCase();
assert.ok(expr.includes('ISNULL(s.salesQty, 0) / @days'));
assert.ok(!expr.includes('x.salesQty'));
});
test('默认窗口为 30 天,统计范围固定为拣选位', () => {
assert.equal(PICKING_BIN_DEFAULT_DAYS, 30);
assert.ok(PICKING_BIN_WINDOW_OPTIONS.includes(30));
assert.deepEqual(PICKING_BIN_BIN_TYPES, ['拣选']);
});
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
await assert.rejects(
() => fetchPickingBinSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
TypeError
);
});
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组,不打库', async () => {
let called = 0;
const db = { query: async () => { called += 1; return [{ skuId: 'S1' }]; } };
assert.deepEqual(await fetchPickingBinSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 0 }), []);
assert.equal(called, 0);
});
test('fetchPickingBinSkuRows 按仓位×商品输出,等级与日销来自商品整档销量', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU1',
stockQty: 12, skuCount: 2, salesQty: 600, allocatedSalesQty: 300,
dailySales: 20, gradeKey: 'hot',
},
{
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU2',
stockQty: 8, skuCount: 2, salesQty: 45, allocatedSalesQty: 22.5,
dailySales: 1.5, gradeKey: 'slow1',
},
];
},
};
const rows = await fetchPickingBinSkuRows(db, [101, 102], {
shipFrom: '2026-09-08 01:00:00',
shipTo: '2026-10-08 01:00:00',
days: 30,
});
assert.equal(rows.length, 2);
assert.deepEqual(rows.map((r) => r.skuId), ['SKU1', 'SKU2']);
// 同一仓位两个商品拆两行,库存是各商品在该仓位的实际库存
assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]);
// 等级按商品自身日销,同一仓位的多行可以不同
assert.deepEqual(rows.map((r) => r.gradeKey), ['hot', 'slow1']);
// 分摊销量 = 商品销量 ÷ 占用的同类型仓位数(用于按行求和)
assert.deepEqual(rows.map((r) => r.allocatedSalesQty), [300, 22.5]);
assert.deepEqual(rows[0], {
wmsCoId: '101',
binName: 'XA01-01-01',
binType: '拣选',
skuId: 'SKU1',
stockQty: 12,
skuCount: 2,
salesQty: 600,
allocatedSalesQty: 300,
dailySales: 20,
gradeKey: 'hot',
});
// 参数必须带上两个仓库、发货窗口与天数
assert.equal(captured.params.w0, 101);
assert.equal(captured.params.w1, 102);
assert.equal(captured.params.v0, 101);
assert.equal(captured.params.shipFrom, '2026-09-08 01:00:00');
assert.equal(captured.params.shipTo, '2026-10-08 01:00:00');
assert.equal(captured.params.days, 30);
// 商品销量按占用仓位数分摊(供求和),但等级依据的是未分摊的商品销量
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
assert.ok(captured.sqlText.includes('ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)'));
// 统计范围固定为真实拣选仓位:pack_type=Bin 且 bin_group 为空;只统计有库存的行
assert.ok(captured.sqlText.includes("p.pack_type = N'Bin'"));
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0'));
assert.ok(captured.sqlText.includes('HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001'));
});
test('fetchPickingBinSkuRows 只统计真实拣选仓位,排除 Pack 箱记录且不带类型参数', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
// Pack 箱记录和存货位被排除,且不再有 binType IN (...) 的可变过滤
assert.ok(captured.sqlText.includes("p.pack_type = N'Bin'"));
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
assert.ok(!captured.sqlText.includes('b.binType IN'));
assert.equal(captured.params.t0, undefined);
assert.equal(captured.params.binTypes, undefined);
// 多来源 union 后必须再按 仓库+商品 汇总,否则同一仓位行会被拆成多行
assert.ok(captured.sqlText.includes('shipAgg AS ('));
assert.ok(captured.sqlText.includes('LEFT JOIN shipAgg s'));
assert.ok(!captured.sqlText.includes('LEFT JOIN ship s'));
});
test('销量覆盖 4 个来源:线上普通行 + 组合装展开 + 全民客户普通行 + 全民客户组合装展开', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 });
// ① 线上店铺订单普通行
assert.ok(captured.sqlText.includes('FROM rpt.jst_OrderItem'), '缺少 jst_OrderItem 普通行分支');
// ② 线上订单组合装行按映射表展开为子件
assert.ok(captured.sqlText.includes('JOIN rpt.jst_comboProductItem'), '缺少组合装展开分支');
assert.ok(captured.sqlText.includes("oi.sku_type = 'combine'"), '缺少组合装行识别条件');
// ③ 全民客户(B2B 分销)渠道普通行,排除拆单父单 Split 与问题单
assert.ok(captured.sqlText.includes('FROM rpt.jst_qmcust_orderlist_item'), '缺少 qmcust 分支');
assert.ok(captured.sqlText.includes("'Split', 'Question'"), 'qmcust 未排除拆单父单/问题单');
// 四个分支共用同一发货窗口
assert.equal(captured.sqlText.split('o.send_date >= @shipFrom').length, 5);
});
test('gradeKey 缺失时兜底为 zero', async () => {
const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', skuId: 'S1', stockQty: 5 }] };
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
assert.equal(rows[0].gradeKey, 'zero');
assert.equal(rows[0].salesQty, 0);
assert.equal(rows[0].allocatedSalesQty, 0);
assert.equal(rows[0].dailySales, 0);
assert.equal(rows[0].skuId, 'S1');
});

View File

@ -94,9 +94,7 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async
query: async (sqlText, params) => { query: async (sqlText, params) => {
captured = { sqlText, params }; captured = { sqlText, params };
return [ return [
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A01-01-1', stockQty: 7, salesQty: 0 }, { wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', stockQty: 12, salesQty: 0 },
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A02-03-1', stockQty: 5, salesQty: 0 },
{ wmsCoId: 101, skuId: 'SKU-B', skuName: '款式B', binName: null, stockQty: 3, salesQty: 0 },
]; ];
}, },
}; };
@ -107,16 +105,10 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async
days: 30, days: 30,
}); });
// 每个仓位独立一行;无仓位的 SKU 保留一行(仓位为空、库存取总库存) assert.equal(rows.length, 1);
assert.equal(rows.length, 3);
assert.equal(rows[0].wmsCoId, '101'); assert.equal(rows[0].wmsCoId, '101');
assert.equal(rows[0].skuName, '款式A'); assert.equal(rows[0].skuName, '款式A');
assert.equal(rows[0].binName, 'A01-01-1'); assert.equal(rows[0].stockQty, 12);
assert.equal(rows[0].stockQty, 7);
assert.equal(rows[1].binName, 'A02-03-1');
assert.equal(rows[1].stockQty, 5);
assert.equal(rows[2].binName, '');
assert.equal(rows[2].stockQty, 3);
// 口径必须与 fetchGradeRows 一致:同一段 gradeCase 表达式 + 同样的「有库存或有销量」过滤 // 口径必须与 fetchGradeRows 一致:同一段 gradeCase 表达式 + 同样的「有库存或有销量」过滤
assert.ok(captured.sqlText.includes("ELSE 'zero'")); assert.ok(captured.sqlText.includes("ELSE 'zero'"));
@ -125,25 +117,4 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async
assert.ok(captured.sqlText.includes("NOT IN ('Delete', 'Cancelled')")); assert.ok(captured.sqlText.includes("NOT IN ('Delete', 'Cancelled')"));
assert.equal(captured.params.w0, 101); assert.equal(captured.params.w0, 101);
assert.equal(captured.params.days, 30); assert.equal(captured.params.days, 30);
// 系统仓位:来自 rpt.jst_packInventoryInfo,按「仓库+SKU+仓位」聚合出仓位级库存,
// 每仓位一行(COALESCE 兜底无仓位 SKU 取总库存),排除无仓位与零库存记录
assert.ok(captured.sqlText.includes('rpt.jst_packInventoryInfo'));
assert.ok(captured.sqlText.includes('COALESCE(bn.binQty, g.stockQty)'));
assert.ok(captured.sqlText.includes('binName'));
});
test('fetchZeroSaleSkuRows 的仓位必须按同一仓库匹配,避免跨仓错配', async () => {
let captured = null;
const db = {
query: async (sqlText) => {
captured = sqlText;
return [];
},
};
await fetchZeroSaleSkuRows(db, [101], {
shipFrom: '2026-08-28 01:00:00', shipTo: '2026-09-27 01:00:00', days: 30,
});
assert.ok(captured.includes('bn.wmsCoId = g.wmsCoId AND bn.skuId = g.skuId'));
assert.ok(captured.includes("ISNULL(p.qty, 0) <> 0"));
}); });

View File

@ -1,527 +0,0 @@
/**
* 仓储中心物流转运异常 —— 口径回归测试(2026-10-21)
*
* 锁定几条最容易翻车的语义:
* ① 12 个发货仓的关键词归属:「山东青岗集仓唯品仓」不能被「山东青岗集仓」抢走;
* 品牌仓不能被主仓抢走;「京仓-横林二仓京东云仓」业务排除;
* 山东/河南的代发仓不能误并入。
* ② 核验批次:09:00 核验**前一日**发货的单;未到 09:00 时默认看昨天那批,
* 不能拿今天的空批次冒充结论。
* ③ 异常判定:已签收正常;未揽收/无物流单/异常/拒收/其他 → 异常;
* 运输中类只在「最后轨迹距核验时刻 ≥ 24h」才算运输停滞。
* ④ 一单多包裹:明细按包裹保留,汇总按**内部单号**去重(异常率不能虚高)。
* ⑤ 缓存:第二次命中缓存,force=1 必须绕过。
*
* ⚠️ 本文件不连数据库,纯逻辑 + 打桩。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const registry = require('../src/services/warehouseLogisticsExceptionRegistry');
// ── ① 仓库归属 ──────────────────────────────────────────────────────────
test('12 个发货仓都能按关键词归位', () => {
const cases = [
['简尚-横林二仓', 'HL2'],
['简尚-横林三仓', 'HL3'],
['简尚-横林二仓品牌仓', 'HL2_BRAND'],
['简尚-崔桥工厂仓', 'CQ'],
['简尚-崔桥工厂品牌仓', 'CQ_BRAND'],
['简尚-崔桥工厂半成品包装项目仓', 'CQ_WIP'],
['简尚-新苏北仓', 'XSB'],
['简尚-睢宁仓', 'SN'],
['简尚-山东青岗集仓', 'SD_QGJ'],
['简尚-山东品牌仓', 'SD_BRAND'],
['简尚-山东青岗集仓唯品仓', 'SD_QGJ_VIP'],
['简尚-河南洛宁仓', 'HN_LN'],
];
assert.equal(registry.CENTERS.length, 12);
for (const [name, key] of cases) {
assert.equal(registry.resolveCenter(name)?.key, key, name);
}
});
test('唯品仓 / 品牌仓不会被主仓的关键词抢走(最长匹配)', () => {
assert.notEqual(
registry.resolveCenter('简尚-山东青岗集仓唯品仓').key,
registry.resolveCenter('简尚-山东青岗集仓').key,
'唯品仓必须独立,不能并进青岗集仓'
);
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓').key, 'CQ_WIP');
});
test('子仓能并入主仓,无关代发仓 / 京东云仓被排除', () => {
assert.equal(registry.resolveCenter('简尚-运营C横林二仓专用仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('简尚-分销部睢宁专用仓')?.key, 'SN');
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null, '业务指定排除');
for (const name of ['工厂-山东菏泽森美代发仓', '工厂-河南千优代发仓', '简尚-苏州福利品仓']) {
assert.equal(registry.resolveCenter(name), null, `${name} 不该并入任何主仓`);
}
});
// ── ② 核验批次 ──────────────────────────────────────────────────────────
const at = (d, h, m = 0) => new Date(2026, 9, d, h, m, 0); // 2026-10-xx 本机时区
test('已过 09:00 → 核验今天这批(发货日是昨天)', () => {
const b = registry.resolveBatch(undefined, at(21, 9, 30));
assert.equal(b.verifyDate, '2026-10-21');
assert.equal(b.shipDate, '2026-10-20');
assert.equal(b.deadline, '2026-10-21 09:00:00');
assert.equal(b.windowStart, '2026-10-20 00:00:00');
assert.equal(b.windowEnd, '2026-10-21 00:00:00');
assert.equal(b.started, true);
assert.equal(b.isToday, true);
});
test('未到 09:00 → 今天这批还没跑,默认退回昨天那批', () => {
const b = registry.resolveBatch(undefined, at(21, 8, 30));
assert.equal(b.verifyDate, '2026-10-20');
assert.equal(b.shipDate, '2026-10-19');
assert.equal(b.started, true, '昨天的批次早就到点了');
assert.equal(b.lookback, true);
});
test('显式指定今天但还没到 09:00 → notStarted,不能给结论', async () => {
const b = registry.resolveBatch('2026-10-21', at(21, 8, 0));
assert.equal(b.started, false);
});
test('批次护栏:未来日期压到今天,超过 30 天压到下限', () => {
const future = registry.resolveBatch('2026-12-01', at(21, 10, 0));
assert.equal(future.verifyDate, '2026-10-21');
const tooOld = registry.resolveBatch('2026-01-01', at(21, 10, 0));
assert.equal(tooOld.verifyDate, '2026-09-22', `回看下限应为今天-29天,实际 ${tooOld.verifyDate}`);
});
// ── ③ 异常判定 ──────────────────────────────────────────────────────────
const batch = registry.resolveBatch('2026-10-21', at(21, 10, 0));
const svcPath = require.resolve('../src/services/warehouseLogisticsExceptionService');
const { classify } = require(svcPath);
test('已签收 = 正常;未揽收/无物流单/异常/拒收/其他 = 异常', () => {
const row = (o) => ({ logistic_id: 'L', logistic_status_name: '', logistic_time: null, ...o });
assert.equal(classify(row({ logistic_status_name: '已签收' }), batch), null);
assert.equal(classify(row({ logistic_status_name: '未揽收' }), batch), 'NOT_PICKED');
assert.equal(classify(row({ logistic_status_name: '无物流单' }), batch), 'NO_TRACKING');
assert.equal(classify(row({ logistic_status_name: '异常' }), batch), 'CARRIER_EXCEPTION');
assert.equal(classify(row({ logistic_status_name: '拒收' }), batch), 'RETURNED');
// 「其他」实测全是「包裹已换单退回」
assert.equal(classify(row({ logistic_status_name: '其他' }), batch), 'RETURNED');
});
test('运输中 / 派送中一律不考核(有「运输」字样,往后中转记录不看)', () => {
// 2026-10-21 二次定版:业务明确「有『运输』字样的,往后中转记录不考核」
assert.equal(classify({
logistic_id: 'L', logistic_status_name: '运输中', logistic_time: '2026-10-20 08:00:00',
}), null, '运输中哪怕 25h 没新轨迹也不算异常');
assert.equal(classify({
logistic_id: 'L', logistic_status_name: '派送中', logistic_time: '2026-10-20 08:00:00',
}), null);
});
test('已揽收:没有下一步中转才算异常,轨迹里出现运输/中转字样就算动起来了', () => {
const picked = (info) => classify({
logistic_id: 'L', logistic_status_name: '已揽收', logistic_time: '2026-10-21 04:00:00',
last_info: info,
});
// 只有「已揽收/已收取快件/库存中」,没有任何中转 → 卡在网点
assert.equal(picked('京东快递 已收取快件'), 'PICKED_NO_TRANSIT');
assert.equal(picked('中通快运【简尚家居】已揽收'), 'PICKED_NO_TRANSIT');
assert.equal(picked(''), 'PICKED_NO_TRANSIT');
// 轨迹里已经有运输/中转/发往 → 有下一步中转记录,不考核
assert.equal(picked('快件已发往【常州中转部】'), null);
assert.equal(picked('运输中,预计明日送达'), null);
});
test('状态为空时按运单号区分:有号=未揽收,无号=无物流单', () => {
assert.equal(classify({ logistic_id: 'L1', logistic_status_name: '' }, batch), 'NOT_PICKED');
assert.equal(classify({ logistic_id: '', logistic_status_name: '' }, batch), 'NO_TRACKING');
});
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
const dbPath = require.resolve('../src/config/cloudDatabase');
function stubDb(rows) {
const calls = { times: 0, sql: [], params: [] };
const handler = async (sql, params) => {
calls.times += 1;
calls.sql.push(String(sql));
calls.params.push(params || {});
return rows;
};
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: { query: handler, querySlow: handler },
};
return calls;
}
const mk = (o) => ({
so_id: 'SO', inner_order_id: 1, shop_name: '测试店', warehouse_name: '简尚-崔桥工厂仓',
logistic_id: 'L1', logistic_company_name: '中通', logistic_status_name: '已签收',
send_time: '2026-10-20 10:00:00', logistic_time: '2026-10-20 18:00:00',
query_time: '2026-10-21 09:05:00', last_info: '', ...o,
});
const rows = [
// 1:一单两包裹 —— 一个未揽收、一个已签收 → 该单算异常,但只占 1 个「单」
mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1A', logistic_status_name: '未揽收', logistic_time: null }),
mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1B', logistic_status_name: '已签收' }),
// 2:山东青岗集仓唯品仓 · 异常
mk({ inner_order_id: 2, so_id: 'SO-2', warehouse_name: '简尚-山东青岗集仓唯品仓',
logistic_id: 'L2', logistic_status_name: '异常', logistic_time: '2026-10-20 20:00:00',
send_time: '2026-10-20 12:00:00' }),
// 3:横林二仓品牌仓 · 拒收
mk({ inner_order_id: 3, so_id: 'SO-3', warehouse_name: '简尚-横林二仓品牌仓',
logistic_id: 'L3', logistic_status_name: '拒收', logistic_time: '2026-10-21 07:00:00',
send_time: '2026-10-20 15:00:00' }),
// 4:京东云仓 —— 业务排除,不进任何主仓
mk({ inner_order_id: 4, so_id: 'SO-4', warehouse_name: '京仓-横林二仓京东云仓',
logistic_id: 'L4', logistic_status_name: '未揽收', logistic_time: null }),
// 5:代发仓 —— 不归属任何主仓
mk({ inner_order_id: 5, so_id: 'SO-5', warehouse_name: '工厂-山东菏泽森美代发仓',
logistic_id: 'L5', logistic_status_name: '未揽收', logistic_time: null }),
// 6:新苏北仓 · 已揽收后没有任何中转记录(最后轨迹 10-20 02:00)→ 已揽收未中转
mk({ inner_order_id: 6, so_id: 'SO-6', warehouse_name: '简尚-新苏北仓',
logistic_id: 'L6', logistic_status_name: '已揽收', logistic_time: '2026-10-20 02:00:00',
send_time: '2026-10-20 01:00:00', last_info: '京东快递 已收取快件' }),
// 7:睢宁仓 · 运输中 → 已动起来,不考核
mk({ inner_order_id: 7, so_id: 'SO-7', warehouse_name: '简尚-睢宁仓',
logistic_id: 'L7', logistic_status_name: '运输中', logistic_time: '2026-10-21 04:00:00',
send_time: '2026-10-20 09:00:00' }),
// 8:山东青岗集仓 · 无物流单
mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓',
logistic_id: '', logistic_status_name: '无物流单', logistic_time: null,
send_time: '2026-10-20 11:00:00' }),
// 9:横林三仓 · 已签收 → 正常
mk({ inner_order_id: 9, so_id: 'SO-9', warehouse_name: '简尚-横林三仓' }),
// 10:完全重复行(同单同运单号)→ 必须去重
mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓',
logistic_id: '', logistic_status_name: '无物流单', logistic_time: null,
send_time: '2026-10-20 11:00:00' }),
];
async function withStub(fn, stubRows = rows) {
const realDb = require.cache[dbPath];
const calls = stubDb(stubRows);
delete require.cache[svcPath];
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
try {
const svc = require(svcPath);
svc.clearLogisticsExceptionCache();
return await fn(svc, calls);
} finally {
if (realDb) require.cache[dbPath] = realDb;
else delete require.cache[dbPath];
delete require.cache[svcPath];
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
}
}
test('汇总按内部单号去重,明细按包裹保留;排除仓/代发仓不进统计', async () => {
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
// 归属主仓的单:1,2,3,6,7,8,9 = 7 单;包裹 8 个(1 号单两包)
assert.equal(data.summary.shippedOrders, 7);
assert.equal(data.summary.packageCount, 8, '重复行要去重');
// 异常单:1(未揽收) 2(异常) 3(拒收) 6(已揽收未中转) 8(无物流单) = 5
assert.equal(data.summary.exceptionOrders, 5);
assert.equal(data.summary.exceptionPackages, 5);
assert.equal(data.summary.centerCount, 5);
assert.equal(data.summary.exceptionRate, 71.4);
const typeCount = data.summary.byType;
assert.equal(typeCount.NOT_PICKED, 1);
assert.equal(typeCount.CARRIER_EXCEPTION, 1);
assert.equal(typeCount.RETURNED, 1);
assert.equal(typeCount.PICKED_NO_TRANSIT, 1);
assert.equal(typeCount.NO_TRACKING, 1);
// 唯品仓独立成仓,不并进青岗集仓
const vip = data.centers.find((c) => c.key === 'SD_QGJ_VIP');
assert.equal(vip.shippedOrders, 1);
assert.equal(vip.exceptionOrders, 1);
const qgj = data.centers.find((c) => c.key === 'SD_QGJ');
assert.equal(qgj.exceptionOrders, 1, '青岗集仓自己只有 1 单异常(无物流单那单)');
// 排除仓 / 代发仓不该出现在任何仓的统计里
assert.equal(data.centers.reduce((s, c) => s + c.shippedOrders, 0), 7);
// 明细按「发货→核验」等得最久的排前面(6 号单 10-20 01:00 发货,到 09:00 已 32 小时)
assert.equal(data.orders[0].oId, '6', `首行应为最久未动的单,实际 ${data.orders[0].oId}`);
assert.equal(data.orders[0].pendingHours, 32);
assert.equal(data.orders[0].staleHours, 31, '最后轨迹 10-20 02:00 → 距核验 31 小时');
// 数据源护栏:只查一张表、带发货日窗口
assert.equal(data.freshness.coversDeadline, true);
assert.equal(data.freshness.snapshotAt, '2026-10-21 09:05:00');
});
});
test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async () => {
await withStub(async (svc, calls) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
assert.equal(data.notStarted, true);
assert.equal(data.summary.exceptionOrders, 0);
assert.equal(calls.times, 0, '未到点不该打库');
assert.equal(data.centers.length, 12);
});
});
test('结果缓存生效,force=1 绕过缓存重查', async () => {
await withStub(async (svc, calls) => {
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
const first = await svc.getLogisticsExceptions(opts);
assert.equal(first.cached, false);
assert.equal(calls.times, 1);
const second = await svc.getLogisticsExceptions(opts);
assert.equal(second.cached, true);
assert.equal(calls.times, 1, '第二次应命中缓存');
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
assert.equal(third.cached, false);
assert.equal(calls.times, 2, 'force=1 必须真查库');
});
});
test('明细 limit 生效并给出截断标记', async () => {
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 2 });
assert.equal(data.orders.length, 2);
assert.equal(data.truncated, true);
assert.equal(data.detailLimit, 2);
});
});
test('导出:放开明细上限、返回全量异常(不受列表 2000 上限限制)', async () => {
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptionExportRows({
date: '2026-10-21',
now: at(21, 10, 0),
});
assert.equal(data.detailLimit, 5000, '导出的明细上限应放到 5000');
assert.equal(data.truncated, false);
assert.equal(data.rows.length, data.summary.exceptionPackages, '导出行数应等于异常包裹数');
assert.ok(data.batch?.label, '导出要带批次信息给文件名用');
assert.ok(Array.isArray(data.exclusions?.carriers), '导出也要带剔除口径');
// 明细字段够导出用(页面上那几列)
const row = data.rows[0];
for (const key of ['warehouseName', 'oId', 'soId', 'carrier', 'trackingNo',
'logisticStatus', 'exceptionLabel', 'sendTime', 'pendingHours']) {
assert.ok(key in row, `导出缺字段 ${key}`);
}
});
});
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => {
await withStub(async (svc, calls) => {
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(calls.times, 1);
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
await svc.getLogisticsExceptionExportRows(opts);
assert.equal(calls.times, 2, '导出是首次查,应真查库');
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
});
});
test('快照没覆盖核验时刻时给出红色告警文案', async () => {
const staleRows = rows.map((r) => ({ ...r, query_time: '2026-10-21 06:10:00' }));
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0) });
assert.equal(data.freshness.coversDeadline, false);
assert.match(data.freshness.hint, /没覆盖/u);
}, staleRows);
});
// ── ⑥ 剔除口径(业务 2026-10-21 指定)───────────────────────────────────
test('不参与考核的承运商:顺丰系全剔 + 武进安能/中铁/壹米滴答/自提/线下/商家配送/京喜京东', () => {
const hit = (name) => registry.matchExcludedCarrier(name);
for (const name of [
'唯品顺丰',
'武进安能',
'武进安能大件',
'中铁物流',
'壹米滴答专线',
'自提',
'乐鑫达线下韵达配件',
'商家配送',
// 顺丰系:业务 2026-10-22「涉及到顺丰的快递全部剔除」,账号一个不漏
'顺丰速运',
'顺丰标快',
'洛宁顺丰干配',
'山东顺丰干配',
'苏北顺丰干配大件',
'苏北顺丰干配小件',
'山东顺丰大件',
'景瓴十方顺丰大件-莒南',
// 京东系只有京喜自营那条线剔(10-22 曾要求全剔,业务随即撤回)
'京喜京东快递',
'京喜京东快运',
]) {
assert.ok(hit(name), `${name} 应剔除,实际未命中`);
}
// 「唯品顺丰」要返回更具体的原因(页脚能看出是唯品那条线),但同样是剔除
assert.equal(hit('唯品顺丰'), '唯品顺丰');
assert.equal(hit('山东顺丰大件'), '顺丰');
assert.equal(hit('京喜京东快递'), '京喜京东');
// 🔥 别把「京喜京东」放大成「京东」:这些京东系账号要正常考核
assert.equal(hit('京东快递'), null);
assert.equal(hit('京东自营快递'), null);
assert.equal(hit('京东自营快递亿家达'), null);
assert.equal(hit('京东自营快递朴美'), null);
assert.equal(hit('崔桥京东特惠'), null);
assert.equal(hit('曹县京东快运'), null);
// 非顺丰/非京东的常规承运商不能误剔
assert.equal(hit('常州中通快运'), null);
assert.equal(hit('常州德邦'), null);
assert.equal(hit('百世快运'), null);
assert.equal(hit('睢宁德邦'), null);
assert.equal(hit('曹县德邦小件'), null);
});
test('🔥 睢宁中通是条件剔除:睢宁仓照常考核,其它仓异地打单的剔', () => {
const hit = (name, centerKey) => registry.matchExcludedCarrier(name, centerKey);
// 睢宁仓自己用 → 正常
assert.equal(hit('睢宁中通', 'SN'), null);
// 异地打单(实测山东品牌仓 137 单未揽收、河南洛宁 20 单、崔桥工厂 6 单)→ 剔
assert.ok(hit('睢宁中通', 'SD_BRAND'));
assert.ok(hit('睢宁中通', 'HN_LN'));
assert.ok(hit('睢宁中通', 'CQ'));
assert.ok(hit('睢宁中通', 'HL2'));
// 没给仓(保守)→ 不剔,宁可少剔也不能误剔睢宁仓
assert.equal(hit('睢宁中通'), null);
// 别把「常州中通快运」等其它中通账号连带剔掉
assert.equal(hit('常州中通快运', 'SD_BRAND'), null);
});
test('🔥 「武进安能」不能泛化成「安能」:常州安能新要正常考核', () => {
// 12 仓实测「常州安能新」3383 单/30 天,泛化「安能」会把整片正常单误剔
assert.equal(registry.matchExcludedCarrier('常州安能新'), null);
assert.equal(registry.matchExcludedCarrier('雅柏安能'), null);
assert.equal(registry.matchExcludedCarrier('常州中通快运'), null);
assert.equal(registry.matchExcludedCarrier(''), null);
assert.equal(registry.matchExcludedCarrier(null), null);
});
test('已取消 / 作废 / 拦截的订单剔除', () => {
const hit = (status, labels) => registry.matchVoidedOrder(status, labels);
assert.ok(hit('已取消', ''), '订单状态已取消要剔');
assert.ok(hit('已作废', ''));
assert.ok(hit('已发货', '["发货后取消","发货后售后"]'), '发货后取消要剔');
assert.ok(hit('已发货', '["留言备注拦截","禁止拆分"]'), '留言备注拦截要剔');
assert.ok(hit('已发货', '["作废"]'));
// 正常单不能被误剔
assert.equal(hit('已发货', '["店铺-拼多多","已成团"]'), null);
assert.equal(hit('已发货', ''), null);
// 「取消预售」是营销标签,不是取消,不能剔
assert.equal(hit('已发货', '["取消预售"]'), null);
});
test('剔除的单既不算异常,也不进发货分母', async () => {
const mixed = [
// 10:唯品顺丰 · 未揽收 → 本来会误报,必须整单消失
mk({ inner_order_id: 10, so_id: 'SO-10', logistic_company_name: '唯品顺丰',
logistic_status_name: '未揽收', logistic_time: null }),
// 11:中铁物流 · 异常
mk({ inner_order_id: 11, so_id: 'SO-11', logistic_company_name: '中铁物流',
logistic_status_name: '异常' }),
// 12:武进安能 · 已签收 → 连带分母也要消失
mk({ inner_order_id: 12, so_id: 'SO-12', logistic_company_name: '武进安能' }),
// 13:常州安能新 · 未揽收 → 必须留下(反例)
mk({ inner_order_id: 13, so_id: 'SO-13', logistic_company_name: '常州安能新',
logistic_status_name: '未揽收', logistic_time: null }),
// 14:正常承运商但订单已取消 → 剔
mk({ inner_order_id: 14, so_id: 'SO-14', logistic_company_name: '常州中通快运',
logistic_status_name: '无物流单', logistic_time: null,
order_status_name: '已取消' }),
// 15:正常承运商但发货后取消 → 剔
mk({ inner_order_id: 15, so_id: 'SO-15', logistic_company_name: '常州中通快运',
logistic_status_name: '未揽收', logistic_time: null,
order_labels: '["发货后取消","发货后售后"]' }),
// 16:正常单留作对照
mk({ inner_order_id: 16, so_id: 'SO-16', logistic_company_name: '常州中通快运' }),
// 17:京喜京东快递 · 已揽收未中转 → 京喜自营轨迹不回传,剔
mk({ inner_order_id: 17, so_id: 'SO-17', warehouse_name: '简尚-山东青岗集仓',
logistic_company_name: '京喜京东快递', logistic_status_name: '已揽收', logistic_time: '2026-10-20 20:00:00',
last_info: '快件已从【菏泽水岸城邦站】揽收完成' }),
// 18:睢宁中通在**非睢宁仓**(山东品牌仓)未揽收 → 异地打单,剔
mk({ inner_order_id: 18, so_id: 'SO-18', warehouse_name: '简尚-山东品牌仓',
logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }),
// 19:睢宁中通在**睢宁仓**未揽收 → 本仓本账号,照常考核(反例,必须留)
mk({ inner_order_id: 19, so_id: 'SO-19', warehouse_name: '简尚-睢宁仓',
logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }),
];
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
// 只剩 13、16、19 三单(10/11/12 承运商剔除,14/15 作废剔除,17 京喜、18 异地睢宁中通剔除)
assert.equal(data.summary.shippedOrders, 3, `分母应只剩 3 单,实际 ${data.summary.shippedOrders}`);
assert.equal(data.summary.exceptionOrders, 2, '只剩 13、19 两单异常');
assert.equal(data.summary.exceptionRate, 66.7);
assert.deepEqual(data.orders.map((o) => o.oId).sort(), ['13', '19']);
// 剔除量要如实返回,页面能展示
assert.equal(data.exclusions.carrierOrders, 5, '唯品顺丰/中铁/武进安能/京喜京东/异地睢宁中通 5 单');
assert.equal(data.exclusions.voidedOrders, 2, '已取消 + 发货后取消 2 单');
assert.equal(data.exclusions.carrierPackages, 5);
assert.equal(data.exclusions.voidedPackages, 2);
assert.ok(data.exclusions.carrierReasons['睢宁中通(非睢宁仓异地揽收)']);
assert.ok(data.exclusions.voidedReasons['订单已取消']);
assert.ok(data.exclusions.voidedReasons['标签含「发货后取消」']);
}, mixed);
});
test('刷单 / 信誉单剔除(按订单标签,不按「特殊单」)', () => {
assert.equal(registry.matchNonRealOrder('["刷单待处理","特殊单","店铺-拼多多"]'), '刷单');
assert.equal(registry.matchNonRealOrder('["特殊单","禁止拆分","转空包发货"]'), '信誉单(转空包)');
// 反例:「特殊单」本身混着补差价/配件单,不能当判据
assert.equal(registry.matchNonRealOrder('["特殊单","补差价"]'), null);
assert.equal(registry.matchNonRealOrder('["店铺-京东","晚发赔"]'), null);
assert.equal(registry.matchNonRealOrder(''), null);
assert.equal(registry.matchNonRealOrder(null), null);
});
test('刷单的包裹不进分母也不算异常', async () => {
const mixed = [
// 20:刷单 · 未揽收 → 假异常,必须消失
mk({ inner_order_id: 20, so_id: 'SO-20', logistic_status_name: '未揽收', logistic_time: null,
order_labels: '["刷单待处理","特殊单","店铺-拼多多"]' }),
// 21:信誉单(转空包)· 无物流单 → 消失
mk({ inner_order_id: 21, so_id: 'SO-21', logistic_status_name: '无物流单', logistic_time: null,
order_labels: '["特殊单","禁止拆分","转空包发货"]' }),
// 22:带「特殊单」但不是刷单/信誉单 → 必须留下
mk({ inner_order_id: 22, so_id: 'SO-22', logistic_status_name: '未揽收', logistic_time: null,
order_labels: '["特殊单","补差价"]' }),
];
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
assert.equal(data.summary.shippedOrders, 1, '只剩 22 号单');
assert.deepEqual(data.orders.map((o) => o.oId), ['22']);
assert.equal(data.exclusions.nonRealOrders, 2);
assert.equal(data.exclusions.nonRealReasons['刷单'], 1);
assert.equal(data.exclusions.nonRealReasons['信誉单(转空包)'], 1);
assert.deepEqual(data.exclusions.orderTypeLabels, ['刷单', '信誉单']);
}, mixed);
});
test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
await withStub(async (svc) => {
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
assert.equal(data.notStarted, true);
assert.ok(Array.isArray(data.exclusions.carriers));
assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
assert.equal(data.exclusions.carrierOrders, 0);
});
});

View File

@ -1,394 +0,0 @@
'use strict';
/**
* 「仓储中心物流转运异常」每天推文本到钉钉群 —— 正文组装与通道回归测试
* ────────────────────────────────────────────────────────────────────────────
* 这里只测四件容易悄悄坏掉的事:
* ① 群消息正文的数字与明细(业务照着这条消息干活,数字错了没人能发现);
* ② 0 条 / 明细条数上限 / 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
* ③ webhook 地址与加签(sign 算错就是 310000,肉眼看不出来);
* ④ 钉钉报错必须原样透出 errcode/errmsg(业务排错全靠这个码)。
* 不测数据库与真实网络:settings / job 的 DB 分支在接口层验证。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
const job = require('../src/jobs/warehouseLogisticsExceptionNotifyJob');
const sender = require('../src/services/dingtalkRobotWebhookSender');
const settings = require('../src/services/warehouseLogisticsExceptionNotifySettings');
function sampleData(overrides = {}) {
return {
batch: {
label: '10-22 09:00 核验 · 10-21 发货',
verifyDate: '2026-10-22',
shipDate: '2026-10-21',
deadline: '2026-10-22 09:00:00',
},
summary: {
shippedOrders: 9886,
packageCount: 9886,
exceptionOrders: 143,
exceptionPackages: 144,
exceptionRate: 1.4,
centerCount: 9,
byType: { PICKED_NO_TRANSIT: 137, NOT_PICKED: 7 },
},
centers: [
{ key: 'sn', name: '睢宁仓', shippedOrders: 1200, exceptionOrders: 1, exceptionRate: 0.1 },
{ key: 'cq', name: '崔桥工厂仓', shippedOrders: 3000, exceptionOrders: 2, exceptionRate: 0.1 },
{ key: 'hl', name: '横林二仓', shippedOrders: 800, exceptionOrders: 0, exceptionRate: 0 },
],
types: [
{ key: 'PICKED_NO_TRANSIT', label: '已揽收未中转', desc: '揽收后没有下一步中转记录' },
{ key: 'NOT_PICKED', label: '未揽收', desc: '快递还没来揽' },
],
exclusions: {
carriers: ['唯品顺丰', '顺丰', '京喜京东'],
conditionalCarriers: [{ keyword: '睢宁中通', keepCenters: ['睢宁仓'] }],
orderStatusKeywords: ['取消', '作废'],
orderLabelKeywords: ['发货后取消'],
orderTypeLabels: ['刷单', '转空包'],
carrierPackages: 12,
voidedPackages: 3,
nonRealPackages: 5,
},
rows: [
{
centerKey: 'cq',
warehouseName: '崔桥工厂仓',
oId: '9845001',
soId: '3318',
shopName: '京东自营',
carrier: '京东快递',
trackingNo: 'JD1',
logisticStatus: '已揽收',
exceptionLabel: '已揽收未中转',
exceptionDesc: '揽收后没有下一步中转记录',
sendTime: '2026-10-21 16:00:00',
lastLogisticTime: '2026-10-21 21:00:00',
pendingHours: 17,
lastLogisticInfo: '已揽收',
},
{
centerKey: 'sn',
warehouseName: '睢宁仓',
oId: '9844248',
soId: '3316',
shopName: '天猫旗舰店',
carrier: '睢宁德邦',
trackingNo: '',
logisticStatus: '',
exceptionLabel: '未揽收',
exceptionDesc: '快递还没来揽',
sendTime: '2026-10-21 14:22:10',
lastLogisticTime: '',
pendingHours: null,
lastLogisticInfo: '',
},
],
...overrides,
};
}
test('群消息正文:抬头带「异常」关键词、数字与分类型、按仓只列有异常的仓', () => {
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30, pageUrl: 'https://ck.yydd.ai:8443/p' });
const text = built.text;
// 钉钉「自定义关键词」安全设置一般配「异常」,抬头必须含这两个字
assert.match(text, /【仓储物流转运异常】/);
assert.match(text, /10-22 09:00 核验 · 10-21 发货/);
assert.match(text, /已发货 9886 单 \/ 9886 个包裹,异常 144 个包裹(1\.4%)/);
// 分类型按数量降序,且必须是可读标签(不是 PICKED_NO_TRANSIT 这种 key)
assert.match(text, /类型:已揽收未中转 137 · 未揽收 7/);
// 剔除数量 = 12 + 3 + 5
assert.match(text, /已剔除 20 个包裹/);
// 按仓只列有异常的:睢宁 1、崔桥 1;横林二仓 0 条不能出现
assert.match(text, /按仓(异常包裹):/);
assert.match(text, /睢宁仓 1 个(发货 1200 单)/);
assert.match(text, /崔桥工厂仓 1 个(发货 3000 单)/);
assert.ok(!text.includes('横林二仓'), '没有异常的仓不该占行');
// 页码链接带核验日
assert.match(text, /查看全部 \/ 导出 Excel:https:\/\/ck\.yydd\.ai:8443\/p\?date=2026-10-22/);
assert.equal(built.rowCount, 144);
assert.equal(built.listedCount, 2);
assert.equal(built.truncated, false);
});
test('明细行:仓|内部单号|快递|异常类型|发货时间|待处理,字段里换行要压平', () => {
const line = job.buildDetailLine({
warehouseName: '崔桥工厂仓',
oId: '9845001',
carrier: '京东快递',
exceptionLabel: '已揽收未中转',
sendTime: '2026-10-21 16:00:00',
pendingHours: 17.25,
lastLogisticInfo: '第一行\n第二行',
}, 3);
assert.equal(line, '3. 崔桥工厂仓|9845001|京东快递|已揽收未中转|10-21 16:00|17.3h');
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
// 空值兜底:不能让消息里出现「undefined|null」
assert.equal(
job.buildDetailLine({}, 1),
'1. 未知仓|无单号|未知快递|异常|—|—'
);
});
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
const data = sampleData({
rows: [],
summary: {
...sampleData().summary,
exceptionOrders: 0,
exceptionPackages: 0,
exceptionRate: 0,
byType: {},
centerCount: 0,
},
exclusions: {},
});
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
assert.match(built.text, /本批无转运异常,全部正常/);
assert.match(built.text, /异常 0 个包裹(/);
assert.ok(!built.text.includes('明细前'), '0 条时不该出现明细标题');
assert.equal(built.rowCount, 0);
assert.equal(built.listedCount, 0);
});
test('明细条数受配置控制:0 = 只发汇总,超量只列前 N 条并提示剩余', () => {
const zero = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
assert.ok(!/明细前 \d+ 条/.test(zero.text), 'maxDetailRows=0 时不要明细块');
assert.match(zero.text, /明细共 144 条,消息里不放,请点下面链接查看/);
const one = job.buildNotifyText(sampleData(), { maxDetailRows: 1 });
assert.equal(one.listedCount, 1);
assert.ok(one.text.includes('1. 崔桥工厂仓'), '只列第一条');
assert.ok(!one.text.includes('2. 睢宁仓'), '第二条应被截掉');
assert.match(one.text, /其余 143 条见页面/);
});
test('明细太多时按字节精简:不超 20000 字节上限,且不切断半个汉字', () => {
// 造 60 条很长的明细(仓名 144 字 ≈ 432 字节/行),必然超过 text 上限
const longName = '崔桥工厂半成品包装项目仓'.repeat(12);
const rows = Array.from({ length: 60 }, (_item, index) => ({
centerKey: 'cq',
warehouseName: longName,
oId: `9845${String(index).padStart(3, '0')}`,
carrier: '中通快递',
exceptionLabel: '已揽收未中转',
sendTime: '2026-10-21 16:00:00',
pendingHours: 17,
}));
const data = sampleData({
rows,
summary: { ...sampleData().summary, exceptionPackages: rows.length, exceptionOrders: rows.length },
});
const built = job.buildNotifyText(data, { maxDetailRows: 50 });
assert.ok(built.bytes <= sender.MAX_CONTENT_BYTES, `实得 ${built.bytes} 字节,超了上限`);
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, '应留出安全余量');
assert.equal(built.truncated, true, '应标记为已精简');
assert.ok(built.listedCount < 50, '应自动少列几条');
assert.ok(!built.text.includes('\uFFFD'), '不能出现被截断产生的乱码');
// 削掉明细后仍保留抬头和链接
assert.match(built.text, /【仓储物流转运异常】/);
assert.match(built.text, /查看全部/);
});
test('按字节截断工具:中文按 3 字节算,不切半个字', () => {
assert.deepEqual(sender.truncateBytes('abc', 10), { text: 'abc', truncated: false });
// 「中」= 3 字节:10 字节最多放 3 个字(9 字节)
const cut = sender.truncateBytes('中文字符', 10);
assert.equal(cut.text, '中文字');
assert.equal(cut.truncated, true);
assert.ok(Buffer.byteLength(cut.text, 'utf8') <= 10);
});
test('时区:容器是 UTC,推送时刻仍按北京时间判(09:10 不能变成 01:10)', () => {
// 2026-10-22T01:10Z = 北京时间 09:10
assert.equal(job.chinaHourMinute(new Date('2026-10-22T01:10:00Z')), '09:10');
assert.equal(job.chinaDateKey(new Date('2026-10-22T01:10:00Z')), '2026-10-22');
// 北京时间 00:30(UTC 前一天 16:30)→ 日期必须是新的一天
assert.equal(job.chinaDateKey(new Date('2026-10-21T16:30:00Z')), '2026-10-22');
assert.equal(job.chinaHourMinute(new Date('2026-10-21T16:30:00Z')), '00:30');
});
test('推送时间 / 明细条数:非法输入一律退回默认值,不能把定时任务配挂', () => {
assert.equal(settings.normalizeSendTime('08:05'), '08:05');
assert.equal(settings.normalizeSendTime('9:10'), '09:10');
assert.equal(settings.normalizeSendTime('25:00'), '09:10');
assert.equal(settings.normalizeSendTime(''), '09:10');
assert.equal(settings.normalizeSendTime(undefined), '09:10');
assert.equal(settings.normalizeMaxDetailRows(undefined), 30);
assert.equal(settings.normalizeMaxDetailRows(-5), 0);
assert.equal(settings.normalizeMaxDetailRows(999), 50);
assert.equal(settings.normalizeMaxDetailRows('12'), 12);
assert.equal(settings.normalizeMaxDetailRows('abc'), 30);
});
test('配置校验:只要有 Webhook 就算配好;加签密钥只回掩码', () => {
assert.deepEqual(settings.missingFields({ webhookUrl: '' }), ['Webhook 地址']);
assert.deepEqual(settings.missingFields({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }), []);
assert.equal(settings.isWarehouseLogisticsExceptionNotifyReady({}), false);
assert.equal(
settings.isWarehouseLogisticsExceptionNotifyReady({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }),
true,
'安全设置用「关键词」时不需要加签密钥'
);
const admin = settings.toAdminConfig({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t',
webhookSecret: 'SEC0123456789abcdef',
enabled: true,
});
assert.equal(admin.webhookSecretConfigured, true);
assert.ok(!JSON.stringify(admin).includes('SEC0123456789abcdef'), '明文加签密钥不能出现在返回体里');
assert.match(admin.webhookSecretPreview, /\*+/);
// Webhook 要完整回显:业务需要核对 access_token
assert.match(admin.webhookUrl, /access_token=t/);
});
test('Webhook 地址校验:必须是 robot/send 且带 access_token', () => {
const ok = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
assert.equal(sender.normalizeWebhookUrl(ok), ok);
assert.throws(() => sender.normalizeWebhookUrl(''), /请填写/);
assert.throws(() => sender.normalizeWebhookUrl('oapi.dingtalk.com/robot/send'), /不是合法 URL/);
// 常见的复制错误:贴成了企业内部应用的地址 / 少复制了路径
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/robot/send'), /缺少 access_token/);
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/topapi/robot/send?access_token=a'), /不像群机器人/);
});
test('加签:sign = urlEncode(base64(HmacSHA256(secret, `${ts}\\n${secret}`)))', () => {
const webhook = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
const secret = 'SECtestsecret';
const ts = 1700000000000;
const signed = new URL(sender.buildSignedUrl(webhook, secret, ts));
assert.equal(signed.searchParams.get('timestamp'), String(ts));
assert.equal(signed.searchParams.get('access_token'), 'abc123', '原参数要保留');
// 手工算一遍(顺序写错就是 310000,这条断言就是防这个)
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}\n${secret}`, 'utf8')
.digest('base64');
// searchParams 会自动 decode,所以这里拿到的是原始 base64(含 + / =)
assert.equal(signed.searchParams.get('sign'), expected);
// 没配密钥(安全设置用关键词/IP):只追加 timestamp,不带 sign
const plain = new URL(sender.buildSignedUrl(webhook, '', ts));
assert.equal(plain.searchParams.get('sign'), null);
assert.equal(plain.searchParams.get('timestamp'), String(ts));
});
// ── 钉钉通道:stub fetch 验请求形状与错误透传 ────────────────────────────────
function stubFetch(handler) {
const calls = [];
const original = global.fetch;
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), options });
const body = handler.body === undefined ? {} : handler.body;
return new Response(JSON.stringify(body), { status: handler.status || 200 });
};
return {
calls,
restore: () => { global.fetch = original; },
};
}
test('发文本:msgtype=text,加签参数拼在 URL 上,@所有人开关透传', async () => {
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
try {
const result = await sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=abc123',
secret: 'SECtestsecret',
content: '【仓储物流转运异常】测试',
atAll: true,
});
assert.equal(result.delivered, true);
assert.equal(result.errmsg, 'ok');
const call = stub.calls[0];
assert.equal(call.options.method, 'POST');
assert.match(call.options.headers['Content-Type'], /application\/json/);
// 加签后 URL 必须带 timestamp + sign
const url = new URL(call.url);
assert.ok(url.searchParams.get('timestamp'), '缺少 timestamp');
assert.ok(url.searchParams.get('sign'), '缺少 sign');
const body = JSON.parse(call.options.body);
assert.equal(body.msgtype, 'text');
assert.equal(body.text.content, '【仓储物流转运异常】测试');
assert.equal(body.at.isAtAll, true);
assert.deepEqual(body.at.atMobiles, []);
} finally {
stub.restore();
}
});
test('发文本:空内容不发请求(避免钉钉 400 把错误码埋掉)', async () => {
let touched = 0;
const original = global.fetch;
global.fetch = async () => { touched += 1; throw new Error('不应该发请求'); };
try {
await assert.rejects(
() => sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
content: ' ',
}),
/内容为空/
);
assert.equal(touched, 0);
} finally {
global.fetch = original;
}
});
test('发文本:钉钉 errcode 原样透出(310000 关键词/加签不匹配是最常见的坑)', async () => {
const stub = stubFetch({ body: { errcode: 310000, errmsg: 'sign not match' } });
try {
await assert.rejects(
() => sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
secret: 'SECwrong',
content: '【仓储物流转运异常】测试',
}),
(error) => {
assert.equal(error.code, 310000);
assert.match(error.message, /310000/);
assert.match(error.message, /sign not match/);
return true;
}
);
} finally {
stub.restore();
}
});
test('发文本:超长正文自动截断后仍会发出去(不是直接抛错)', async () => {
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
try {
const huge = '异常'.repeat(12000); // 24000 字 ≈ 72000 字节,远超上限
const result = await sender.sendTextToWebhook({
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
content: huge,
});
assert.equal(result.truncated, true);
assert.ok(result.bytes <= sender.MAX_CONTENT_BYTES);
const body = JSON.parse(stub.calls[0].options.body);
assert.ok(Buffer.byteLength(body.text.content, 'utf8') <= sender.MAX_CONTENT_BYTES);
} finally {
stub.restore();
}
});

View File

@ -1,667 +0,0 @@
/**
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版)
*
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义:
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走;
* ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、
* 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段;
* ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)——
* 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错;
* ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**:
* 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。
* ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做,
* 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废;
* ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。
* 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回;
* ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃;
* **作废单(已取消 / 合并 / 拆单父单)不考核**;
* ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。
*
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const registry = require('../src/services/warehouseShipTimelinessRegistry');
// ── ① 主仓归属:最长匹配 ────────────────────────────────────────────────
test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => {
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ');
assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND');
assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ');
});
test('resolveCenter 能捞到各种命名的子仓', () => {
const cases = [
['简尚-运营C横林二仓专用仓', 'HL2'],
['简尚-横林二仓京东仓', 'HL2'],
['京仓-横林二仓京东自营仓', 'HL2'],
['简尚-崔桥工厂仓唯品仓', 'CQ'],
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
['简尚-新苏北临时仓', 'XSB'],
['简尚-分销部睢宁专用仓', 'SN'],
['京仓-山东青岗集京东仓储', 'SD_QGJ'],
['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'],
['简尚-河南洛宁仓', 'HN_LN'],
];
for (const [name, expected] of cases) {
assert.equal(registry.resolveCenter(name)?.key, expected, name);
}
});
test('「京仓-横林二仓京东云仓」业务指定排除,不计入横林二仓', () => {
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null);
// ⚠️ 不能连带排掉名字相近的仓 —— 关键词必须写完整片段
assert.equal(registry.resolveCenter('简尚-横林二仓京东仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('京仓-横林二仓京东自营仓')?.key, 'HL2');
assert.equal(registry.resolveCenter('京仓-崔桥工厂仓京东云仓')?.key, 'CQ');
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
});
test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
const shouldBeNull = [
'工厂-山东菏泽森美代发仓',
'工厂-山东恒思代发仓',
'工厂-河南千优代发仓',
'工厂-河北霸州派雅世佳代发仓',
'简尚-苏州福利品仓',
];
for (const name of shouldBeNull) {
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
}
});
test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => {
const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP');
assert.ok(c, 'CQ_WIP 必须存在');
assert.equal(c.placeholder, undefined, '已接入真实仓,不能再是占位仓');
assert.equal(c.name, '崔桥工厂半成品包装项目仓');
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓')?.key, 'CQ_WIP');
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓')?.key, 'CQ');
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓')?.key, 'CQ_BRAND');
});
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
test('13:00 前不做核验,返回 null(不回填昨日数据)', () => {
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
assert.equal(registry.resolveActiveRound(at(12, 59)), null);
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
});
test('13:00~17:59 用 13:00 轮,窗口是「截止今日 07:00 前创建(含历史)」', () => {
for (const [h, m] of [[13, 0], [15, 30], [17, 59]]) {
const r = registry.resolveActiveRound(at(h, m));
assert.ok(r, `${h}:${m} 应已进入首轮核验`);
assert.equal(r.key, 'NOON');
// 核验时刻 = 13:00(判定「发了没发 / 超时多久」用它)
assert.equal(r.deadline, '2026-10-20 13:00:00');
assert.equal(r.verifyHour, 13);
// 订单窗口上界 = 07:00(只核验该时刻前创建的单)
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
assert.notEqual(r.deadline, r.windowEnd, '核验时刻与窗口上界必须是两个值');
assert.equal(r.includeHistory, true);
assert.notEqual(r.windowStart, '2026-10-20 00:00:00', '下界不能卡在当天 00:00');
assert.equal(r.windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
}
});
test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => {
const r = registry.resolveActiveRound(at(18, 0));
assert.equal(r.key, 'AFTERNOON');
assert.equal(r.deadline, '2026-10-20 18:00:00');
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
});
test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于「等 18:00 轮」)', () => {
const rounds = registry.describeRounds(at(16, 5));
assert.deepEqual(
rounds.map((r) => [r.key, r.deadline.slice(11, 16), r.windowEnd.slice(11, 16)]),
[['NOON', '13:00', '07:00'], ['AFTERNOON', '18:00', '16:00']]
);
assert.equal(rounds[0].active, true);
assert.equal(rounds[1].active, false, '16:05 还没到 18:00 轮');
assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON');
});
test('审核晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => {
const [noon, afternoon] = registry.describeRounds(at(16, 5));
assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00');
assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00',
'18:00 是当天最后一轮 → 次日 13:00');
// 跨月边界也要对
assert.equal(
registry.nextRoundDeadline({ key: 'AFTERNOON', date: '2026-10-31' }),
'2026-11-01 13:00:00'
);
});
// ── ③ 常量口径 ──────────────────────────────────────────────────────────
test('数据源必须是聚水潭实时状态表,不是订单表', () => {
assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details');
});
test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => {
assert.equal(registry.SHIPPING_STATUS, '发货中');
assert.equal(registry.UNAUDITED_STATUS, '已支付待审核');
assert.equal(registry.ABNORMAL_STATUS, '异常');
// 页面/路由仍叫「24 小时发货时效」,但判定是 13:00/18:00 到点即超时
assert.equal(registry.SHIP_TIMELINESS_HOURS, 24);
});
test('作废单(已取消)明确不考核', () => {
assert.ok(
registry.IGNORED_STATES.includes('已取消'),
'「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍'
);
assert.ok(registry.IGNORED_STATES.includes('已发货'));
assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货'));
});
test('创建时间要从订单表补,且只补窄列', () => {
assert.deepEqual(
[...registry.CREATED_SOURCE_TABLES],
['jst_Orders', 'jst_qmcust_orderlist'],
'两张订单表都要查(店铺互斥,交集为 0)'
);
assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '必须分批,不能一次 IN 上万个');
});
// ── ④ 服务层分桶(注入假查询,不连库)──
function stubDb(dbPath, scenario = 'default') {
const wmsId = 15038426;
// 创建时间(展示)+ 作废事实来源:o_id → 订单表行
// 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间
const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o });
const defaultFacts = {
1: f('jst_Orders', { created: '2026-09-25 00:50:00' }),
2: f('jst_Orders', { created: '2026-10-20 16:20:00' }),
6: f('jst_Orders', { created: '2026-10-20 17:00:00' }),
7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }),
9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }),
// 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例
// 5127276315127080023(内部单号 9486414)就是这样被误判成超时的
10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }),
// 11:状态表 send_time 空,订单表 send_date 有值(已发)
11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }),
// 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货)
// —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」
37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }),
// 12:拆单父单,status 不是 Split 但 is_split=1
12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }),
13: f('jst_Orders', { created: '2026-10-20 15:00:00' }),
// 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮)
31: f('jst_Orders', { created: '2026-10-20 15:30:00' }),
32: f('jst_Orders', { created: '2026-10-20 14:30:00' }),
33: f('jst_Orders', { created: '2026-10-20 13:30:00' }),
34: f('jst_Orders', { created: '2026-10-20 12:30:00' }),
// 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476)
// 只能进 18:00 轮,不能进 13:00 轮
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
};
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
const noonFacts = {
21: f('jst_Orders', { created: '2026-10-20 06:45:00' }),
22: f('jst_Orders', { created: '2026-10-20 05:55:00' }),
23: f('jst_Orders', { created: '2026-10-19 19:55:00' }),
24: f('jst_Orders', { created: '2026-10-20 07:30:00' }),
// 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
// 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
};
const factsMap = scenario === 'noon' ? noonFacts : defaultFacts;
const mk = (o) => ({
shop_name: '测试店',
logistic_status_name: '',
query_time: '2026-10-20 11:18:27',
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
...o,
});
const defaultRows = [
// 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
last_oa_confirm_time: '2026-09-25 01:00:00' }),
// 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间)
mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 16:10:00',
last_oa_confirm_time: '2026-10-20 16:30:00' }),
// 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时
mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-26 00:10:00',
last_oa_confirm_time: '2026-09-26 00:15:00' }),
// 4:已支付待审核(还没审单、没有审核时间)→ 不计考核
mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00',
last_oa_confirm_time: null }),
// 5:异常 → 不计考核
mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00',
last_oa_confirm_time: '2026-09-23 01:05:00' }),
// 6:审核 17:05 晚于窗口上界 → 本轮不该核验
mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 16:55:00',
last_oa_confirm_time: '2026-10-20 17:05:00' }),
// 7:被业务排除的京东云仓 → 不归属任何主仓
mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓',
order_status_name: '发货中', pay_time: '2026-09-25 00:00:00',
last_oa_confirm_time: '2026-09-25 00:10:00' }),
// 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
last_oa_confirm_time: '2026-09-25 01:00:00' }),
// 9:作废单(已取消)—— 业务定性不考核
mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '已取消', pay_time: '2026-09-21 08:00:00',
last_oa_confirm_time: '2026-09-21 08:10:00' }),
// 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除
mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 09:59:00',
last_oa_confirm_time: '2026-09-21 10:05:00' }),
// 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除
mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 10:59:00',
last_oa_confirm_time: '2026-09-21 11:05:00' }),
// 12:拆单父单(is_split=1)—— 自己不发货,必须剔除
mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-21 11:59:00',
last_oa_confirm_time: '2026-09-21 12:05:00' }),
// 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉,
// 本版必须算超时(业务口径:到点未发就算超时)
mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 14:59:00',
last_oa_confirm_time: '2026-10-20 15:30:00' }),
// ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ──
// 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验
mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓',
order_status_name: '发货中', pay_time: '2026-10-20 15:29:00',
last_oa_confirm_time: '2026-10-20 15:35:00' }),
// 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时
mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓',
order_status_name: '发货中', pay_time: '2026-10-20 14:29:00',
last_oa_confirm_time: '2026-10-20 14:35:00' }),
// 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验
mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓',
order_status_name: '发货中', pay_time: '2026-10-20 13:29:00',
last_oa_confirm_time: '2026-10-20 13:35:00' }),
// 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时
mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓',
order_status_name: '发货中', pay_time: '2026-10-20 12:29:00',
last_oa_confirm_time: '2026-10-20 12:35:00' }),
// 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。
// 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
first_oa_confirm_time: '2026-10-20 13:10:00',
last_oa_confirm_time: '2026-10-20 13:10:00' }),
// 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10)
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
first_oa_confirm_time: '2026-10-19 10:10:00',
last_oa_confirm_time: '2026-10-20 13:30:00' }),
// 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。
// 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped
mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-25 01:00:00',
last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }),
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
last_oa_confirm_time: '2026-09-22 09:05:00' }),
];
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
const noonRows = [
// 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分
mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 06:40:00',
last_oa_confirm_time: '2026-10-20 07:20:00' }),
// 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛)
mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 05:50:00',
last_oa_confirm_time: '2026-10-20 06:40:00' }),
// 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到
mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 19:50:00',
last_oa_confirm_time: '2026-10-19 20:10:00' }),
// 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间)
mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 07:29:00',
last_oa_confirm_time: '2026-10-20 07:40:00' }),
// 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。
// 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
first_oa_confirm_time: '2026-10-19 10:10:00',
last_oa_confirm_time: '2026-10-20 13:30:00' }),
// 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算,
// 不能显示 0 分(真实案例:创建 10-18 的单今天才审)
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
first_oa_confirm_time: '2026-10-20 13:10:00',
last_oa_confirm_time: '2026-10-20 13:10:00' }),
];
const rows = scenario === 'noon' ? noonRows : defaultRows;
const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] };
const handler = async (sql, params) => {
const text = String(sql);
if (/jst_warehouseInfo/u.test(text)) {
return [
{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId },
{ Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 },
{ Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 },
{ Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 },
];
}
// 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表
if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) {
calls.backfill += 1;
calls.backfillSql.push(text);
const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders';
const m = text.match(/IN \(([0-9,\s]+)\)/u);
const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : [];
return ids
.filter((id) => factsMap[id] && factsMap[id].table === table)
.map((id) => {
const x = factsMap[id];
return {
o_id: id,
created: x.created,
status: x.status,
send_date: x.send_date,
is_merge: x.is_merge,
is_split: x.is_split,
};
});
}
calls.state += 1;
calls.sql.push(text);
calls.params.push(params || {});
return rows;
};
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: { query: handler, querySlow: handler },
};
return { calls, wmsId };
}
function withStubbedService(fn, scenario = 'default') {
return async () => {
const dbPath = require.resolve('../src/config/cloudDatabase');
const realDb = require.cache[dbPath];
const ctx = stubDb(dbPath, scenario);
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
try {
const svc = require('../src/services/warehouseShipTimelinessService');
await fn(svc, ctx);
} finally {
if (realDb) require.cache[dbPath] = realDb;
else delete require.cache[dbPath];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
}
};
}
test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => {
// 18:05 → 18:00 轮:窗口上界 16:00(按审核时间),核验时刻 18:00
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
const c = data.centers.find((x) => x.key === 'CQ');
assert.ok(c, '应能归到崔桥工厂仓');
// 超时 = 1(09-25 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时)
// + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核)
// = 6 单
// 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发
// 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的
assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时');
assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天');
assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单');
assert.equal(data.summary.overdueUnshipped, 7);
assert.equal(data.summary.overdueCenterCount, 3);
assert.equal(data.summary.oldestDays, 25);
assert.equal(data.summary.heldCount, 2);
// 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示
assert.equal(data.orders.length, 7);
assert.equal(String(data.orders[0].oId), '1');
assert.equal(data.orders[0].auditTime, '2026-09-25 01:00:00', '基准是审核时间');
assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00', '创建时间只作展示');
assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00');
assert.equal(data.orders[0].benchmarkSource, 'last_audit');
// 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟
assert.equal(data.orders[0].overdueMinutes, 37020);
// 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算
const fresh = data.orders.find((o) => String(o.oId) === '13');
assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)');
assert.equal(fresh.overdueMinutes, 150);
assert.equal(fresh.waitedHours, 2.5);
// 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290
const lateAudit = data.orders.find((o) => String(o.oId) === '26');
assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)');
assert.equal(lateAudit.overdueMinutes, 290);
// 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10
const reAudited = data.orders.find((o) => String(o.oId) === '25');
assert.ok(reAudited, '重审单也要算');
assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核');
assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30');
// 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间
const third = data.orders.find((o) => String(o.oId) === '3');
assert.ok(third, '补不到创建时间的单也要算超时');
assert.equal(third.createdAt, '', '确实没补到');
assert.equal(third.benchmarkSource, 'last_audit');
assert.equal(third.payTime, '2026-09-26 00:10:00');
// 创建时间晚于窗口上界 → 本轮不核验(2 号:创建 16:20、6 号:创建 17:00)
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验');
// ── 仓级例外窗口(业务 2026-10-20:山东两仓 15:00、河南洛宁 13:00)──
assert.ok(!data.orders.some((o) => String(o.oId) === '31'),
'山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界,18:00 轮不核验');
assert.ok(data.orders.some((o) => String(o.oId) === '32'),
'山东品牌仓 14:30 创建 → 早于该仓 15:00 上界,要核验');
assert.ok(!data.orders.some((o) => String(o.oId) === '33'),
'河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界,18:00 轮不核验');
assert.ok(data.orders.some((o) => String(o.oId) === '34'),
'河南洛宁仓 12:30 创建 → 早于该仓 13:00 上界,要核验');
assert.equal(data.window.overrides.length, 3, '山东两仓 + 河南洛宁 三处例外');
assert.deepEqual(
data.window.overrides.map((o) => [o.key, o.hour]).sort(),
[['HN_LN', 13], ['SD_BRAND', 15], ['SD_QGJ', 15]]
);
// 例外只作用在 18:00 轮;13:00 轮没有例外(见下一个用例)
assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u);
// 被排除的仓不能算
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
for (const id of ['9', '10', '11', '12']) {
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
}
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
assert.ok(
!data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)),
'不考核快递的单不能出现在超时明细里'
);
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']);
// 不计考核清单
assert.equal(data.heldOrders.length, 2);
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
assert.deepEqual(reasons, ['待审核', '订单异常'].sort());
// 新鲜度直接取自主查询的 query_time,不再额外扫表
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27');
// 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新,
// 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。
assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟');
assert.equal(
data.freshness.stale,
data.freshness.ageMinutes > 90,
'滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)'
);
if (data.freshness.stale) {
assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示');
}
// 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」
assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false');
assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚');
// ── 性能护栏 ────────────────────────────────────────────────────────
assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)');
const sql = calls.sql[0];
assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表');
assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判');
// 本版基准是审核时间 → 两列审核时间都必须在投影里
assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列');
assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底');
// 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消)
assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数');
assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断');
assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用');
// 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s
assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列');
assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列');
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
for (const bs of calls.backfillSql) {
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
'多取一列就会把 0.2s 拖到 38s');
}
// ⚠️ 窗口参数走 params,不在 SQL 里手写
assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除');
assert.equal(calls.params[0].shipStatus, '发货中');
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
assert.equal(data.round.key, 'AFTERNOON');
assert.match(String(data.round.windowEnd), /16:00:00$/u);
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端');
assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算');
}));
test('13:00 轮:只核验「今天 00:00~07:00 审核」的单 + 历史未发', withStubbedService(async (svc) => {
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
assert.equal(data.round.key, 'NOON');
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
// 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验)
assert.ok(!data.orders.some((o) => String(o.oId) === '21'),
'今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现');
// 24 号:今天 07:40 审核 → 同理不核验
assert.ok(!data.orders.some((o) => String(o.oId) === '24'),
'今天 07:40 审核 → 不进 13:00 轮');
// 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮
assert.ok(!data.orders.some((o) => String(o.oId) === '26'),
'下午才审核的老单不能进 13:00 轮');
// 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛)
const b = data.orders.find((o) => String(o.oId) === '22');
assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时');
assert.equal(b.benchmarkSource, 'last_audit');
assert.equal(b.overdueMinutes, 380);
assert.equal(b.waitedHours, 6.3);
// 23 号:昨天审核、至今未发 → 历史未发池也要看到
const d = data.orders.find((o) => String(o.oId) === '23');
assert.ok(d, '含历史未发单(历史不受 00:00 起限制)');
assert.equal(d.overdueMinutes, 1010);
// 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验
assert.ok(!data.orders.some((o) => String(o.oId) === '25'),
'重审到 13:30 的单归 18:00 轮,不进 13:00 轮');
// 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单
assert.equal(data.summary.overdueUnshipped, 2);
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判');
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
}, 'noon'));
test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbedService(async (svc, { calls }) => {
const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 });
assert.equal(data.notStarted, true);
assert.equal(data.round, null);
assert.equal(data.orders.length, 0);
assert.equal(data.summary.overdueUnshipped, 0);
// 11 个主仓仍然列出来(结构不能塌),但数字全为 0
assert.equal(data.centers.length, registry.CENTERS.length);
assert.ok(data.centers.every((x) => x.overdueUnshipped === 0));
assert.equal(calls.state, 0, '未到核验时间不应查库');
assert.equal(calls.backfill, 0, '未到核验时间也不该回订单表');
assert.match(data.note, /13:00/u);
assert.equal(data.nextRoundAt, '2026-10-20 13:00:00');
assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00');
}));
// ── ⑧ 时区:生产容器是 UTC,库里时间是北京时间 ──────────────────────────
// 2026-10-21 实测:容器 /etc/timezone=Etc/UTC,mssql useUTC:false 把 GETDATE()
// 构造成「墙钟当 UTC」的 Date → 比真实 now 快 8 小时。北京时间 00:00~08:00
// 服务端取到的是「昨天」,13:00 轮因此把整个历史未发池算进来(3895 单 vs 本地 6 单)。
test('beijingNow:任何机器时区下读出来的本地时间都等于北京时间', () => {
// 真实时刻 2026-10-21T00:04:00Z = 北京时间 2026-10-21 08:04
const base = new Date('2026-10-21T00:04:00Z');
const bj = registry.beijingNow(base);
const stamp = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
+ `-${String(d.getDate()).padStart(2, '0')} `
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
assert.equal(stamp(bj), '2026-10-21 08:04', '平移后本地 getter 必须读出北京时间');
// 跨日场景:真实时刻 2026-10-20T23:48:00Z = 北京时间 2026-10-21 07:48(UTC 上还是「昨天」)
const midnight = registry.beijingNow(new Date('2026-10-20T23:48:00Z'));
assert.equal(stamp(midnight).slice(0, 10), '2026-10-21',
'北京时间 00:00~08:00 不能取成前一天');
});
test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器时区', () => {
// 北京时间 2026-10-21 07:48(UTC 上是 10-20 23:48)→ 还没到 13:00,应当 notStarted
const round = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-20T23:48:00Z')));
assert.equal(round, null, '北京时间 07:48 未到 13:00 首轮核验');
// 北京时间 2026-10-21 13:30 → 13:00 轮生效,窗口上界 07:00、核验 13:00
const noon = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-21T05:30:00Z')));
assert.equal(noon.key, 'NOON');
assert.equal(noon.date, '2026-10-21');
assert.equal(noon.deadline, '2026-10-21 13:00:00');
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
});

View File

@ -1,40 +0,0 @@
import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 缺货补货监控(拣货位缺货 → 从存货位补货)
// params.warehouse: changzhou / wh7 / suining / henglin2 / subei / henglin3(缺省 = 常州主仓)
export const getBinReplenishList = (params = {}) => request.get(
'/api/bin-replenish/list',
{ params, timeout: LONG_TIMEOUT_MS }
);
export const exportBinReplenish = (params = {}) => request.get(
'/api/bin-replenish/export',
{
params,
responseType: 'blob',
timeout: LONG_TIMEOUT_MS,
silentError: true,
}
);
export const getPickBinMeta = (warehouse) => request.get(
'/api/bin-replenish/pick-bins',
{ params: warehouse ? { warehouse } : {} }
);
export const importPickBins = (file, warehouse) => {
const formData = new FormData();
formData.append('file', file);
if (warehouse) formData.append('warehouse', warehouse);
return request.post('/api/bin-replenish/pick-bins', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: LONG_TIMEOUT_MS,
});
};
export const clearPickBins = (warehouse) => request.delete(
'/api/bin-replenish/pick-bins',
{ params: warehouse ? { warehouse } : {} }
);

View File

@ -1,16 +0,0 @@
import request from './request';
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
// 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级
// refresh=1 跳过后端 5 分钟缓存,强制重算
export const getPickingBinSalesAnalysis = (params = {}) => request.get(
'/api/picking-bin-sales-analysis',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 导出库位明细(xlsx/csv,服务端直接返回文件流)
export const exportPickingBinSalesAnalysis = (params = {}) => request.get(
'/api/picking-bin-sales-analysis/export',
{ params, responseType: 'blob', timeout: LIST_TIMEOUT_MS }
);

View File

@ -1,59 +0,0 @@
import request from './request';
// 转运异常要扫一遍「昨日发货」的物流宽表(rpt 下全是视图),冷启动实测 20 秒左右,
// 超时放大到 3 分钟,避免前端先断开、后端还在跑。
const TIMEOUT_MS = 3 * 60 * 1000;
// 仓储中心物流转运异常:12 个发货仓的异常单量 + 明细
// params.date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天)
// params.limit 明细条数上限
// params.force 1 = 绕过 5 分钟结果缓存
export const getWarehouseLogisticsExceptions = (params = {}) => request.get(
'/api/warehouse-logistics-exception/list',
{
params,
timeout: TIMEOUT_MS,
headers: { 'Cache-Control': 'no-cache' },
}
);
// 口径元数据(12 个仓、异常类型、回看天数),不打库,秒回
export const getWarehouseLogisticsExceptionMeta = () => request.get(
'/api/warehouse-logistics-exception/meta',
{ timeout: 30000 }
);
// 导出用:同一套口径、不分页(明细上限放到 5000),比列表更慢,超时再放大
export const getWarehouseLogisticsExceptionExportRows = (params = {}) => request.get(
'/api/warehouse-logistics-exception/export',
{
params,
timeout: 5 * 60 * 1000,
headers: { 'Cache-Control': 'no-cache' },
}
);
// ── 每天定点推文本到钉钉群 ────────────────────────────────────────────────
// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选)
// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview)
export const getWarehouseLogisticsExceptionNotifySettings = () => request.get(
'/api/warehouse-logistics-exception/notify/settings',
{ timeout: 30000, headers: { 'Cache-Control': 'no-cache' } }
);
// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填
export const saveWarehouseLogisticsExceptionNotifySettings = (payload = {}) => request.put(
'/api/warehouse-logistics-exception/notify/settings',
payload,
{ timeout: 30000 }
);
// 立即推送 / 预览正文 / 测试通道
// dryRun=true 只组装消息不发送(返回正文全文)
// probe=true 只发一句通道测试,不查业务数据
// 冷启动要全量扫一遍物流宽表(生产实测 3~4 分钟),超时给足
export const runWarehouseLogisticsExceptionNotify = (payload = {}) => request.post(
'/api/warehouse-logistics-exception/notify/run',
payload,
{ timeout: 10 * 60 * 1000 }
);

View File

@ -1,23 +0,0 @@
import request from './request';
// 发货时效要扫两张订单表 + 出库表(rpt 下全是视图),冷启动实测 40~50 秒,
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
// params.limit 明细条数上限
export const getWarehouseShipTimeliness = (params = {}) => request.get(
'/api/warehouse-ship-timeliness/list',
{
params,
timeout: TIMELINESS_TIMEOUT_MS,
headers: { 'Cache-Control': 'no-cache' },
}
);
// 仅取今日两轮核验的描述(不打订单表,秒回),用于顶部提示
export const getWarehouseShipTimelinessRounds = () => request.get(
'/api/warehouse-ship-timeliness/rounds',
{ timeout: 30000 }
);

View File

@ -29,7 +29,6 @@ export const DEFAULT_SIDEBAR_CONFIG = {
], ],
}, },
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
{ key: 'bin-replenish-monitor', type: 'item', label: '缺货补货监控', icon: 'inbox', routeKey: 'bin-replenish-monitor', visible: true, order: 90 },
{ {
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true, key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
children: [ children: [
@ -37,14 +36,11 @@ export const DEFAULT_SIDEBAR_CONFIG = {
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 }, { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
], ],
}, },
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
{ {
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true, key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
children: [ children: [
{ key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 }, { key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 },
{ key: 'stock-sales-analysis', label: '库存动销分析', icon: 'line-chart', routeKey: 'stock-sales-analysis', visible: true, order: 20 }, { key: 'stock-sales-analysis', label: '库存动销分析', icon: 'line-chart', routeKey: 'stock-sales-analysis', visible: true, order: 20 },
{ key: 'picking-bin-sales-analysis', label: '拣选库位动销分析', icon: 'inbox', routeKey: 'picking-bin-sales-analysis', visible: true, order: 30 },
], ],
}, },
{ {

View File

@ -1,91 +0,0 @@
/** 拣选库位动销分析 —— 表头与口径说明(粒度:仓位 × 商品编码) */
export const PICKING_BIN_COLUMNS = [
{
key: 'warehouse',
label: '仓库',
tip: '聚水潭主仓(按 wms_co_id 单仓独立统计,不合并分仓)',
},
{
key: 'binName',
label: '仓位',
tip: '系统仓位编码(jst_packInventoryInfo.bin);同一仓位有多个商品时会出现多行',
},
{
key: 'binType',
label: '类型',
tip: '统计范围固定为真实拣选仓位(pack_type=Bin 且 bin_group 为空);箱记录和存货位不纳入动销分析',
},
{
key: 'skuId',
label: '商品编码',
tip: '商品编码(jst_packInventoryInfo.sku_id),一行就是一个「仓位 × 商品」组合',
},
{
key: 'stockQty',
label: '库存数量',
tip: '该商品在这个仓位上的当前库存件数(同一仓位多行的库存相加 = 仓位库存)',
},
{
key: 'salesQty',
label: '商品销量',
tip: '近 N 天该商品的销量(整档,不按仓位分摊):含线上订单、组合装子件拆分、全民客户(B2B分销)渠道;同一商品占几个仓位,这几行显示的销量都一样,所以这一列不能直接按行求和',
},
{
key: 'dailySales',
label: '商品日销',
tip: '商品销量 ÷ 统计天数,动销等级严格按此值划分',
},
{
key: 'gradeKey',
label: '动销等级',
tip: '按商品日销划分:爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0',
},
];
export const PICKING_BIN_WINDOW_OPTIONS = [
{ label: '近 7 天', value: 7 },
{ label: '近 15 天', value: 15 },
{ label: '近 30 天', value: 30 },
{ label: '近 60 天', value: 60 },
];
export const PICKING_BIN_DEFAULT_DAYS = 30;
/** 动销等级展示色(与后端 key 对应) */
export const PICKING_BIN_GRADE_COLORS = {
hot: 'red',
best: 'volcano',
normal: 'blue',
slow1: 'gold',
slow2: 'default',
zero: 'default',
};
export const PICKING_BIN_DESCRIPTION = [
'【统计范围】只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);箱记录(pack_type=Pack)和存货位不纳入动销分析。',
'',
'【粒度】一行 = 一个「仓位 × 商品编码」组合。一个仓位放了 N 个商品就显示 N 行,列表与导出一致。',
' 库存数量 = 该商品在这个仓位上的实际库存(同一仓位多行相加 = 仓位总库存)。',
'',
'【动销等级口径】按「商品日销」分档:商品日销 = 该商品近 N 天销量 ÷ N。',
' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;',
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该商品无销量。',
' 等级判断的是商品本身的销售速度,与其摆放位置无关 —— 同一件商品占几个仓位,等级都相同。',
'默认统计窗口为近 30 天,可切换 7 / 15 / 30 / 60 天。',
'',
'【为什么不用分摊销量来定等级】',
' 同一 SKU 常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。',
' 若把销量先摊到每个仓位再定等级,一件日销 100 的爆款放在 10 个仓位就会被算成日销 10 —— 判断的是「摆法」而不是商品。',
'',
'【商品销量为什么不能直接按行求和】',
' 商品销量取的是整档销量(不除以占用仓位数),同一件商品跨仓位会重复出现,所以该列相加无意义。',
' 需要按行求和请用导出文件里的「分摊销量」列:商品销量 ÷ 其占用的同类型仓位数。',
' 恒等式已实测校验(差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。',
' 注意它小于全仓销量:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,无仓位记录的 SKU 销量不计入任何库位。',
'',
'数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位 + 商品汇总);',
'销量按 send_date 窗口统计 4 个来源:① jst_Orders 普通商品行;② jst_Orders 组合装行(ZH-拼接编码)按组合装映射展开为子件;',
'③ jst_qmcust_orderlist 全民客户(B2B分销)普通行(排除拆单父单 Split);④ qmcust 组合装行同样展开。',
'统计范围:只取 pack_type=Bin 且 bin_group 为空的真实拣选仓位,箱记录(pack_type=Pack)和存货位(bin_group=存货)不参与统计。',
];

View File

@ -6,13 +6,11 @@ export const SIDEBAR_PAGES = [
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
{ pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'stock_sales_analysis', path: '/planning/operation/stock-sales-analysis', name: '库存动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'stock_sales_analysis', path: '/planning/operation/stock-sales-analysis', name: '库存动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'picking_bin_sales_analysis', path: '/planning/operation/picking-bin-sales-analysis', name: '拣选库位动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_cuiqiao', path: '/planning/operation/returns-inventory/cuiqiao', name: '崔桥工厂仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_cuiqiao', path: '/planning/operation/returns-inventory/cuiqiao', name: '崔桥工厂仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'returns_inventory_henan', path: '/planning/operation/returns-inventory/henan', name: '河南客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_henan', path: '/planning/operation/returns-inventory/henan', name: '河南客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'bin_replenish_monitor', path: '/planning/operation/bin-replenish-monitor', name: '缺货补货监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
]; ];
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {

View File

@ -23,7 +23,6 @@ import {
TeamOutlined, TeamOutlined,
UserOutlined, UserOutlined,
SunOutlined, SunOutlined,
WarningOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Dropdown, Switch } from 'antd'; import { Dropdown, Switch } from 'antd';
import { verifyToken } from '../api/auth'; import { verifyToken } from '../api/auth';
@ -71,7 +70,6 @@ const SIDEBAR_ICON_MAP = {
shopping: ShoppingOutlined, shopping: ShoppingOutlined,
table: TableOutlined, table: TableOutlined,
team: TeamOutlined, team: TeamOutlined,
warning: WarningOutlined,
}; };
const SIDEBAR_ROUTE_MAP = { const SIDEBAR_ROUTE_MAP = {
@ -121,24 +119,8 @@ const SIDEBAR_ROUTE_MAP = {
activePath: '/planning/operation/mkh-bom-data', activePath: '/planning/operation/mkh-bom-data',
matchMode: 'prefix', matchMode: 'prefix',
}, },
'warehouse-ship-timeliness': {
path: '/planning/operation/warehouse-ship-timeliness',
activePath: '/planning/operation/warehouse-ship-timeliness',
matchMode: 'prefix',
},
'warehouse-logistics-exception': {
path: '/planning/operation/warehouse-logistics-exception',
activePath: '/planning/operation/warehouse-logistics-exception',
matchMode: 'prefix',
},
'daily-inventory': { path: '/planning/operation/daily-inventory' }, 'daily-inventory': { path: '/planning/operation/daily-inventory' },
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' }, 'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' },
'bin-replenish-monitor': {
path: '/planning/operation/bin-replenish-monitor',
activePath: '/planning/operation/bin-replenish-monitor',
matchMode: 'prefix',
},
settings: { path: '/settings' }, settings: { path: '/settings' },
'online-desktop-users': { path: '/settings/online-desktop-users' }, 'online-desktop-users': { path: '/settings/online-desktop-users' },
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },

View File

@ -1,517 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Empty, Input, InputNumber, Popconfirm, Segmented, Select, Spin, Table, Tag, Tooltip, Upload,
message,
} from 'antd';
import {
DeleteOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UploadOutlined,
} from '@ant-design/icons';
import {
clearPickBins, exportBinReplenish, getBinReplenishList, importPickBins,
} from '../../api/binReplenish';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import { SycmPage } from '../../components/Common';
import './style.css';
const num = (value) => (value === null || value === undefined || value === ''
? '--'
: Number(value).toLocaleString('zh-CN'));
const STATUS_TAGS = {
pickout: { color: 'red', label: '拣货位缺货' },
empty: { color: 'default', label: '无货可补' },
low: { color: 'orange', label: '拣货位偏低' },
nopick: { color: 'blue', label: '未设拣货位' },
ok: { color: 'green', label: '正常' },
};
const STATUS_OPTIONS = [
{ label: '待补货', value: 'replenish' },
{ label: '无货可补', value: 'empty' },
{ label: '未设拣货位', value: 'nopick' },
{ label: '全部', value: 'all' },
];
const DEFAULT_TARGET_DAYS = 7;
const renderBinTags = (bins = [], emptyText = '未维护', limit = 3) => {
if (!bins.length) return <span className="brp-muted">{emptyText}</span>;
const visible = bins.slice(0, limit);
const rest = bins.length - visible.length;
return (
<span className="brp-bins">
{visible.map((item) => (
<Tag key={`${item.bin}-${item.qty}`} className={`brp-bin${item.qty > 0 ? '' : ' is-zero'}`}>
{item.bin}
<b>{num(item.qty)}</b>
</Tag>
))}
{rest > 0 ? (
<Tooltip title={bins.slice(limit).map((item) => `${item.bin}(${num(item.qty)})`).join('、')}>
<Tag className="brp-bin brp-bin--more">+{rest}</Tag>
</Tooltip>
) : null}
</span>
);
};
const BinReplenishMonitor = () => {
const [warehouse, setWarehouse] = useState('cuiqiao');
const [status, setStatus] = useState('replenish');
const [keyword, setKeyword] = useState('');
const [targetDays, setTargetDays] = useState(DEFAULT_TARGET_DAYS);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState({
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], pickBinMeta: null, updatedAt: '', cacheHit: false,
});
const [importing, setImporting] = useState(false);
const keywordTimer = useRef(null);
const load = useCallback(async (options = {}) => {
const isRefresh = options.refresh === true;
if (isRefresh) setRefreshing(true);
else setLoading(true);
setError('');
try {
const response = await getBinReplenishList({
warehouse,
status,
keyword,
targetDays,
page: options.page || page,
pageSize: options.pageSize || pageSize,
refresh: isRefresh ? 'true' : undefined,
});
const payload = response?.data?.data || response?.data || {};
setData({
rows: payload.rows || [],
total: payload.total || 0,
summary: payload.summary || {},
warehouse: payload.warehouse || null,
warehouses: payload.warehouses || [],
pickBinMeta: payload.pickBinMeta || null,
updatedAt: payload.updatedAt || '',
cacheHit: Boolean(payload.cacheHit),
});
if (isRefresh) message.success('已重新回源取数');
} catch (err) {
setError(err?.message || '加载失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [warehouse, status, keyword, targetDays, page, pageSize]);
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [warehouse, status, page, pageSize, targetDays]);
useEffect(() => {
if (keywordTimer.current) clearTimeout(keywordTimer.current);
keywordTimer.current = setTimeout(() => {
setPage(1);
load({ page: 1 });
}, 400);
return () => { if (keywordTimer.current) clearTimeout(keywordTimer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
const summary = data.summary;
const pickBinMeta = data.pickBinMeta || {};
const usingFallback = pickBinMeta.source !== 'import';
const metrics = useMemo(() => ([
{
key: 'pickout',
tone: 'red',
label: '拣货位缺货',
value: summary.pickout || 0,
unit: '个 SKU',
foot: `可立即从存货位补 ${num(summary.suggestQtyTotal)} 件`,
tip: '拣货位库存为 0、存货位还有货:需要把存货位的库存补到拣货位',
},
{
key: 'empty',
tone: 'grey',
label: '无货可补',
value: summary.empty || 0,
unit: '个 SKU',
foot: '拣货位与存货位都为 0,需采购',
tip: '拣货位库存为 0,且仓库里已经没有该商品的存货,补货要走走采购',
},
{
key: 'low',
tone: 'orange',
label: '拣货位偏低',
value: summary.low || 0,
unit: '个 SKU',
foot: `低于 ${targetDays} 天销量目标`,
tip: `拣货位还有货,但低于目标库存(= 近15天日销 × ${targetDays},最少 ${10} 件)`,
},
{
key: 'qty',
tone: 'blue',
label: '建议补货合计',
value: summary.suggestQtyTotal || 0,
unit: '件',
foot: `覆盖 ${num(summary.withPickSlot)} 个有拣货位的 SKU`,
tip: '建议补货量 = min(存货位库存, 目标拣货位库存 − 当前拣货位库存)',
},
]), [summary, targetDays]);
const handleExport = async () => {
try {
const response = await exportBinReplenish({ warehouse, status, keyword, targetDays });
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控.xlsx`);
} catch (error_) {
const text = await readBlobErrorMessage(error_);
message.error(text || '导出失败');
}
};
const handleImport = async (file) => {
setImporting(true);
try {
const response = await importPickBins(file, warehouse);
const result = response?.data?.data || {};
message.success(`已导入 ${result.importedRows || 0} 个拣货位,正在重新取数…`);
await load({ refresh: true, page: 1 });
setPage(1);
} catch (error_) {
message.error(error_?.message || '导入失败');
} finally {
setImporting(false);
}
return false;
};
const handleClear = async () => {
try {
await clearPickBins(warehouse);
message.success('已清除货位清单,回到「货位散件记录」回退口径');
setPage(1);
await load({ refresh: true, page: 1 });
} catch (error_) {
message.error(error_?.message || '清除失败');
}
};
const columns = [
{
title: '状态',
dataIndex: 'status',
width: 112,
fixed: 'left',
render: (value) => {
const tag = STATUS_TAGS[value] || STATUS_TAGS.ok;
return <Tag color={tag.color} className="brp-status-tag">{tag.label}</Tag>;
},
},
{
title: '商品编码',
dataIndex: 'skuId',
width: 175,
render: (value, row) => (
<div className="brp-sku">
<span className="brp-sku__code">{value}</span>
{row.supplier ? <span className="brp-sku__supplier">{row.supplier}</span> : null}
</div>
),
},
{ title: '品名', dataIndex: 'productName', width: 260, ellipsis: true },
{
title: '拣货位(货位号 / 数量)',
dataIndex: 'pickBins',
width: 300,
render: (bins, row) => (row.hasPickSlot ? renderBinTags(bins) : <span className="brp-muted">未维护拣货位</span>),
},
{
title: '拣货位库存',
dataIndex: 'pickQty',
width: 108,
align: 'right',
render: (value) => (
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>{num(value)}</span>
),
},
{
title: '存货位库存',
dataIndex: 'storageQty',
width: 108,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
title: '建议补货',
dataIndex: 'suggestQty',
width: 110,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--suggest' : 'brp-qty brp-qty--zero'}>
{num(value)}
</span>
),
},
{
title: '目标拣货位',
dataIndex: 'targetQty',
width: 108,
align: 'right',
render: (value) => <span className="brp-qty brp-qty--muted">{num(value)}</span>,
},
{
title: '存货位(前 5 个 / 数量)',
dataIndex: 'storageBins',
width: 300,
render: (bins) => renderBinTags(bins, '无存货', 3),
},
{
title: '近15天日销',
dataIndex: 'avgSale',
width: 108,
align: 'right',
render: (value) => <span className="brp-qty brp-qty--muted">{num(value)}</span>,
},
{
title: '现有库存',
dataIndex: 'totalQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
];
return (
<SycmPage className="brp-page">
<div className="brp-hero">
<div className="brp-hero__main">
<h1 className="brp-hero__title">缺货补货监控</h1>
<p className="brp-hero__desc">
盯的是<b>拣货位</b>:拣货位没货就拣不出来,需要从<b>存货位</b>把货补过去。
状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。
</p>
</div>
<div className="brp-hero__meta">
<div>
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
</div>
<div>
拣货位口径:
<b className={usingFallback ? '' : 'brp-ok'}>
{usingFallback ? '默认拣选位(Bin 且非存货位)' : `导入清单 ${num(pickBinMeta.count)} 个`}
</b>
</div>
<div>
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
</div>
<div>
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中未设拣货位{' '}
<b>{num(summary.nopick)}</b> 个(不参与待补货)
</div>
</div>
</div>
<div className="brp-metrics">
{metrics.map((item) => (
<div key={item.key} className={`brp-metric brp-metric--${item.tone}`}>
<Tooltip title={item.tip}>
<div className="brp-metric__label">
{item.label}
<InfoCircleOutlined className="brp-metric__info" />
</div>
</Tooltip>
<div className="brp-metric__value">
{num(item.value)}
<span className="brp-metric__unit">{item.unit}</span>
</div>
<div className="brp-metric__foot">{item.foot}</div>
</div>
))}
</div>
<div className="brp-toolbar">
<span className="brp-toolbar__field">
<span className="brp-toolbar__label">仓库</span>
<Select
value={warehouse}
onChange={(value) => { setWarehouse(value); setPage(1); }}
style={{ width: 210 }}
options={(data.warehouses || []).map((item) => ({
value: item.key,
label: item.name,
}))}
/>
</span>
<span className="brp-toolbar__field">
<span className="brp-toolbar__label">范围</span>
<Segmented
value={status}
onChange={(value) => { setStatus(value); setPage(1); }}
options={STATUS_OPTIONS.map((item) => ({
...item,
label: item.value === 'replenish'
? `${item.label} ${num((summary.pickout || 0) + (summary.low || 0))}`
: item.value === 'empty'
? `${item.label} ${num(summary.empty || 0)}`
: item.label,
}))}
/>
</span>
<span className="brp-toolbar__field">
<span className="brp-toolbar__label">目标天数(天)</span>
<InputNumber
min={1}
max={90}
value={targetDays}
onChange={(value) => { setTargetDays(Number(value) || DEFAULT_TARGET_DAYS); setPage(1); }}
style={{ width: 90 }}
/>
</span>
<Input
allowClear
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜商品编码 / 品名 / 供应商"
prefix={<SearchOutlined />}
style={{ width: 260 }}
/>
<span className="brp-toolbar__spacer" />
<Upload
accept=".xlsx,.xls,.csv,.txt"
showUploadList={false}
beforeUpload={handleImport}
disabled={importing}
>
<Button icon={<UploadOutlined />} loading={importing}>导入货位清单</Button>
</Upload>
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={refreshing}
onClick={() => load({ refresh: true })}
>
刷新数据
</Button>
</div>
{usingFallback ? (
<Alert
type="info"
showIcon
className="brp-alert"
message="拣货位口径:默认拣选位(pack_type = Bin 且非存货位),与「拣选库位动销分析」一致"
description="这是系统当前能确定的最准口径。如果业务另有一份拣货位清单,可以上传覆盖(只放货位号,如 XA01-01-11),上传后按清单判定,其余货位都算存货位。"
action={(
<Upload accept=".xlsx,.xls,.csv,.txt" showUploadList={false} beforeUpload={handleImport}>
<Button size="small" type="primary">上传清单</Button>
</Upload>
)}
/>
) : (
<Alert
type="success"
showIcon
className="brp-alert"
message={`拣货位口径:导入清单(${num(pickBinMeta.count)} 个货位)`}
description={`最近导入:${pickBinMeta.lastImport?.fileName || '—'} · ${pickBinMeta.lastImport?.importedRows || 0} 个货位 · ${pickBinMeta.lastImport?.importedAt ? new Date(pickBinMeta.lastImport.importedAt).toLocaleString('zh-CN') : '—'}。清单变更后重新上传即可覆盖。`}
action={(
<Popconfirm
title="清除后回到「货位散件记录」回退口径,确定吗?"
onConfirm={handleClear}
okText="清除"
cancelText="取消"
>
<Button size="small" danger icon={<DeleteOutlined />}>清除清单</Button>
</Popconfirm>
)}
/>
)}
{error ? (
<Alert
type="error"
showIcon
className="brp-alert"
message={error}
action={<Button size="small" onClick={() => load()}>重试</Button>}
/>
) : null}
<Spin
spinning={loading}
tip="正在从仓位库存视图聚合,首次约 30~60 秒"
wrapperClassName="brp-table-spin"
>
<Table
rowKey="skuId"
size="small"
className="brp-table"
columns={columns}
dataSource={data.rows}
scroll={{ x: 1900 }}
locale={{
emptyText: (
<Empty
description={status === 'replenish'
? '当前没有需要补货的拣货位'
: '没有符合条件的商品'}
/>
),
}}
pagination={{
current: page,
pageSize,
total: data.total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (total) => `共 ${total} 个 SKU`,
onChange: (nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
},
}}
/>
</Spin>
<div className="brp-notes">
<div className="brp-notes__title">数据来源与口径说明</div>
<ul>
<li>
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码 + 货位汇总。
「拣货位库存」= 拣货位上的数量合计;「存货位库存」= 其余货位(含整箱、未分箱)的数量合计。
</li>
<li>
<b>拣货位</b>:副本没有货位类型字段,按导入的货位清单判定;未导入时回退为
pack_type = Bin 的记录(页面顶部会标出当前口径)。
</li>
<li>
<b>目标库存</b>:max(10, 近15天日销 × {targetDays});日销取 rpt.pm_stockMonitor 同仓最新快照的
fifteenAvgSale,取不到的按 10 件兜底。
</li>
<li>
<b>建议补货量</b>:min(存货位库存, 目标拣货位库存 − 当前拣货位库存),只在「拣货位缺货 / 偏低」时给数字;
「无货可补」表示拣货位和存货位都没货,要走走采购,不是仓库内部调拨。
</li>
<li>
<b>未设拣货位</b>:该 SKU 在拣货位清单里一个货位都没有(多为整箱直发、或还没维护拣货位)。
这类不算「拣货位缺货」,不进待补货清单,可在「未设拣货位」页签单独核对。
</li>
<li>
<b>时效</b>:仓位库存视图单次聚合约 35~45 秒,服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等半分钟左右。
</li>
</ul>
</div>
</SycmPage>
);
};
export default BinReplenishMonitor;

View File

@ -1,303 +0,0 @@
/* 缺货补货监控 —— 页头 / 指标卡 / 工具栏 / 明细表 */
.brp-page {
--brp-gap: 12px;
--brp-border: var(--sycm-border-card, #e6e6e6);
--brp-radius: var(--sycm-radius-xl, 12px);
--brp-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
--brp-blue: #2062e6;
--brp-red: #e5484d;
--brp-orange: #e8890c;
--brp-grey: #6b7280;
--brp-green: #1bb574;
}
/* ── 页头 ───────────────────────────────────────────────── */
.brp-hero {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 24px;
margin-bottom: var(--brp-gap);
}
.brp-hero__main {
min-width: 320px;
}
.brp-hero__title {
margin: 0;
color: var(--sycm-text-strong, #262626);
font-size: 20px;
font-weight: 600;
line-height: 1.3;
}
.brp-hero__desc {
margin: 4px 0 0;
max-width: 820px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.6;
}
.brp-hero__meta {
display: grid;
gap: 3px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.6;
text-align: right;
}
.brp-warn {
color: var(--brp-orange);
}
.brp-ok {
color: var(--brp-green);
}
/* ── 指标卡 ─────────────────────────────────────────────── */
.brp-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--brp-gap);
margin-bottom: var(--brp-gap);
}
.brp-metric {
position: relative;
overflow: hidden;
padding: 12px 14px 11px;
border: 1px solid var(--brp-border);
border-radius: var(--brp-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--brp-shadow);
}
.brp-metric::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: var(--brp-tone, var(--brp-blue));
}
.brp-metric--red { --brp-tone: var(--brp-red); }
.brp-metric--orange { --brp-tone: var(--brp-orange); }
.brp-metric--blue { --brp-tone: var(--brp-blue); }
.brp-metric--grey { --brp-tone: var(--brp-grey); }
.brp-metric__label {
display: flex;
align-items: center;
gap: 4px;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
cursor: help;
}
.brp-metric__info {
font-size: 11px;
}
.brp-metric__value {
margin-top: 6px;
color: var(--sycm-text-strong, #262626);
font-size: 24px;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1.15;
}
.brp-metric__unit {
margin-left: 4px;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
font-weight: 400;
}
.brp-metric__foot {
margin-top: 6px;
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.4;
}
/* ── 工具栏 ─────────────────────────────────────────────── */
.brp-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--brp-border);
border-radius: var(--brp-radius);
background: var(--sycm-bg-container, #fff);
box-shadow: var(--brp-shadow);
}
.brp-toolbar__field {
display: inline-flex;
align-items: center;
gap: 6px;
}
.brp-toolbar__label {
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 12px;
white-space: nowrap;
}
.brp-toolbar__spacer {
flex: 1 1 auto;
}
.brp-alert {
margin-bottom: 10px;
}
/* ── 表格 ───────────────────────────────────────────────── */
.brp-table-spin {
display: block;
}
.brp-table .ant-table-thead > tr > th {
background: #fafafa;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.brp-table .ant-table-tbody > tr > td {
font-size: 12px;
}
.brp-status-tag {
margin-inline-end: 0;
}
.brp-sku {
display: flex;
flex-direction: column;
gap: 2px;
}
.brp-sku__code {
color: var(--sycm-text-strong, #262626);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.brp-sku__supplier {
overflow: hidden;
color: var(--sycm-text-tertiary, #8c8c8c);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.brp-bins {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.brp-bin {
margin: 0;
padding: 0 6px;
border-color: #d9e2f2;
background: #f5f8ff;
color: #2b4a80;
font-size: 11px;
line-height: 18px;
}
.brp-bin b {
margin-left: 4px;
font-variant-numeric: tabular-nums;
}
.brp-bin.is-zero {
border-color: #f3d2d2;
background: #fff6f6;
color: #a33a3a;
}
.brp-bin--more {
border-color: #eee;
background: #fafafa;
color: #8c8c8c;
}
.brp-qty {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.brp-qty--zero {
color: var(--brp-red);
font-weight: 600;
}
.brp-qty--suggest {
color: var(--brp-blue);
font-weight: 600;
}
.brp-qty--muted {
color: var(--sycm-text-tertiary, #8c8c8c);
font-weight: 400;
}
.brp-muted {
color: var(--sycm-text-tertiary, #8c8c8c);
}
/* ── 口径说明 ───────────────────────────────────────────── */
.brp-notes {
margin-top: 14px;
padding: 12px 14px;
border: 1px dashed var(--brp-border);
border-radius: var(--brp-radius);
background: var(--sycm-bg-container, #fff);
color: var(--sycm-text-secondary, #595959);
font-size: 12px;
line-height: 1.7;
}
.brp-notes__title {
margin-bottom: 6px;
color: var(--sycm-text-strong, #262626);
font-weight: 600;
}
.brp-notes ul {
margin: 0;
padding-left: 18px;
}
.brp-notes li + li {
margin-top: 4px;
}
@media (max-width: 1200px) {
.brp-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.brp-hero__meta {
text-align: left;
}
}
@media (max-width: 640px) {
.brp-metrics {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@ -953,7 +953,6 @@ const MkhBomData = () => {
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text> <Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
</Space> </Space>
</Modal> </Modal>
<header className="mkh-header"> <header className="mkh-header">
<div className="mkh-header__heading"> <div className="mkh-header__heading">
<Title level={3}>MKH-BOM 资料</Title> <Title level={3}>MKH-BOM 资料</Title>

View File

@ -1,288 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Col, Input, Modal, Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import {
exportPickingBinSalesAnalysis,
getPickingBinSalesAnalysis,
} from '../../api/pickingBinSalesAnalysis';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import {
PICKING_BIN_COLUMNS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_GRADE_COLORS,
PICKING_BIN_DESCRIPTION,
} from '../../constants/pickingBinSalesAnalysis';
import './style.css';
const { Title, Text } = Typography;
const int = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const num = (value, digits = 2) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const TipTitle = ({ tip, children }) => (
<Tooltip title={tip}>
<span className="picking-bin-th-tip">{children}</span>
</Tooltip>
);
const PickingBinSalesAnalysis = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const [days, setDays] = useState(PICKING_BIN_DEFAULT_DAYS);
const [warehouse, setWarehouse] = useState('');
const [grades, setGrades] = useState([]);
const [keyword, setKeyword] = useState('');
const [exporting, setExporting] = useState(false);
const params = useMemo(() => ({
days,
...(warehouse ? { wmsCoId: warehouse } : {}),
}), [days, warehouse]);
const load = useCallback(async (options = {}) => {
setLoading(true);
setError('');
try {
const res = await getPickingBinSalesAnalysis({
...(options.params ?? params),
...(options.force ? { refresh: '1' } : {}),
});
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '拣选库位动销分析数据加载失败');
setData(null);
} finally {
setLoading(false);
}
}, [params]);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => data?.items ?? [], [data]);
const gradeMap = useMemo(() => new Map((data?.grades ?? []).map((g) => [g.key, g])), [data]);
const filteredRows = useMemo(() => {
const kw = keyword.trim().toLowerCase();
return items.filter((it) => {
if (grades.length > 0 && !grades.includes(it.gradeKey)) return false;
if (kw
&& !String(it.binName).toLowerCase().includes(kw)
&& !String(it.skuId).toLowerCase().includes(kw)) return false;
return true;
});
}, [items, grades, keyword]);
const columns = useMemo(() => PICKING_BIN_COLUMNS.map((col) => ({
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
key: col.key,
dataIndex: col.key,
align: ['warehouse', 'binName', 'binType', 'skuId', 'gradeKey'].includes(col.key) ? 'left' : 'right',
width: col.key === 'warehouse' ? 240
: (col.key === 'gradeKey' ? 170 : (col.key === 'skuId' ? 150 : 110)),
sorter: ['stockQty', 'salesQty', 'dailySales'].includes(col.key)
? (a, b) => (a[col.key] ?? 0) - (b[col.key] ?? 0)
: undefined,
render: (value) => {
if (col.key === 'gradeKey') {
const g = gradeMap.get(value);
return <Tag color={PICKING_BIN_GRADE_COLORS[value] || 'default'}>{g?.label || value}</Tag>;
}
if (col.key === 'binType' || col.key === 'skuId') return <Text>{value || '—'}</Text>;
if (col.key === 'dailySales') return num(value, 2);
if (col.key === 'salesQty') return num(value, 2);
if (col.key === 'stockQty') return int(value);
return value || '—';
},
})), [gradeMap]);
const handleExport = useCallback(async () => {
setExporting(true);
try {
const blob = await exportPickingBinSalesAnalysis({
...params,
...(grades.length > 0 ? { grade: grades.join(',') } : {}),
format: 'xlsx',
});
const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期';
const fileName = `拣选库位动销分析-${dateKey}.xlsx`;
await downloadBlobAsFile(blob, fileName);
message.success(`已开始下载:${fileName}`);
} catch (exportError) {
message.error(await readBlobErrorMessage(exportError, '库位动销明细导出失败'));
} finally {
setExporting(false);
}
}, [params, grades, data?.latestBizDate]);
const toggleGrade = useCallback((key) => {
setGrades((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}, []);
const summary = data?.summary ?? [];
const total = data?.total ?? null;
return (
<SycmPage className="picking-bin-page">
<div className="picking-bin-header">
<div className="picking-bin-header__title">
<Title level={4} style={{ margin: 0 }}>拣选库位动销分析</Title>
<Button
type="text"
icon={<InfoCircleOutlined />}
onClick={() => setHelpOpen(true)}
className="picking-bin-header__help"
>
口径说明
</Button>
</div>
<div className="picking-bin-header__meta">
<span>统计基准日:{data?.latestBizDate || '—'}</span>
{data?.shipWindow ? (
<span>销量窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)</span>
) : null}
{data?.takenAt ? (
<span>仓位数据时间:{new Date(data.takenAt).toLocaleString('zh-CN', { hour12: false })}</span>
) : null}
</div>
<Space wrap>
<Space size={4}>
<Text type="secondary">统计天数</Text>
<Select
value={days}
options={PICKING_BIN_WINDOW_OPTIONS}
onChange={setDays}
style={{ width: 110 }}
/>
</Space>
<Select
allowClear
value={warehouse || undefined}
options={(data?.warehouses ?? []).map((w) => ({ label: w.warehouse, value: w.wmsCoId }))}
onChange={(v) => setWarehouse(v || '')}
showSearch
optionFilterProp="label"
style={{ minWidth: 240 }}
placeholder="全部仓库"
/>
<Input.Search
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索仓位 / 商品编码"
style={{ width: 180 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => load()} loading={loading}>刷新</Button>
<Tooltip title="跳过 5 分钟缓存,重新扫描订单与仓位明细重算(约 5~20 秒)">
<Button onClick={() => load({ force: true })} loading={loading}>强制重算</Button>
</Tooltip>
<Tooltip title="导出当前筛选下的明细(仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / 仓位SKU数 / 商品销量 / 分摊销量 / 商品日销 / 动销等级),仅含拣选位。按「仓位 × 商品编码」拆行;商品销量为整档销量(同一商品跨仓位重复,不可直接求和),需要求和请用「分摊销量」列;动销等级按商品日销判定">
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出明细</Button>
</Tooltip>
</Space>
</div>
<Alert
type="info"
showIcon
message={`按「商品日均销量(商品日销)」划分 ${summary.length} 个动销等级,一行 = 一个「仓位 × 商品编码」组合`}
description="商品日销 = 该商品近 N 天销量 ÷ N,不做仓位分摊,因此同一件商品放在几个仓位都是同一等级。商品销量跨仓位重复显示,不能直接按行求和(需要求和请导出用「分摊销量」列)。点击下方等级卡片可筛选。"
style={{ marginBottom: 12 }}
/>
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
<Spin spinning={loading}>
<Row gutter={[8, 8]} className="picking-bin-summary">
{summary.map((s) => (
<Col key={s.key} flex="1 1 150px">
<Card
size="small"
hoverable
onClick={() => toggleGrade(s.key)}
className={grades.includes(s.key) ? 'picking-bin-card is-active' : 'picking-bin-card'}
>
<Tooltip title={`${s.tip};明细 ${int(s.rowCount)} 行,库存合计 ${int(s.stockQty)} 件,日销合计 ${num(s.dailySales, 1)}(销量按去重商品计)`}>
<Statistic
title={s.label}
value={s.skuCount}
suffix="品"
valueStyle={{ fontSize: 18 }}
/>
</Tooltip>
<div className="picking-bin-card__meta">
明细 {int(s.rowCount)} 行 · 库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
</div>
</Card>
</Col>
))}
{total ? (
<Col flex="1 1 150px">
<Card size="small" className="picking-bin-card is-total">
<Tooltip title={`去重商品 ${int(total.skuCount)} 个,涉及「仓位 × 商品」组合 ${int(total.rowCount)} 行`}>
<Statistic title={total.label} value={total.skuCount} suffix="品" valueStyle={{ fontSize: 18 }} />
</Tooltip>
<div className="picking-bin-card__meta">
明细 {int(total.rowCount)} 行 · 库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
</div>
</Card>
</Col>
) : null}
</Row>
<Table
className="picking-bin-table"
columns={columns}
dataSource={filteredRows}
rowKey={(r) => `${r.wmsCoId}::${r.binName}::${r.skuId}`}
size="small"
bordered
pagination={{
pageSize: 50,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (t) => `共 ${t} 条明细`,
}}
scroll={{ x: 'max-content', y: 'calc(100vh - 420px)' }}
/>
</Spin>
<Text type="secondary" className="picking-bin-footer">
当前筛选出 {filteredRows.length} 条明细
{items.length !== filteredRows.length ? `(共 ${items.length} 条)` : ''};
去重商品 {int(data?.total?.skuCount ?? 0)} 个;统计范围:仅拣选位。
</Text>
<Modal
title="计算口径说明"
open={helpOpen}
onCancel={() => setHelpOpen(false)}
footer={null}
width={760}
>
<div className="picking-bin-help">
{PICKING_BIN_DESCRIPTION.map((line, i) => (
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
))}
</div>
</Modal>
</SycmPage>
);
};
export default PickingBinSalesAnalysis;

View File

@ -1,95 +0,0 @@
.picking-bin-page {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.picking-bin-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.picking-bin-header__title {
display: flex;
align-items: center;
gap: 4px;
}
.picking-bin-header__help {
font-size: 13px;
}
.picking-bin-header__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.55);
}
.picking-bin-th-tip {
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
cursor: help;
white-space: nowrap;
}
.picking-bin-summary {
margin-bottom: 12px;
}
.picking-bin-card .ant-statistic-title {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picking-bin-card.is-active {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
}
.picking-bin-card.is-total {
background: rgba(22, 119, 255, 0.06);
}
.picking-bin-card__meta {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picking-bin-table .ant-table-thead > tr > th {
white-space: nowrap;
font-size: 12px;
padding: 6px 8px;
}
.picking-bin-table .ant-table-tbody > tr > td {
padding: 5px 8px;
font-size: 13px;
vertical-align: middle;
}
.picking-bin-footer {
font-size: 12px;
}
.picking-bin-help p {
margin: 0 0 6px;
line-height: 1.7;
font-size: 13px;
white-space: pre-wrap;
}
.picking-bin-help p.is-indent {
padding-left: 16px;
color: rgba(0, 0, 0, 0.65);
}

View File

@ -71,25 +71,6 @@ const RETURN_DEADLINE_TAGS = {
}; };
const RETURN_DEADLINE_RANK = { overdue: 3, pending: 2, done: 1, none: 0 }; const RETURN_DEADLINE_RANK = { overdue: 3, pending: 2, done: 1, none: 0 };
// 供应商是否合作(后端取自简尚供应商主数据 js_supplier.coopersgn:1=合作 / 2=不合作)
const SUPPLIER_COOP_TAGS = {
cooperative: { color: 'green', label: '合作' },
ended: { color: 'red', label: '不合作' },
unknown: { color: 'default', label: '未知' },
};
const SUPPLIER_COOP_RANK = { ended: 2, cooperative: 1, unknown: 0 };
const SUPPLIER_COOP_TIPS = {
cooperative: '该商品编码的供应商在简尚供应商主数据里标记为「合作中」(js_supplier.coopersgn = 1)',
ended: '该商品编码的供应商在简尚供应商主数据里标记为「不合作」(js_supplier.coopersgn = 2),退货处理需另行商定对接人',
unknown: '供应商名在简尚供应商主数据(js_supplier)里没有匹配记录,或该编码没有供应商,无法判断合作状态',
};
// 最晚退厂时间(工作平台「供应商不合作」)—— 2026-09-28 先上线占位:
// 数据属于工作平台库 WorkbenchDataShared,副本账号目前没有该库访问权限(后端
// supplierExitDateProvider.js 有完整取证),IT 开通后自动出数,页面无需再改。
const EXIT_DATE_PENDING_TIP = '数据来源:工作平台(WorkbenchDataShared)· 供应商不合作。该库当前未授权给副本账号,待 IT 开通只读权限(或把该表同步进报表副本)后自动显示最晚退厂日期。';
const EXIT_DATE_VALUE_TIP = '工作平台「供应商不合作」给出的最晚退厂日期:该供应商放在仓里的货最后必须拉走的日期';
// 预警等级过滤:主视图改成一排状态页签(全部 / 红色预警 / 关注 / 正常 / 未知), // 预警等级过滤:主视图改成一排状态页签(全部 / 红色预警 / 关注 / 正常 / 未知),
// 因此不再需要下拉选项;导出时仍把 warningLevel 原样传给后端(服务端 filter 同口径)。 // 因此不再需要下拉选项;导出时仍把 warningLevel 原样传给后端(服务端 filter 同口径)。
@ -367,42 +348,11 @@ const COLUMNS = [
}, },
}, },
{ {
title: '供应商是否合作', title: '是否出库',
dataIndex: 'supplierCoop', dataIndex: 'isOutbound',
width: 136, width: 90,
align: 'center', align: 'center',
sorter: (a, b) => (SUPPLIER_COOP_RANK[a.supplierCoop] || 0) - (SUPPLIER_COOP_RANK[b.supplierCoop] || 0), render: (value) => (value ? <Tag color="green">是</Tag> : <Tag>否</Tag>),
render: (value) => {
const tag = SUPPLIER_COOP_TAGS[value] || SUPPLIER_COOP_TAGS.unknown;
return (
<Tooltip title={SUPPLIER_COOP_TIPS[value] || SUPPLIER_COOP_TIPS.unknown}>
<Tag color={tag.color}>{tag.label}</Tag>
</Tooltip>
);
},
},
{
title: '最晚退厂时间',
dataIndex: 'latestExitDateLabel',
width: 132,
align: 'center',
sorter: (a, b) => String(a.latestExitDateLabel || '').localeCompare(String(b.latestExitDateLabel || '')),
render: (value, record) => {
if (value) {
return (
<Tooltip title={EXIT_DATE_VALUE_TIP}>
<Tag color="orange">{value}</Tag>
</Tooltip>
);
}
// 已接入但该供应商没有退厂日期 → 显示破折号;整列未接入 → 显示「待接入」
if (record?.latestExitDateAvailable) return <span className="rsw-exit-date-empty">—</span>;
return (
<Tooltip title={EXIT_DATE_PENDING_TIP}>
<Tag color="default">待接入</Tag>
</Tooltip>
);
},
}, },
{ {
title: '创建时间来源', title: '创建时间来源',
@ -455,8 +405,6 @@ const StockWarning = ({ warehouse }) => {
rows: [], total: 0, summary: {}, warehouse: '', warehouseName: '', returnSlot: '', rows: [], total: 0, summary: {}, warehouse: '', warehouseName: '', returnSlot: '',
warehouseHasReturnStock: true, statDate: '', stockSyncedAt: '', gaps: [], import: null, warehouseHasReturnStock: true, statDate: '', stockSyncedAt: '', gaps: [], import: null,
hasImport: false, hasImport: false,
// 最晚退厂时间数据源状态(工作平台库未接入时为 available:false + message)
exitDateMeta: { available: false, sourceLabel: '', message: '' },
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
@ -495,7 +443,6 @@ const StockWarning = ({ warehouse }) => {
gaps: payload.gaps || [], gaps: payload.gaps || [],
import: payload.import || null, import: payload.import || null,
hasImport: Boolean(payload.hasImport), hasImport: Boolean(payload.hasImport),
exitDateMeta: payload.exitDateMeta || { available: false, sourceLabel: '', message: '' },
}); });
} catch (requestError) { } catch (requestError) {
setError(requestError?.response?.data?.error || requestError?.message || '加载客退数据监控失败'); setError(requestError?.response?.data?.error || requestError?.message || '加载客退数据监控失败');

View File

@ -648,11 +648,6 @@
font-weight: 600; font-weight: 600;
} }
/* 表格「最晚退厂时间」列:数据源已接入但该供应商无日期时的占位符 */
.rsw-exit-date-empty {
color: var(--sycm-text-quaternary, #bfbfbf);
}
/* ── 退货库存预警:图表区 ─────────────────────────────────── */ /* ── 退货库存预警:图表区 ─────────────────────────────────── */
.rsw-charts { .rsw-charts {
display: grid; display: grid;

View File

@ -209,7 +209,7 @@ const StockSalesAnalysis = () => {
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细与库存明细重算(约 5~20 秒)"> <Tooltip title="跳过 5 分钟缓存,重新扫描订单明细与库存明细重算(约 5~20 秒)">
<Button onClick={() => load({ days, force: true })} loading={loading}>强制重算</Button> <Button onClick={() => load({ days, force: true })} loading={loading}>强制重算</Button>
</Tooltip> </Tooltip>
<Tooltip title="导出当前筛选下「销售=0」档的 SKU 明细(仓库 / SKU编码 / SKU名称 / 系统仓位 / 库存数量),跟随统计天数与仓库筛选;每个仓位独立一行,库存数量为该仓位的实际数量,仓位数据未覆盖的 SKU 保留一行(仓位留空、库存为总量)"> <Tooltip title="导出当前筛选下「销售=0」档的 SKU 明细(仓库 / SKU编码 / SKU名称 / 库存数量),跟随统计天数与仓库筛选">
<Button icon={<DownloadOutlined />} onClick={handleExportZero} loading={exporting}>导出销售=0</Button> <Button icon={<DownloadOutlined />} onClick={handleExportZero} loading={exporting}>导出销售=0</Button>
</Tooltip> </Tooltip>
</Space> </Space>

View File

@ -1,966 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Collapse, DatePicker, Dropdown, Empty, Input, InputNumber, Modal, Space, Spin, Statistic,
Switch, Table, Tag, TimePicker, Tooltip, Typography, message,
} from 'antd';
import { BellOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmPage } from '../../components/Common';
import {
getWarehouseLogisticsExceptions,
getWarehouseLogisticsExceptionExportRows,
getWarehouseLogisticsExceptionNotifySettings,
saveWarehouseLogisticsExceptionNotifySettings,
runWarehouseLogisticsExceptionNotify,
} from '../../api/warehouseLogisticsException';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
// 核验点每天只有 09:00 一个,10 分钟刷一次足够;接口冷启动要 20 秒,别跟着秒级轮询跑
const AUTO_REFRESH_MS = 10 * 60 * 1000;
// 回看窗口(与后端 LOOKBACK_DAYS 对齐:今天是第 1 天,往前 29 天)
const LOOKBACK_DAYS = 30;
const number = (value, digits = 0) => (
value === null || value === undefined || value === ''
? '0'
: Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
);
// 小时数 → 「32 小时」/「1 天 8 小时」
const formatHours = (hours) => {
const h = Number(hours);
if (!Number.isFinite(h)) return '—';
if (h < 0) return '核验后';
if (h < 1) return '不到 1 小时';
if (h < 24) return `${Math.round(h)} 小时`;
const d = Math.floor(h / 24);
const rest = Math.round(h % 24);
return rest ? `${d} 天 ${rest} 小时` : `${d} 天`;
};
/**
* 明细过滤(仓 + 异常类型)。
* ⚠️ 页面表格和「导出」必须共用这一个函数 —— 否则导出的行和页面看到的对不上。
*/
const filterDetailOrders = (orders, { centerKey = '', typeKey = '' } = {}) => (
(Array.isArray(orders) ? orders : []).filter((o) => (
(!centerKey || o.centerKey === centerKey)
&& (!typeKey || o.exceptionType === typeKey)
))
);
// 导出列:标题必须是纯文本(页面列用了 Tooltip,直接复用会导出成 [object Object])
const EXPORT_COLUMNS = [
{ title: '发货仓', dataIndex: 'warehouseName' },
{ title: '内部单号', dataIndex: 'oId' },
{ title: '线上订单号', dataIndex: 'soId' },
{ title: '店铺', dataIndex: 'shopName' },
{ title: '快递公司', dataIndex: 'carrier' },
{ title: '运单号', dataIndex: 'trackingNo', exportValue: (v) => v || '无运单号' },
{ title: '物流状态', dataIndex: 'logisticStatus', exportValue: (v) => v || '无状态' },
{ title: '异常类型', dataIndex: 'exceptionLabel' },
{ title: '异常说明', dataIndex: 'exceptionDesc' },
{ title: '发货时间', dataIndex: 'sendTime' },
{ title: '最后物流时间', dataIndex: 'lastLogisticTime', exportValue: (v) => v || '无轨迹' },
{
title: '发货后待处理(小时)',
dataIndex: 'pendingHours',
exportValue: (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v) * 10) / 10 : ''),
},
{ title: '最后物流描述', dataIndex: 'lastLogisticInfo' },
];
// 导出菜单:key = `<format>`;sheet/文件名里带核验日,业务归档好找
const EXPORT_MENU_ITEMS = [
{ key: 'xlsx', label: '导出 Excel(.xlsx)' },
{ key: 'csv', label: '导出 CSV(.csv)' },
];
// 「异常通知」表单初始值。加签密钥永远不回显明文:留空 = 沿用服务端已存的那份
const EMPTY_NOTIFY_FORM = {
enabled: false,
sendTime: '09:10',
webhookUrl: '',
webhookSecret: '',
maxDetailRows: 30,
atAll: false,
pageUrl: '',
};
// 后端「没推」的原因 → 人话(后端 reason 是给日志用的英文标识)
const NOTIFY_REASON_TEXT = {
disabled: '推送开关没打开',
not_configured: 'Webhook 地址还没填',
already_sent_today: '今天已经推过了(一天只推一次)',
not_started: '这一批还没到核验时刻,没有结论可推',
dry_run: '仅预览,未发送',
};
// 钉钉群机器人 webhook 怎么拿 —— 业务自己配,写清楚省得来问
const NOTIFY_HELP_ITEMS = [
{
key: 'webhook',
label: '① 拿群机器人 Webhook 地址',
children: (
<div className="wle-notify__help">
电脑版钉钉 → 打开目标群 → 右上角 <b>群设置</b> → <b>机器人</b> → <b>添加机器人</b> →
选 <b>自定义</b> → 起个名字(如「物流异常通知」)→ 复制 <b>Webhook 地址</b>。
<br />
地址形如 <code>https://oapi.dingtalk.com/robot/send?access_token=xxxx</code>,
整串(含 access_token)直接贴到下面。
</div>
),
},
{
key: 'security',
label: '② 安全设置:加签密钥(可选)',
children: (
<div className="wle-notify__help">
添加机器人时如果安全设置选了 <b>加签</b>,把以 <code>SEC</code> 开头的密钥填到「加签密钥」里。
<br />
如果选的是 <b>自定义关键词</b>,密钥留空即可 —— 但关键词必须包含 <b>异常</b>
(本模块消息抬头固定是「【仓储物流转运异常】」,天然命中)。
<br />
如果选的是 <b>IP 地址(段)</b>,需要把生产服务器 IP 加进白名单,密钥同样留空。
</div>
),
},
{
key: 'why',
label: '③ 为什么群里只有文字,没有 Excel 附件',
children: (
<div className="wle-notify__help">
钉钉的<b>自定义机器人 webhook</b> 只支持 text / markdown / link / actionCard,
<b>不支持发文件</b>。所以这里推的是<b>文本消息</b>:汇总数字 + 前 N 条明细 + 页面链接。
<br />
要全量明细或 Excel,点消息末尾的链接进页面,用右上角「导出」按钮导出即可
(口径与消息完全一致)。
</div>
),
},
];
const DETAIL_COLUMNS = [
{
title: '发货仓',
dataIndex: 'warehouseName',
width: 190,
ellipsis: true,
render: (v) => v || '—',
},
{ title: '内部单号', dataIndex: 'oId', width: 96 },
{
title: '线上订单号',
dataIndex: 'soId',
width: 178,
ellipsis: true,
render: (v) => (v ? <span className="wle-detail__soid">{v}</span> : '—'),
},
{ title: '店铺', dataIndex: 'shopName', width: 150, ellipsis: true, render: (v) => v || '—' },
{ title: '快递公司', dataIndex: 'carrier', width: 130, ellipsis: true, render: (v) => v || '—' },
{
title: (
<Tooltip title="聚水潭回传的运单号。空的说明仓库已推单但快递没建单">
<span>运单号</span>
</Tooltip>
),
dataIndex: 'trackingNo',
width: 150,
ellipsis: true,
render: (v) => (v ? v : <Tag bordered={false} color="red">无运单号</Tag>),
},
{
title: '物流状态',
dataIndex: 'logisticStatus',
width: 100,
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : <Tag bordered={false} color="red">无状态</Tag>),
},
{
title: (
<Tooltip title="判定到核验时刻(当天 09:00)物流卡在哪一步;点类型图例可只看某一类">
<span>异常类型</span>
</Tooltip>
),
dataIndex: 'exceptionLabel',
width: 116,
render: (v, row) => (
<Tooltip title={row?.exceptionDesc || v}>
<Tag bordered={false} color={row?.tone || 'default'}>{v}</Tag>
</Tooltip>
),
},
{ title: '发货时间', dataIndex: 'sendTime', width: 152, ellipsis: true },
{
title: (
<Tooltip title="这条运单最后一条物流轨迹的时间。空白说明快递还没揽收、没有任何轨迹">
<span>最后物流时间</span>
</Tooltip>
),
dataIndex: 'lastLogisticTime',
width: 152,
ellipsis: true,
render: (v) => v || '—',
},
{
title: (
<Tooltip title="从发货时间到核验时刻(核验日 09:00)经过多久 —— 仓库看到「发了多久物流还没动静」">
<span>发货后待处理</span>
</Tooltip>
),
dataIndex: 'pendingHours',
width: 118,
render: (v) => <span className="wle-detail__hours">{formatHours(v)}</span>,
},
{
title: '最后物流描述',
dataIndex: 'lastLogisticInfo',
width: 280,
ellipsis: true,
render: (v) => (v ? (
<Tooltip title={v}>
<span>{v}</span>
</Tooltip>
) : '—'),
},
];
/**
* 仓储中心物流转运异常(独立页面)
* ────────────────────────────────────────────────────────────────
* 业务口径(yanhui 2026-10-21,二次收敛):
* · 12 个发货仓,子仓并入主仓一起算;
* · **每天 09:00 核验前一日已发货订单的物流记录** —— 到 9 点还卡在
* 「未揽收」或「已揽收但没有下一步中转」就是转运异常,按仓归口;
* · 运输中 / 派送中 / 已签收 = 正常,不进列表;不再按「24h 无新轨迹」判停滞;
* · 刷单、信誉单、已取消/作废/拦截、指定承运商不参与考核;
* · 可回看近 30 天,但历史批次是按**当前**物流快照重算的(页面会提示)。
*
* 数据来自 /api/warehouse-logistics-exception/list(service 层见
* backend/src/services/warehouseLogisticsExceptionService.js)。
*/
function WarehouseLogisticsException() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 选中的仓:为空时展示全部仓
const [activeCenter, setActiveCenter] = useState('');
// 异常类型筛选:为空 = 全部
const [activeType, setActiveType] = useState('');
// 核验日;null = 交给后端按「现在几点」自动判(未到 9 点自动看昨天那批)
const [verifyDate, setVerifyDate] = useState(null);
// 导出中(导出比列表慢,按钮要转圈,避免重复点)
const [exporting, setExporting] = useState(false);
// 每天定点把异常文本推到钉钉群(「异常通知」)
const [notifyOpen, setNotifyOpen] = useState(false);
const [notifyLoading, setNotifyLoading] = useState(false);
const [notifySaving, setNotifySaving] = useState(false);
const [notifyRunning, setNotifyRunning] = useState(false);
const [notifyConfig, setNotifyConfig] = useState(null);
const [notifyForm, setNotifyForm] = useState(EMPTY_NOTIFY_FORM);
// 预览正文:放在弹窗内受控渲染(React 19 下 Modal.info 静态方法弹不出来)
const [notifyPreview, setNotifyPreview] = useState(null);
const load = useCallback(async (force = false) => {
setLoading(true);
setError('');
try {
const result = await getWarehouseLogisticsExceptions({
limit: 1000,
force: force ? 1 : 0,
date: verifyDate ? verifyDate.format('YYYY-MM-DD') : undefined,
});
if (!result || result.success === false) {
setError(result?.error || '加载物流转运异常失败');
return;
}
setData(result.data || null);
} catch (loadError) {
setError(loadError?.response?.data?.error || loadError?.message || '加载物流转运异常失败');
} finally {
setLoading(false);
}
}, [verifyDate]);
useEffect(() => {
load();
const timer = window.setInterval(() => load(), AUTO_REFRESH_MS);
return () => window.clearInterval(timer);
}, [load]);
const centers = useMemo(() => (Array.isArray(data?.centers) ? data.centers : []), [data]);
const allOrders = useMemo(() => (Array.isArray(data?.orders) ? data.orders : []), [data]);
const types = useMemo(() => (Array.isArray(data?.types) ? data.types : []), [data]);
const summary = data?.summary || {};
const batch = data?.batch || null;
const freshness = data?.freshness || null;
// 剔除口径 + 本批实际剔除量(后端给什么就展示什么,页面不硬编码名单)
const exclusions = data?.exclusions || null;
const notStarted = Boolean(data?.notStarted);
const activeCenterName = useMemo(() => {
if (!activeCenter) return '';
const hit = centers.find((c) => c.key === activeCenter);
return hit ? hit.name : activeCenter;
}, [activeCenter, centers]);
const detailOrders = useMemo(
() => filterDetailOrders(allOrders, { centerKey: activeCenter, typeKey: activeType }),
[activeCenter, activeType, allOrders]
);
const disabledDate = (current) => (
current && (current > dayjs().endOf('day')
|| current < dayjs().startOf('day').subtract(LOOKBACK_DAYS - 1, 'day'))
);
/**
* 导出异常明细。
* 口径要点:**按当前核验日 + 当前筛选(仓 / 异常类型)导出全部**,不是只导当前屏。
* 空表头 / 空筛选时导出的是「本批全部异常」,文件名叫「仓储物流转运异常_<核验日>」。
*/
const handleExport = async (format) => {
setExporting(true);
try {
const result = await getWarehouseLogisticsExceptionExportRows({
date: verifyDate ? verifyDate.format('YYYY-MM-DD') : undefined,
});
if (!result || result.success === false) {
message.error(result?.error || '导出失败');
return;
}
const payload = result.data || {};
const rows = filterDetailOrders(payload.rows, {
centerKey: activeCenter,
typeKey: activeType,
});
if (!rows.length) {
message.warning('当前筛选条件下没有可导出的异常明细');
return;
}
const typeLabel = activeType ? (types.find((t) => t.key === activeType)?.label || activeType) : '';
const scope = [activeCenterName, typeLabel].filter(Boolean).join('_');
const verifyDateText = payload.batch?.verifyDate || dayjs().format('YYYY-MM-DD');
const fileName = `仓储物流转运异常_${verifyDateText}${scope ? `_${scope}` : ''}`;
await exportTableFile(format, fileName, EXPORT_COLUMNS, rows, '转运异常明细');
const truncatedHint = payload.truncated
? `;明细超过上限 ${payload.detailLimit} 行,已截断`
: '';
message.success(`已导出 ${rows.length} 行(${String(format).toUpperCase()})${truncatedHint}`);
} catch (exportError) {
message.error(exportError?.response?.data?.error || exportError?.message || '导出失败');
} finally {
setExporting(false);
}
};
// ── 异常通知:每天定点把异常情况以文本推到钉钉群 ──────────────────────────
// 后端只回加签密钥掩码,所以表单里的 webhookSecret 永远是空的 —— 留空 = 沿用已存的
const applyNotifyConfig = useCallback((config) => {
setNotifyConfig(config || null);
setNotifyForm({
enabled: config?.enabled === true,
sendTime: config?.sendTime || '09:10',
webhookUrl: config?.webhookUrl || '',
webhookSecret: '',
maxDetailRows: Number.isFinite(Number(config?.maxDetailRows))
? Number(config.maxDetailRows)
: 30,
atAll: config?.atAll === true,
pageUrl: config?.pageUrl || '',
});
}, []);
const openNotifySettings = useCallback(async () => {
setNotifyOpen(true);
setNotifyPreview(null);
setNotifyLoading(true);
try {
const result = await getWarehouseLogisticsExceptionNotifySettings();
if (!result || result.success === false) {
message.error(result?.error || '读取异常通知配置失败');
return;
}
applyNotifyConfig(result.data);
} catch (loadError) {
message.error(loadError?.response?.data?.error || loadError?.message || '读取异常通知配置失败');
} finally {
setNotifyLoading(false);
}
}, [applyNotifyConfig]);
const buildNotifyPayload = useCallback(() => ({
enabled: notifyForm.enabled,
sendTime: notifyForm.sendTime,
webhookUrl: String(notifyForm.webhookUrl || '').trim(),
webhookSecret: String(notifyForm.webhookSecret || '').trim(),
maxDetailRows: Number(notifyForm.maxDetailRows) || 0,
atAll: notifyForm.atAll === true,
pageUrl: String(notifyForm.pageUrl || '').trim(),
}), [notifyForm]);
const handleSaveNotify = async ({ silent = false } = {}) => {
setNotifySaving(true);
try {
const result = await saveWarehouseLogisticsExceptionNotifySettings(buildNotifyPayload());
if (!result || result.success === false) {
message.error(result?.error || '保存异常通知配置失败');
return null;
}
applyNotifyConfig(result.data);
if (!silent) message.success('异常通知配置已保存');
return result.data;
} catch (saveError) {
message.error(saveError?.response?.data?.error || saveError?.message || '保存异常通知配置失败');
return null;
} finally {
setNotifySaving(false);
}
};
/**
* mode: 'dry' 预览正文(不发送)| 'probe' 只发一句通道测试 | 'send' 立即推送本批
* 都要先保存:否则「改完 webhook 直接点测试」会拿旧地址发,排错时会很迷惑。
*
* ⚠️ 预览**不能**用 `Modal.info` 静态方法:本项目跑 React 19,antd v5 的静态方法
* 拿不到 ConfigProvider context(控制台只给一行 warning),弹窗**静默不渲染**。
* 所以预览改成弹窗内的受控区块(notifyPreview)。
*/
const handleRunNotify = async (mode) => {
const saved = await handleSaveNotify({ silent: true });
if (!saved) return;
setNotifyRunning(true);
try {
const payload = mode === 'dry'
? { dryRun: true, force: true }
: { probe: mode === 'probe', force: true };
const result = await runWarehouseLogisticsExceptionNotify(payload);
if (!result || result.success === false) {
message.error(result?.error || '推送到钉钉群失败');
return;
}
const info = result.data || {};
if (mode === 'dry') {
setNotifyPreview({
text: info.text || '',
rowCount: info.rowCount ?? 0,
listedCount: info.listedCount ?? 0,
bytes: info.bytes || 0,
truncated: info.truncated === true,
batchLabel: info.batch?.label || '',
});
message.success('已生成预览(未发送),正文见下方');
} else if (mode === 'probe') {
message.success('通道测试已发出,去钉钉群看有没有收到那条测试消息');
} else if (info.sent) {
setNotifyPreview(null);
message.success(`已推送到钉钉群(${info.rowCount ?? 0} 条异常,列出 ${info.listedCount ?? 0} 条)`);
} else {
message.warning(`未推送:${NOTIFY_REASON_TEXT[info.reason] || info.reason || '未知原因'}`);
}
const latest = await getWarehouseLogisticsExceptionNotifySettings();
if (latest?.data) applyNotifyConfig(latest.data);
} catch (runError) {
message.error(runError?.response?.data?.error || runError?.message || '推送到钉钉群失败');
} finally {
setNotifyRunning(false);
}
};
const notifyTimeValue = useMemo(() => {
const [hours, minutes] = String(notifyForm.sendTime || '09:10')
.split(':')
.map((part) => Number(part) || 0);
return dayjs().hour(hours).minute(minutes).second(0);
}, [notifyForm.sendTime]);
const notifyLastState = notifyConfig?.lastState || {};
return (
<SycmPage className="wle-page">
<header className="wle-header">
<Title level={3}>仓储中心物流转运异常</Title>
<Text type="secondary" className="wle-note">
<b>每天 09:00 核验 12 个发货仓「前一日已发货」订单的物流记录</b>,只看异常,正常签收的不列。
核验批次 = <b>核验日 09:00</b> + <b>前一天发货的单</b>。
到核验时刻物流还卡在<b>揽收前</b>就算转运异常,分五类:
<b>未揽收</b>(发了货快递没来揽)、<b>已揽收未中转</b>(揽了但没下一步中转,卡在网点)、
<b>无物流单</b>(连运单号都没建)、<b>快递报异常</b>(如寄件人取消寄件、异常签收)、
<b>退回</b>(拒收 / 换单退回)。
<b>运输中、派送中、已签收 = 正常,不进列表</b> —— 有「运输」字样之后的中转记录不回传,不作考核。
一单多包裹按包裹列明细、按内部单号统计,异常率不会被多包裹单放大。
<b>不参与考核</b>:顺丰系全部(唯品顺丰 / 顺丰干配 / 顺丰大件各账号)、武进安能、中铁、壹米滴答、
京喜京东(快递·快运)、自提、线下、商家配送
(轨迹不回传或本身无快递轨迹);
<b>睢宁中通</b> 只在睢宁仓考核,其它仓异地打单的剔出;
<b>已取消 / 作废 / 拦截</b>的订单(单据已终止)、<b>刷单 / 信誉单</b>(本身不是真实发货)。
<b>历史批次是按当前物流快照回算的</b> —— 昨天是异常、今天已签收的单会显示成正常,
要留档结论请以当天 09:00 看到的为准。
</Text>
</header>
<Card className="wle-status" bordered={false}>
<div className="wle-status__row">
<div className="wle-status__batch">
{notStarted ? (
<>
<span className="wle-status__badge wle-status__badge--idle">未到核验时间</span>
<span className="wle-status__label">
{String(batch?.deadline || '').slice(5, 16)} 才开始核验
</span>
</>
) : (
<>
<span className="wle-status__badge">{batch?.label || '—'}</span>
<span className="wle-status__label">
核验 {batch?.shipDate || '—'} 已发货订单的物流记录
</span>
{batch?.lookback ? (
<span className="wle-status__window">历史批次 · 按当前快照回算</span>
) : null}
</>
)}
</div>
<Space size={8} wrap>
<DatePicker
value={verifyDate || (batch?.verifyDate ? dayjs(batch.verifyDate) : null)}
disabledDate={disabledDate}
onChange={(value) => setVerifyDate(value ? value.startOf('day') : null)}
style={{ width: 148 }}
placeholder="核验日"
/>
{data?.generatedAt ? (
<Text type="secondary" className="wle-status__time">
数据更新于 {String(data.generatedAt).slice(11, 19)}
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s)` : ''}
</Text>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
<Dropdown
trigger={['click']}
disabled={exporting || notStarted}
menu={{ items: EXPORT_MENU_ITEMS, onClick: ({ key }) => handleExport(key) }}
>
<Button icon={<DownloadOutlined />} loading={exporting}>导出</Button>
</Dropdown>
<Button icon={<BellOutlined />} onClick={openNotifySettings}>异常通知</Button>
</Space>
</div>
</Card>
{error ? (
<Alert
type="error"
showIcon
className="wle-alert"
message={error}
action={<Button size="small" onClick={() => load(true)}>重试</Button>}
/>
) : null}
{freshness && (freshness.coversDeadline === false || freshness.stale) ? (
<Alert
type={freshness.coversDeadline === false ? 'error' : 'warning'}
showIcon
className="wle-alert"
message={freshness.coversDeadline === false
? '物流快照未覆盖本轮核验时刻,异常量偏大'
: '物流数据可能滞后'}
description={freshness.hint}
/>
) : null}
<div className="wle-stats">
<Card bordered={false} className="wle-stat">
<Statistic
title={(
<Tooltip title="核验日的前一天,12 个发货仓发出的订单总数(按内部单号去重)">
<span>前一日发货</span>
</Tooltip>
)}
value={notStarted ? 0 : (summary.shippedOrders ?? 0)}
suffix="单"
formatter={(v) => (notStarted ? '—' : number(v))}
/>
<Text type="secondary" className="wle-stat__sub">
共 {notStarted ? '—' : number(summary.packageCount)} 个包裹
</Text>
</Card>
<Card bordered={false} className="wle-stat">
<Statistic
title={(
<Tooltip title="到 09:00 核验时刻物流仍不正常的订单(按内部单号去重)">
<span>转运异常</span>
</Tooltip>
)}
value={notStarted ? 0 : (summary.exceptionOrders ?? 0)}
suffix="单"
formatter={(v) => (notStarted ? '—' : number(v))}
valueStyle={{ color: (summary.exceptionOrders || 0) > 0 ? '#cf1322' : undefined }}
/>
<Text type="secondary" className="wle-stat__sub">
涉及 {notStarted ? '—' : number(summary.exceptionPackages)} 个包裹
</Text>
</Card>
<Card bordered={false} className="wle-stat">
<Statistic
title="异常率"
value={notStarted ? 0 : (summary.exceptionRate ?? 0)}
suffix="%"
formatter={(v) => (notStarted ? '—' : number(v, 1))}
valueStyle={{ color: (summary.exceptionRate || 0) >= 10 ? '#d46b08' : undefined }}
/>
<Text type="secondary" className="wle-stat__sub">
异常单 ÷ 前一日发货单
</Text>
</Card>
<Card bordered={false} className="wle-stat">
<Statistic
title="涉及仓库"
value={notStarted ? 0 : (summary.centerCount ?? 0)}
suffix={`/ ${centers.length || 12}`}
formatter={(v) => (notStarted ? '—' : number(v))}
/>
<Text type="secondary" className="wle-stat__sub">
有转运异常的仓
</Text>
</Card>
</div>
{types.length ? (
<div className="wle-types">
{types.map((t) => {
const count = summary.byType?.[t.key] || 0;
const isActive = activeType === t.key;
return (
<Tooltip key={t.key} title={t.desc || t.label}>
<button
type="button"
className={`wle-type${isActive ? ' is-active' : ''}${count ? '' : ' is-empty'}`}
onClick={() => setActiveType(isActive ? '' : t.key)}
>
<Tag bordered={false} color={t.tone}>{t.label}</Tag>
<span className="wle-type__count">{notStarted ? '—' : count}</span>
</button>
</Tooltip>
);
})}
{activeType ? (
<button type="button" className="wle-type__clear" onClick={() => setActiveType('')}>
取消筛选
</button>
) : null}
</div>
) : null}
<div className="wle-body">
<Card
bordered={false}
className="wle-centers"
title="发货仓"
extra={<Text type="secondary" className="wle-centers__hint">点仓库看明细</Text>}
>
<Spin spinning={loading && !data} size="small">
<ul className="wle-centers__list">
{centers.map((c) => {
const isActive = activeCenter === c.key;
return (
<li key={c.key}>
<Tooltip
placement="right"
title={`${c.name}:前一日发货 ${c.shippedOrders} 单,转运异常 ${c.exceptionOrders} 单`
+ `${c.shippedOrders ? `(异常率 ${c.exceptionRate}%)` : ''}`}
>
<button
type="button"
className={[
'wle-center',
isActive ? 'is-active' : '',
c.exceptionOrders ? '' : 'is-clean',
].filter(Boolean).join(' ')}
onClick={() => setActiveCenter(isActive ? '' : c.key)}
>
<span className="wle-center__name">{c.name}</span>
<span className={`wle-center__badge${c.exceptionOrders > 0 ? ' is-warn' : ''}`}>
{notStarted ? '—' : (c.exceptionOrders > 0 ? c.exceptionOrders : '—')}
</span>
</button>
</Tooltip>
</li>
);
})}
</ul>
</Spin>
</Card>
<Card
bordered={false}
className="wle-detail"
title={(
<span>
转运异常明细
<span className="wle-detail__scope">
{[activeCenterName, activeType ? types.find((t) => t.key === activeType)?.label : '']
.filter(Boolean).join(' · ') || '全部仓库'}
{' · '}
{detailOrders.length} 条
{data?.truncated && allOrders.length >= (data?.detailLimit || 0)
? `(已截断,仅显示前 ${data.detailLimit} 条)`
: ''}
</span>
</span>
)}
extra={(activeCenter || activeType) ? (
<button
type="button"
className="wle-detail__clear"
onClick={() => { setActiveCenter(''); setActiveType(''); }}
>
查看全部
</button>
) : null}
>
<Spin spinning={loading && !data}>
{notStarted ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={`未到核验时间,${String(batch?.deadline || '').slice(5, 16)} 开始核验`}
/>
) : detailOrders.length ? (
<Table
rowKey={(r) => `${r.oId}-${r.trackingNo || r.sendTime}-${r.exceptionType}`}
size="small"
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 条` }}
dataSource={detailOrders}
columns={DETAIL_COLUMNS}
scroll={{ x: 1900 }}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={activeCenter || activeType
? '这个筛选条件下没有转运异常'
: `前一日发货的订单物流都正常(或还没到核验时刻)`}
/>
)}
</Spin>
</Card>
</div>
<Text type="secondary" className="wle-foot">
说明:每天 <b>09:00</b> 核验<b>前一日已发货</b>订单的物流记录,只看两个卡点 ——
<b>未揽收</b>(快递没来揽)和<b>已揽收未中转</b>(揽了但没有下一步中转记录,卡在网点),
外加 <b>无物流单 / 快递报异常 / 退回</b>。
<b>运输中、派送中、已签收一律正常</b>:「运输」字样之后的中转记录本来就不回传聚水潭,不作考核。
子仓并入主仓统计;<b>京仓-横林二仓京东云仓</b> 与各代发仓不在 12 个发货仓范围内。
<br />
<b>不参与考核</b>:
{exclusions?.carriers?.length ? (
<>承运商 <b>{exclusions.carriers.join(' / ')}</b>(这些专线轨迹不回传聚水潭或本身无快递轨迹);</>
) : null}
{exclusions?.conditionalCarriers?.length ? (
<>
{exclusions.conditionalCarriers.map((r) => (
<span key={r.keyword}>
<b>{r.keyword}</b> 只在 <b>{(r.keepCenters || []).join(' / ')}</b> 考核,
其它仓异地用同一账号打单的剔出({r.reason});
</span>
))}
</>
) : null}
<b>已取消 / 作废 / 拦截</b>的订单
{exclusions?.orderLabelKeywords?.length
? <>(含标签「{exclusions.orderLabelKeywords.join('」「')}」)</>
: null}
;<b>{exclusions?.orderTypeLabels?.length ? exclusions.orderTypeLabels.join(' / ') : '刷单 / 信誉单'}</b>
(按订单标签「刷单」「转空包」识别)—— 都不是真实发货,不用跟物流。
{exclusions && (exclusions.carrierOrders > 0 || exclusions.voidedOrders > 0
|| exclusions.nonRealOrders > 0) ? (
<>
{' '}本批已剔除 <b>{(exclusions.carrierOrders || 0) + (exclusions.voidedOrders || 0)
+ (exclusions.nonRealOrders || 0)}</b> 单
(承运商 {exclusions.carrierOrders || 0} 单、已终止 {exclusions.voidedOrders || 0} 单、
刷单/信誉单 {exclusions.nonRealOrders || 0} 单),
{exclusions.carrierReasons && Object.keys(exclusions.carrierReasons).length ? (
<>
其中承运商剔除:<b>
{Object.entries(exclusions.carrierReasons)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k} ${v} 单`)
.join(' / ')}
</b>;
</>
) : null}
剔除的单<b>既不算异常也不算发货分母</b>,所以异常率不会被稀释。
</>
) : null}
<br />
页面按<b>当前物流快照</b>计算:核验时刻之后才更新的轨迹(比如 9 点后才揽收、才签收)会让对应异常自动消失,
所以这里的数字是「到此刻为止还异常的」—— 想留档 09:00 那一刻的结论,需要每天 9 点落一份快照。
</Text>
<Modal
title="异常通知 · 每天把异常明细推到钉钉群"
open={notifyOpen}
onCancel={() => setNotifyOpen(false)}
width={720}
destroyOnHidden
maskClosable={false}
styles={{ body: { maxHeight: '62vh', overflowY: 'auto' } }}
footer={[
<Button key="close" onClick={() => setNotifyOpen(false)}>关闭</Button>,
<Button key="probe" onClick={() => handleRunNotify('probe')} loading={notifyRunning} disabled={notifySaving}>
测试通道
</Button>,
<Button key="preview" onClick={() => handleRunNotify('dry')} loading={notifyRunning} disabled={notifySaving}>
预览正文
</Button>,
<Button key="save" onClick={() => handleSaveNotify()} loading={notifySaving}>保存</Button>,
<Button key="run" type="primary" onClick={() => handleRunNotify('send')} loading={notifyRunning} disabled={notifySaving}>
立即推送到群
</Button>,
]}
>
<Spin spinning={notifyLoading}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type={notifyConfig?.ready ? 'success' : 'warning'}
showIcon
message={notifyConfig?.ready
? `通道已配好,每天 ${notifyForm.sendTime} 自动推送(0 条异常也推,用来确认任务在跑)`
: 'Webhook 地址还没配,填好保存后点「测试通道」验一下'}
description={notifyConfig?.ready ? null : (
<span>
缺:<b>{(notifyConfig?.missingFields || []).join(' / ') || '(尚未读取)'}</b>
</span>
)}
/>
<div className="wle-notify__row">
<span className="wle-notify__label">每天推送</span>
<Switch
checked={notifyForm.enabled}
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
checkedChildren="开"
unCheckedChildren="关"
/>
<TimePicker
format="HH:mm"
value={notifyTimeValue}
allowClear={false}
minuteStep={5}
style={{ width: 96 }}
onChange={(value) => {
if (!value) return;
setNotifyForm((old) => ({ ...old, sendTime: value.format('HH:mm') }));
}}
/>
<Text type="secondary">核验在 09:00,推送默认 09:10(早于核验点会推空,系统自己会跳过)</Text>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">Webhook</span>
<Input
value={notifyForm.webhookUrl}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxxx"
onChange={(e) => setNotifyForm((old) => ({ ...old, webhookUrl: e.target.value }))}
style={{ flex: 1, minWidth: 360 }}
/>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">加签密钥</span>
<Input.Password
value={notifyForm.webhookSecret}
placeholder={notifyConfig?.webhookSecretConfigured
? `已保存 ${notifyConfig.webhookSecretPreview || ''},留空表示不修改`
: 'SEC 开头;安全设置选「关键词 / IP」时留空'}
onChange={(e) => setNotifyForm((old) => ({ ...old, webhookSecret: e.target.value }))}
style={{ flex: 1, minWidth: 300 }}
/>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">明细条数</span>
<InputNumber
min={0}
max={notifyConfig?.maxDetailRowsLimit || 50}
precision={0}
value={notifyForm.maxDetailRows}
onChange={(value) => setNotifyForm((old) => ({ ...old, maxDetailRows: value ?? 0 }))}
style={{ width: 96 }}
/>
<Text type="secondary">
消息里最多列几条明细(0 = 只发汇总);钉钉文本上限 20000 字节,超了会自动精简
</Text>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">@所有人</span>
<Switch
checked={notifyForm.atAll}
onChange={(checked) => setNotifyForm((old) => ({ ...old, atAll: checked }))}
checkedChildren="开"
unCheckedChildren="关"
/>
<Text type="secondary">打开后每批异常都会 @所有人(有异常才推,0 条也会推一条平安消息)</Text>
</div>
<div className="wle-notify__row">
<span className="wle-notify__label">页面链接</span>
<Input
value={notifyForm.pageUrl}
placeholder="消息末尾「查看全部」用的地址(留空用默认生产地址)"
onChange={(e) => setNotifyForm((old) => ({ ...old, pageUrl: e.target.value }))}
style={{ flex: 1, minWidth: 360 }}
/>
</div>
{notifyPreview ? (
<div className="wle-notify__preview">
<div className="wle-notify__preview-head">
<Text type="secondary">
{notifyPreview.batchLabel || '预览'}
{' '}| 异常 <b>{notifyPreview.rowCount}</b> 条
{' '}| 消息里列出 <b>{notifyPreview.listedCount}</b> 条
{' '}| {Math.round((notifyPreview.bytes / 1024) * 10) / 10} KB
{notifyPreview.truncated ? '(超长已精简)' : ''}
</Text>
<Button type="link" size="small" onClick={() => setNotifyPreview(null)}>收起</Button>
</div>
<pre>{notifyPreview.text || '(消息正文为空)'}</pre>
</div>
) : null}
<div className="wle-notify__last">
<Text type="secondary">
上次推送:
{notifyLastState.lastSentDate
? <> {notifyLastState.lastSentDate} {String(notifyLastState.lastSentAt || '').slice(11, 19)}
{' '}· 异常 {notifyLastState.rowCount ?? 0} 条
{' '}· 列出 {notifyLastState.listedCount ?? '—'} 条
{notifyLastState.truncated ? '(超长已精简)' : ''}</>
: ' 还没有成功推送记录'}
</Text>
{notifyLastState.lastError ? (
<div className="wle-notify__error">
<Text type="danger">最近一次失败:{String(notifyLastState.lastError).slice(0, 300)}</Text>
</div>
) : null}
</div>
<Collapse size="small" items={NOTIFY_HELP_ITEMS} />
</Space>
</Spin>
</Modal>
</SycmPage>
);
}
export default WarehouseLogisticsException;

View File

@ -1,345 +0,0 @@
.wle-page {
padding: 16px 20px 24px;
}
.wle-header {
margin-bottom: 14px;
}
.wle-header .ant-typography {
margin-bottom: 4px;
}
/* 口径说明:页面顶部是深色渐变,直接放浅色小字看不清,给一块浅底 */
.wle-note {
display: block;
max-width: 1180px;
margin-top: 8px;
padding: 10px 14px;
background: var(--sycm-bg-container);
border: 1px solid var(--sycm-border-color);
border-radius: 8px;
font-size: 12px;
line-height: 1.75;
}
/* ============ 批次状态条 ============ */
.wle-status {
margin-bottom: 12px;
}
.wle-status__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.wle-status__batch {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.wle-status__badge {
padding: 1px 8px;
border-radius: 4px;
background: rgba(22, 119, 255, 0.12);
color: #0958d9;
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.wle-status__badge--idle {
background: rgba(136, 135, 128, 0.14);
color: var(--sycm-text-tertiary);
}
.wle-status__label {
font-size: 13px;
color: var(--sycm-text-primary);
}
.wle-status__window,
.wle-status__time {
font-size: 12px;
color: var(--sycm-text-tertiary);
}
.wle-alert {
margin-bottom: 12px;
}
/* ============ 统计卡 ============ */
.wle-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.wle-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.wle-stat {
height: 100%;
}
.wle-stat__sub {
display: block;
margin-top: 4px;
font-size: 12px;
}
/* ============ 异常类型筛选 ============ */
.wle-types {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.wle-type {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px 2px 2px;
border: 1px solid var(--sycm-border-color);
border-radius: 16px;
background: var(--sycm-bg-container);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.wle-type:hover {
border-color: rgba(22, 119, 255, 0.45);
}
.wle-type.is-active {
border-color: rgba(22, 119, 255, 0.6);
background: rgba(22, 119, 255, 0.08);
}
.wle-type.is-empty {
opacity: 0.55;
}
.wle-type__count {
font-size: 12px;
font-weight: 600;
color: var(--sycm-text-primary);
font-variant-numeric: tabular-nums;
}
.wle-type__clear {
padding: 0;
border: none;
background: transparent;
font-size: 12px;
color: #1677ff;
cursor: pointer;
}
/* ============ 主从两栏 ============ */
.wle-body {
display: flex;
align-items: flex-start;
gap: 14px;
}
.wle-centers {
flex: none;
width: 272px;
}
.wle-centers__hint {
font-size: 11px;
}
.wle-centers__list {
margin: 0;
padding: 0;
list-style: none;
}
.wle-center {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 8px;
margin-bottom: 2px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
font-size: 13px;
text-align: left;
color: var(--sycm-text-primary);
cursor: pointer;
transition: background 0.12s ease;
}
.wle-center:hover {
background: var(--sycm-bg-subtle);
}
.wle-center.is-active {
border-color: rgba(22, 119, 255, 0.45);
background: rgba(22, 119, 255, 0.08);
}
.wle-center.is-clean .wle-center__name {
color: var(--sycm-text-tertiary);
}
.wle-center__name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.wle-center__badge {
flex: none;
min-width: 24px;
padding: 0 6px;
border-radius: 9px;
background: rgba(136, 135, 128, 0.12);
font-size: 11px;
font-weight: 600;
line-height: 17px;
text-align: center;
color: var(--sycm-text-tertiary);
font-variant-numeric: tabular-nums;
}
.wle-center__badge.is-warn {
background: rgba(255, 77, 79, 0.16);
color: #cf1322;
}
.wle-detail {
flex: 1 1 auto;
min-width: 0;
}
.wle-detail__scope {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--sycm-text-tertiary);
}
.wle-detail__clear {
padding: 0;
border: none;
background: transparent;
font-size: 12px;
color: #1677ff;
cursor: pointer;
}
.wle-detail__hours {
color: #cf1322;
font-variant-numeric: tabular-nums;
}
.wle-detail__soid {
font-variant-numeric: tabular-nums;
}
.wle-foot {
display: block;
margin-top: 12px;
font-size: 12px;
color: var(--sycm-text-tertiary);
}
/* 窄屏:仓库列表改两列网格,明细整行下移 */
@media (max-width: 1200px) {
.wle-body {
flex-direction: column;
}
.wle-centers {
width: 100%;
}
.wle-centers__list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 10px;
}
}
/* ── 异常通知(每天推 Excel 到钉钉群)───────────────────────────── */
.wle-notify__row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wle-notify__label {
flex: 0 0 88px;
font-size: 13px;
color: var(--sycm-text-secondary);
}
.wle-notify__last {
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
}
.wle-notify__error {
margin-top: 4px;
}
.wle-notify__help {
font-size: 12px;
line-height: 1.8;
color: var(--sycm-text-secondary);
}
.wle-notify__help code {
padding: 0 4px;
border-radius: 3px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04));
}
.wle-notify__preview {
padding: 10px 12px;
border: 1px solid var(--sycm-border, rgba(0, 0, 0, 0.08));
border-radius: 8px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
line-height: 1.8;
}
.wle-notify__preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wle-notify__preview pre {
margin: 8px 0 0;
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-bg-container, #fff);
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
line-height: 1.7;
/* 30 条明细大概 40 行,别把整个弹窗撑爆;内部滚动看全 */
max-height: 280px;
overflow: auto;
}

View File

@ -1,546 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Empty, Modal, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
} from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness';
import './style.css';
const { Title, Text } = Typography;
// 核验点只有 13:00 / 18:00 两个,10 分钟刷新一次足够。
// 接口要扫两张订单表 + 出库表(rpt 下全是视图),冷启动 40~60 秒,别跟着其它页面的轮询节奏跑。
const AUTO_REFRESH_MS = 10 * 60 * 1000;
const number = (value, digits = 0) => (
value === null || value === undefined || value === ''
? '0'
: Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
);
// 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」。
// ⚠️ 口径放开后(含历史未发单)会出现跨天的超时,必须给「天」,否则显示成「8640 小时」。
const formatOverdue = (minutes) => {
const m = Number(minutes);
if (!Number.isFinite(m) || m <= 0) return '—';
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
const rest = m % 60;
if (h < 24) return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
const d = Math.floor(h / 24);
const hr = h % 24;
return hr ? `${d} 天 ${hr} 小时` : `${d} 天`;
};
/**
* 「不计考核」清单的列(待审核 / 订单异常)。
* 业务反馈(2026-10-20):贴进来的一批单号在聚水潭查不到,实测真实状态是
* 「已取消(发货前售后)」或「异常」—— 这类单不该算仓库超时。
* 这里把它们单列出来,回答「为什么这些单没进超时列表」。
*/
const HELD_COLUMNS = [
{ title: '内部单号', dataIndex: 'oId', width: 100 },
{ title: '线上订单号', dataIndex: 'soId', width: 170, ellipsis: true },
{ title: '店铺', dataIndex: 'shopName', width: 170, ellipsis: true },
{ title: '发货仓', dataIndex: 'warehouseName', width: 160, ellipsis: true },
{
title: '挂起原因',
dataIndex: 'holdReason',
width: 110,
render: (v) => (
<Tag bordered={false} color={v === '订单异常' ? 'red' : 'default'}>{v || '—'}</Tag>
),
},
{
title: '发货时间',
dataIndex: 'sendTime',
width: 178,
ellipsis: true,
render: (v, row) => {
if (v) return v;
const no = row?.logisticId;
if (no) {
return (
<Tooltip title={`已打单:${row.logisticCompany || ''} ${no}(${row.logisticStatus || '未揽收'})`}>
<Tag bordered={false} color="blue">已打单 · {row.logisticStatus || '未揽收'}</Tag>
</Tooltip>
);
}
return <Tag bordered={false} color="default">未发货</Tag>;
},
},
{
title: '审核时间',
dataIndex: 'auditTime',
width: 152,
ellipsis: true,
render: (v) => (v || <span className="wst-detail__fallback">未审核</span>),
},
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
{ title: '已挂起', dataIndex: 'heldDays', width: 88, render: (v) => (v > 0 ? `${v} 天` : '—') },
];
const DETAIL_COLUMNS = [
// 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单)
{ title: '内部单号', dataIndex: 'oId', width: 110 },
// 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户)
{
title: '线上订单号',
dataIndex: 'soId',
width: 178,
ellipsis: true,
render: (v) => (v ? <span className="wst-detail__soid">{v}</span> : '—'),
},
{
title: '店铺',
dataIndex: 'shopName',
width: 180,
ellipsis: true,
render: (v) => (v || '—'),
},
{
title: '发货仓',
dataIndex: 'warehouseName',
width: 170,
ellipsis: true,
render: (v) => (v || '—'),
},
{
// 发货时间 = 聚水潭回传的发货时间(业务 2026-10-20:明细里不要创建时间,要发货时间)
title: (
<Tooltip title="聚水潭回传的发货时间(send_time)。为空但有运单号的,说明仓库已打单、还没揽收/回传发货时间,会显示成「已打单」">
<span>发货时间</span>
</Tooltip>
),
dataIndex: 'sendTime',
width: 178,
ellipsis: true,
render: (v, row) => {
if (v) return v;
const no = row?.logisticId;
if (no) {
return (
<Tooltip title={`已打单:${row.logisticCompany || ''} ${no}(${row.logisticStatus || '未揽收'})。聚水潭还没回传发货时间,所以还没从超时列表里消失`}>
<Tag bordered={false} color="blue">已打单 · {row.logisticStatus || '未揽收'}</Tag>
</Tooltip>
);
}
return <Tag bordered={false} color="default">未发货</Tag>;
},
},
{
// 审核时间 = **考核基准**,取聚水潭「审核时间」列(最后一次审核,重审会刷新)
title: (
<Tooltip title="= 聚水潭订单列表「审核时间」那一列(最后一次审核,重新审核会刷新)。今天 7:00 前审核的 13:00 核验;截止 16:00 前审核的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
<span>审核时间</span>
</Tooltip>
),
dataIndex: 'auditTime',
width: 152,
ellipsis: true,
render: (v, row) => (v
? v
: (
<Tooltip title="没取到审核时间,已回退创建/付款时间参与分轮与计时">
<span className="wst-detail__fallback">{row?.benchmarkTime || '—'} *</span>
</Tooltip>
)),
},
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
{
// 发货状态:聚水潭实时状态(发货中 = 已审核未发货,即考核对象)
title: (
<Tooltip title="聚水潭订单实时状态。发货中 = 已审核、仓库还没发货">
<span>发货状态</span>
</Tooltip>
),
dataIndex: 'state',
width: 104,
render: (v) => (
<Tag bordered={false} color={v === '发货中' ? 'orange' : 'default'}>{v || '—'}</Tag>
),
},
{
title: '物流状态',
dataIndex: 'logisticStatus',
width: 100,
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : '—'),
},
{
title: (
<Tooltip title="从审核时间到本轮核验时刻(13:00 / 18:00)等了多久">
<span>已等时长</span>
</Tooltip>
),
dataIndex: 'overdueMinutes',
width: 112,
render: (v) => <span className="wst-detail__overdue">{formatOverdue(v)}</span>,
},
];
/**
* 仓储中心 24 小时发货时效(独立页面)
* ────────────────────────────────────────────────────────────────
* 业务口径(yanhui 2026-10-20):
* · 11 个主仓,子仓并入主仓一起算;
* · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验
* (含 7 点前未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
* 今天之前审核仍未发的(历史未发)两个轮次都算;
* **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点);
* 轮次归属和「已等时长」都按**审核时间**算(聚水潭「审核时间」列,最后一次审核);
* · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天;
* · 已出库 / 待出库以**聚水潭发货时间**为准(有 send_time = 已发);
* · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。
*
* 数据来自 /api/warehouse-ship-timeliness/list(service 层见
* backend/src/services/warehouseShipTimelinessService.js)。
*/
function WarehouseShipTimeliness() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 选中的主仓:为空时右侧展示全部仓的超时明细
const [activeCenter, setActiveCenter] = useState('');
// 「业务挂起(疑问单)」明细弹窗
const [heldOpen, setHeldOpen] = useState(false);
// force=true 时带 force=1 让后端绕过 5 分钟结果缓存重查库;
// 定时轮询走缓存(这张视图单次要几十秒,每轮都重查会把库压死)
const load = useCallback(async (force = false) => {
setLoading(true);
setError('');
try {
const result = await getWarehouseShipTimeliness({ limit: 1000, force: force ? 1 : 0 });
if (!result || result.success === false) {
setError(result?.error || '加载发货时效失败');
return;
}
setData(result.data || null);
} catch (loadError) {
setError(loadError?.response?.data?.error || loadError?.message || '加载发货时效失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const timer = window.setInterval(() => load(), AUTO_REFRESH_MS);
return () => window.clearInterval(timer);
}, [load]);
const centers = useMemo(() => (Array.isArray(data?.centers) ? data.centers : []), [data]);
const orders = useMemo(() => (Array.isArray(data?.orders) ? data.orders : []), [data]);
// 业务挂起(疑问单)—— 与 orders 互斥,两者相加才是待出库池
const heldOrders = useMemo(
() => (Array.isArray(data?.heldOrders) ? data.heldOrders : []),
[data]
);
const summary = data?.summary || {};
const round = data?.round || null;
const freshness = data?.freshness || null;
// 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字**
const notStarted = Boolean(data?.notStarted);
const hasRound = Boolean(round);
const cutoffText = String(round?.windowEnd || '').slice(11, 16);
const windowEndText = String(data?.window?.end || '').slice(11, 16);
// 仓级例外窗口:18:00 轮里 山东两仓 15:00、河南洛宁 13:00(业务 2026-10-20 指定)
const windowOverrides = Array.isArray(data?.window?.overrides) ? data.window.overrides : [];
const overrideText = windowOverrides
.map((o) => `${o.name} ${String(o.hour).padStart(2, '0')}:00`)
.join('、');
// 未到核验时间时的提示文案,全部由后端给,前端不写死时刻
const nextRoundText = String(data?.nextRoundAt || '').slice(11, 16) || '13:00';
const nextWindowEndText = String(data?.nextWindowEnd || '').slice(11, 16) || '07:00';
const activeCenterName = useMemo(() => {
if (!activeCenter) return '';
const hit = centers.find((c) => c.key === activeCenter);
return hit ? hit.name : activeCenter;
}, [activeCenter, centers]);
const detailOrders = useMemo(() => (
activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders
), [activeCenter, orders]);
return (
<SycmPage className="wst-page">
<header className="wst-header">
<Title level={3}>仓储中心 24 小时发货时效</Title>
<Text type="secondary">
按聚水潭订单实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
</Text>
<Text type="secondary" className="wst-note">
每天 <b>13:00</b> 核验「<b>今天 07:00 前审核</b>」、<b>18:00</b> 核验「<b>截止 16:00 前审核</b>」、
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前审核、至今未发的历史未发单</b>
(18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
13:00 之前不核验,显示「未到核验时间」。
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前审核</b>的单、
<b>河南洛宁仓</b> 只看 <b>13:00 前审核</b>的单(核验时刻仍是 18:00)。
<b>轮次与「已等时长」都按审核时间算</b>(= 聚水潭「审核时间」列,最后一次审核);
明细并列「审核时间 / 发货时间」,方便直接和聚水潭对账。
<b>已发 / 未发以聚水潭发货时间为准</b>:有发货时间就是已发,不再用订单表的发货字段反推
—— 该字段经常不回写,会把早就发货的单误判成超时。
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
<b>自提 / 中铁 / 壹米滴答 三个快递不考核</b>(客户自提或专线大件,仓库控制不了运输),
已按物流公司自动剔除。
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
</Text>
</header>
<Card className="wst-status" bordered={false}>
<div className="wst-status__row">
<div className="wst-status__round">
{hasRound ? (
<>
<span className="wst-status__badge">{round.label}</span>
<span className="wst-status__label">
核验今天 {cutoffText} 前审核的订单
</span>
<span className="wst-status__window">
含历史未发 · 到点未发即超时
{overrideText ? `(${overrideText} 为仓级例外)` : ''}
</span>
</>
) : (
<>
<span className="wst-status__badge wst-status__badge--idle">未到核验时间</span>
<span className="wst-status__label">
{nextRoundText} 开始首轮核验
(核验今天 {nextWindowEndText} 前审核的订单)
</span>
</>
)}
</div>
<Space size={8}>
{data?.generatedAt ? (
<Text type="secondary" className="wst-status__time">
数据更新于 {String(data.generatedAt).slice(11, 19)}
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s,点刷新重查)` : ''}
</Text>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
</Space>
</div>
</Card>
{error ? (
<Alert
type="error"
showIcon
className="wst-alert"
message={error}
action={<Button size="small" onClick={load}>重试</Button>}
/>
) : null}
{freshness && (freshness.stale || freshness.coversDeadline === false) ? (
<Alert
// 快照没覆盖到核验时刻 → 数字必然偏大,用红色;只是滞后用黄色
type={freshness.coversDeadline === false ? 'error' : 'warning'}
showIcon
className="wst-alert"
message={freshness.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后'}
description={freshness.hint}
/>
) : null}
<div className="wst-stats">
<Card bordered={false} className="wst-stat">
<Statistic
title={(
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按审核时间分轮),不设满 24 小时门槛">
<span>超时未发货</span>
</Tooltip>
)}
value={notStarted ? 0 : (summary.overdueUnshipped ?? 0)}
suffix="单"
formatter={(v) => (notStarted ? '—' : number(v))}
valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }}
/>
</Card>
<Card
bordered={false}
className="wst-stat wst-stat--held"
onClick={() => setHeldOpen(true)}
>
<Statistic
title={(
<Tooltip title="已支付待审核(还没审单,仓库没接到)和订单异常的单。这类单不进仓库考核,点开看明细。">
<span>不计考核 <span className="wst-stat__more">明细</span></span>
</Tooltip>
)}
value={notStarted ? 0 : (summary.heldCount ?? 0)}
suffix="单"
formatter={(v) => (notStarted ? '—' : number(v))}
valueStyle={{ color: (summary.heldCount || 0) > 0 ? '#8c8c8c' : undefined }}
/>
</Card>
<Card bordered={false} className="wst-stat">
<Statistic
title="涉及仓库"
value={notStarted ? 0 : (summary.overdueCenterCount ?? 0)}
suffix="个"
formatter={(v) => (notStarted ? '—' : number(v))}
/>
</Card>
<Card bordered={false} className="wst-stat">
<Statistic
title="最久未发货"
value={notStarted ? 0 : (summary.oldestDays ?? 0)}
suffix="天"
formatter={(v) => (notStarted ? '—' : number(v))}
valueStyle={{ color: (summary.oldestDays || 0) >= 7 ? '#d46b08' : undefined }}
/>
</Card>
</div>
<div className="wst-body">
<Card
bordered={false}
className="wst-centers"
title="仓储中心"
extra={<Text type="secondary" className="wst-centers__hint">点仓库看明细</Text>}
>
<Spin spinning={loading && !data} size="small">
<ul className="wst-centers__list">
{centers.map((c) => {
const isActive = activeCenter === c.key;
const disabled = c.placeholder || notStarted;
return (
<li key={c.key}>
<Tooltip
placement="right"
title={c.placeholder
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
: notStarted
? `今日 ${nextRoundText} 首轮核验开始后才展示超时数据`
: `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前审核的单,`
+ `超时未发货 ${c.overdueUnshipped} 单`
+ `${c.oldestDays > 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}`
+ `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}`
+ (c.warehouseCount > 1 ? `\n含子仓 ${c.warehouseCount} 个` : '')}
>
<button
type="button"
className={[
'wst-center',
isActive ? 'is-active' : '',
!c.overdueUnshipped ? 'is-empty' : '',
disabled ? 'is-disabled' : '',
].filter(Boolean).join(' ')}
onClick={() => {
if (disabled) return;
setActiveCenter(isActive ? '' : c.key);
}}
>
<span className="wst-center__name">{c.name}</span>
{c.placeholder ? (
<span className="wst-center__badge wst-center__badge--none">未接入</span>
) : (
<span className={`wst-center__badge${c.overdueUnshipped > 0 ? ' is-warn' : ''}`}>
{notStarted ? '—' : (c.overdueUnshipped > 0 ? c.overdueUnshipped : '—')}
</span>
)}
</button>
</Tooltip>
</li>
);
})}
</ul>
</Spin>
</Card>
<Card
bordered={false}
className="wst-detail"
title={(
<span>
超时未发货订单
<span className="wst-detail__scope">
{activeCenterName || '全部仓库'} · {detailOrders.length} 单
{data?.truncated && detailOrders.length >= (data?.detailLimit || 0)
? `(已截断,仅显示前 ${data.detailLimit} 条)`
: ''}
</span>
</span>
)}
extra={activeCenter ? (
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>查看全部</button>
) : null}
>
<Spin spinning={loading && !data}>
{notStarted ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={`未到核验时间,今日 ${nextRoundText} 开始首轮核验`}
/>
) : detailOrders.length ? (
<Table
rowKey={(r) => `${r.source}-${r.oId}`}
size="small"
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }}
dataSource={detailOrders}
columns={DETAIL_COLUMNS}
scroll={{ x: 1642 }}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本轮没有超时未发货的订单" />
)}
</Spin>
</Card>
</div>
<Modal
title={`不计考核(待审核 / 异常)· ${heldOrders.length} 单`}
open={heldOpen}
onCancel={() => setHeldOpen(false)}
footer={null}
width={1080}
destroyOnClose
>
<Alert
type="info"
showIcon
className="wst-alert"
message="「已支付待审核」= 还没审单,仓库根本没接到;「订单异常」= 聚水潭标记异常(缺货/地址/风控等)。两类都不该算仓库超时,故单列于此。"
/>
{heldOrders.length ? (
<Table
rowKey={(r) => `${r.oId}`}
size="small"
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }}
dataSource={heldOrders}
columns={HELD_COLUMNS}
scroll={{ x: 1280 }}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本轮没有待审核 / 异常的单" />
)}
</Modal>
<Text type="secondary" className="wst-foot">
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
轮次与「已等时长」都按审核时间(聚水潭显示值)算。
发货 = 聚水潭回传的发货时间。
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
<b>自提 / 中铁 / 壹米滴答不考核</b>
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
</Text>
</SycmPage>
);
}
export default WarehouseShipTimeliness;

View File

@ -1,255 +0,0 @@
.wst-page {
padding: 16px 20px 24px;
}
.wst-header {
margin-bottom: 14px;
}
.wst-header .ant-typography {
margin-bottom: 4px;
}
.wst-note {
display: block;
max-width: 1180px;
margin-top: 8px;
padding: 10px 14px;
/* 页面顶部是深色渐变,直接放浅色小字会看不清,给口径说明一块浅底 */
background: var(--sycm-bg-container);
border: 1px solid var(--sycm-border-color);
border-radius: 8px;
font-size: 12px;
line-height: 1.75;
}
/* ============ 轮次状态条 ============ */
.wst-status {
margin-bottom: 12px;
}
.wst-status__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.wst-status__round {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.wst-status__badge {
padding: 1px 8px;
border-radius: 4px;
background: rgba(22, 119, 255, 0.12);
color: #0958d9;
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.wst-status__badge--idle {
background: rgba(136, 135, 128, 0.14);
color: var(--sycm-text-tertiary);
}
.wst-status__label {
font-size: 13px;
color: var(--sycm-text-primary);
}
.wst-status__window,
.wst-status__time {
font-size: 12px;
color: var(--sycm-text-tertiary);
}
.wst-alert {
margin-bottom: 12px;
}
/* ============ 统计卡 ============ */
/* 5 张卡(超时未出库 / 业务挂起 / 涉及仓库 / 最久未出库 / 合计金额)
用 grid 而不是 Row/Col:24 栅格除不尽 5,Row/Col 会把第 5 张挤到下一行。 */
.wst-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
@media (max-width: 1500px) {
.wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.wst-stat {
height: 100%;
}
/* 「业务挂起」卡可点开明细 */
.wst-stat--held {
cursor: pointer;
transition: box-shadow 0.2s;
}
.wst-stat--held:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.wst-stat__more {
font-size: 12px;
font-weight: 400;
color: #1677ff;
}
/* ============ 主从两栏 ============ */
.wst-body {
display: flex;
align-items: flex-start;
gap: 14px;
}
.wst-centers {
flex: none;
width: 264px;
}
.wst-centers__hint {
font-size: 11px;
}
.wst-centers__list {
margin: 0;
padding: 0;
list-style: none;
}
.wst-center {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 8px;
margin-bottom: 2px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
font-size: 13px;
text-align: left;
color: var(--sycm-text-primary);
cursor: pointer;
transition: background 0.12s ease;
}
.wst-center:hover {
background: var(--sycm-bg-subtle);
}
.wst-center.is-active {
border-color: rgba(22, 119, 255, 0.45);
background: rgba(22, 119, 255, 0.08);
}
.wst-center.is-empty .wst-center__name {
color: var(--sycm-text-tertiary);
}
.wst-center.is-disabled {
cursor: not-allowed;
opacity: 0.6;
}
.wst-center__name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.wst-center__badge {
flex: none;
min-width: 24px;
padding: 0 6px;
border-radius: 9px;
background: rgba(136, 135, 128, 0.12);
font-size: 11px;
font-weight: 600;
line-height: 17px;
text-align: center;
color: var(--sycm-text-tertiary);
font-variant-numeric: tabular-nums;
}
.wst-center__badge.is-warn {
background: rgba(255, 77, 79, 0.16);
color: #cf1322;
}
.wst-center__badge--none {
font-weight: 400;
}
.wst-detail {
flex: 1 1 auto;
min-width: 0;
}
.wst-detail__scope {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--sycm-text-tertiary);
}
.wst-detail__clear {
padding: 0;
border: none;
background: transparent;
font-size: 12px;
color: #1677ff;
cursor: pointer;
}
.wst-detail__overdue {
color: #cf1322;
font-variant-numeric: tabular-nums;
}
/* 创建时间没补到、回退成付款时间时的提示色(带 * 号,tooltip 有说明) */
.wst-detail__fallback {
color: var(--sycm-text-tertiary);
font-style: italic;
}
.wst-foot {
display: block;
margin-top: 12px;
font-size: 12px;
color: var(--sycm-text-tertiary);
}
/* 窄屏:仓库列表改为两列网格,明细整行下移 */
@media (max-width: 1200px) {
.wst-body {
flex-direction: column;
}
.wst-centers {
width: 100%;
}
.wst-centers__list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 10px;
}
}

View File

@ -19,11 +19,8 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
const MkhBomData = lazy(() => import('../pages/MkhBomData')); const MkhBomData = lazy(() => import('../pages/MkhBomData'));
const WarehouseShipTimeliness = lazy(() => import('../pages/WarehouseShipTimeliness'));
const WarehouseLogisticsException = lazy(() => import('../pages/WarehouseLogisticsException'));
const DailyInventory = lazy(() => import('../pages/DailyInventory')); const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis')); const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor')); const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
const lazyLoadingElement = <PageLoadingFallback />; const lazyLoadingElement = <PageLoadingFallback />;
@ -117,14 +114,6 @@ const router = createBrowserRouter([
path: 'planning/operation/mkh-bom-data', path: 'planning/operation/mkh-bom-data',
element: renderLazyComponent(MkhBomData), element: renderLazyComponent(MkhBomData),
}, },
{
path: 'planning/operation/warehouse-ship-timeliness',
element: renderLazyComponent(WarehouseShipTimeliness),
},
{
path: 'planning/operation/warehouse-logistics-exception',
element: renderLazyComponent(WarehouseLogisticsException),
},
{ {
path: 'planning/operation/daily-inventory', path: 'planning/operation/daily-inventory',
element: renderLazyComponent(DailyInventory), element: renderLazyComponent(DailyInventory),
@ -133,15 +122,6 @@ const router = createBrowserRouter([
path: 'planning/operation/stock-sales-analysis', path: 'planning/operation/stock-sales-analysis',
element: renderLazyComponent(StockSalesAnalysis), element: renderLazyComponent(StockSalesAnalysis),
}, },
{
path: 'planning/operation/picking-bin-sales-analysis',
element: renderLazyComponent(PickingBinSalesAnalysis),
},
// 缺货补货监控:拣货位缺货 → 从存货位补货
{
path: 'planning/operation/bin-replenish-monitor',
element: renderLazyComponent(BinReplenishMonitor),
},
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓; // 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线) // 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
{ {