Compare commits

..

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

51 changed files with 54 additions and 10965 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,73 +0,0 @@
'use strict';
const express = require('express');
const {
getReplenishExportRows,
getReplenishList,
} = require('../services/binReplenishService');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
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: 'orderQty', title: '当日订单' },
{ key: 'excess', title: '超出订单数' },
{ key: 'suggestQty', title: '建议补货量' },
{ key: 'shortfall', title: '存储缺口' },
];
/** 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,
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,
});
const fileName = `${payload.warehouse.shortName}缺货补货监控_${payload.orderDate || ''}`;
sendWorkbookDownload(res, {
fileName,
format,
sheets: [{
name: '缺货补货监控',
header: EXPORT_COLUMNS.map((column) => column.title),
rows: payload.rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
}],
});
} 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

@ -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,505 +0,0 @@
'use strict';
/**
* 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群
* ────────────────────────────────────────────────────────────────────────────
* 业务口径(2026-10-22 定稿):
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
* ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序,
* 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。
* ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分),
* 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
* 关键词一般是「异常」,缺了会被拒(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;
}
/**
* 明细一行:仓|快递单号|快递|异常类型
* ⚠️ 业务明确要求明细**不带发货时间**(10-22 反馈),别再加 sendTime 回去 ——
* 仓库看单子只需知道「哪个单、什么异常」,时间类信息一律到页面上看。
* ⚠️ 第二段用**快递单号**(`trackingNo` = 上游 `logistic_id`),不是内部单号 `oId`(10-22 二次要求)。
* 「未揽收 / 无物流单」这类包裹本来就没有快递单号 → 退回内部单号,否则群里这条无法定位。
* ⚠️ 末尾的「待处理小时数」(`pendingHours`)也已按 10-22 三次要求去掉,别再加回来。
*/
function buildDetailLine(row, index) {
const parts = [
cleanCell(row?.warehouseName, '未知仓'),
cleanCell(row?.trackingNo || row?.oId, '无单号'),
cleanCell(row?.carrier, '未知快递'),
cleanCell(row?.exceptionLabel, '异常'),
];
return `${index}. ${parts.join('|')}`;
}
/**
* 按仓分桶:组间按异常包裹数降序(与上面「按仓(异常包裹)」块一致),数量相同按首次出现顺序。
* 组内保持服务层原顺序(已按 pendingHours 降序,压得最久的排最前),别在这里再排。
*/
function bucketRowsByWarehouse(rows) {
const buckets = new Map();
const firstSeen = new Map();
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
const key = cleanCell(row?.warehouseName, '未知仓');
if (!buckets.has(key)) {
buckets.set(key, []);
firstSeen.set(key, index);
}
buckets.get(key).push(row);
});
return Array.from(buckets.entries())
.map(([key, items]) => ({ key, items, index: firstSeen.get(key) }))
.sort((a, b) => b.items.length - a.items.length || a.index - b.index);
}
/**
* 明细按仓聚集:同一个仓的行排在一起(10-22 业务要求,便于各仓在群里只看自己那一段)。
* - 组间:按该仓异常包裹数降序;组内:保持服务层原顺序(时长降序)。
* - 仓名取 `warehouseName`,缺失归到「未知仓」一组,避免 undefined 行被拆成散行。
*/
function groupRowsByWarehouse(rows) {
return bucketRowsByWarehouse(rows).flatMap((group) => group.items);
}
/**
* 按仓分配明细额度,返回**已按仓聚集**的行。
* ⚠️ 不能直接 `rows.slice(0, N)`:某个仓异常特别多时会一口气吃满额度,
* 真数据就出现过 40 条明细全是一个仓、其它仓一条都看不到(10-22 实测)。
* 规则:
* ① 仓数 ≥ 额度 → 每仓 1 条,砍掉的是异常最少的仓;
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法,保证总数正好等于额度)。
*/
function pickDetailRows(rows, limit) {
const groups = bucketRowsByWarehouse(rows);
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total));
if (quota === 0) return [];
if (quota >= total) return groups.flatMap((group) => group.items);
if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]);
const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 }));
const remaining = quota - groups.length;
const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0);
if (remaining > 0 && leftTotal > 0) {
alloc.forEach((item) => {
item.exact = (item.left / leftTotal) * remaining;
item.extra = Math.min(item.left, Math.floor(item.exact));
});
let assigned = alloc.reduce((sum, item) => sum + item.extra, 0);
// 取整剩下的零星额度给「小数部分最大」的仓,避免白浪费额度
[...alloc]
.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))
.forEach((item) => {
if (assigned >= remaining || item.extra >= item.left) return;
item.extra += 1;
assigned += 1;
});
alloc.forEach((item) => {
item.take += item.extra;
});
}
return alloc.flatMap((item) => item.group.items.slice(0, item.take));
}
/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 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) : [];
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
const detailAll = pickDetailRows(rows, maxDetailRows).map((row, index) => buildDetailLine(row, index + 1));
const wantCount = 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,
groupRowsByWarehouse,
pickDetailRows,
cleanCell,
pushWarehouseLogisticsExceptionNotify,
runWarehouseLogisticsExceptionNotifyJob,
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,463 +0,0 @@
'use strict';
/**
* 缺货补货监控(订单驱动版,2026-10-22 重写)
* ==========================================================================
* 业务需求(用户确认):
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣,
* 需要从「存储库位」把货补过去。
*
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
* 拣货库位(拣货仓位):以 XA / XD / XE 开头
* 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头
* 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。
*
* ── 数据源 ───────────────────────────────────────────────────────────
* 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
* 订单:rpt.jst_Orders × jst_OrderItem(线上店铺)
* + rpt.jst_qmcust_orderlist × jst_qmcust_orderlist_item(全民客户 B2B)
* + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
* 按 wh_id 会把多仓混成一个桶。
* 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」,
* 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。
*
* ── 判断逻辑(核心)──────────────────────────────────────────────────
* 对每一个 SKU(在拣货/存储位上有库存):
* 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
* 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
* 当日订单 orderQty = 该 SKU 在订单日期当天的订单量(含组合装展开)
* 超出订单数 excess = max(0, orderQty − pickQty)
* —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
*
* 状态:
* short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
* need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
* enough 拣货位充足(0 < orderQty ≤ pickQty)
* no_order 今日无单(orderQty = 0)
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
*
* ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min,
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
*/
const cloudDb = require('../config/cloudDatabase');
/**
* 仓库清单:**按 wms_co_id 取数**。
* 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定;
* 其他仓若用不同前缀,页面会标注,默认聚焦崔桥工厂仓。
*/
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_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
const MAX_PAGE_SIZE = 5000;
// ── 货位前缀规则(用户指定)────────────────────────────────────────
const PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']);
const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
const STATUS = Object.freeze({
SHORT: 'short',
NEED: 'need',
ENOUGH: 'enough',
NO_ORDER: 'no_order',
});
const STATUS_LABELS = Object.freeze({
short: '存储不足',
need: '需补货',
enough: '拣货位充足',
no_order: '今日无单',
});
// 排序权重:最紧急的排最前
const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 });
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 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;
}
/** 订单日期的窗口边界(仅用日期字符串,避免时区问题;副本 send_date 按中国营业日存储) */
function orderWindow(orderDate) {
const d = new Date(`${orderDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
const next = d.toISOString().slice(0, 10);
return { shipFrom: `${orderDate} 00:00:00`, shipTo: `${next} 00:00:00` };
}
// ── 副本取数 ────────────────────────────────────────────────────────
/**
* 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
* 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。
*/
async function loadReplicaInventory(warehouse) {
const sql = `
SELECT p.sku_id AS skuId,
SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN})
THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty,
SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN})
THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty,
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
AND ISNULL(p.bin, '') <> ''
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
GROUP BY p.sku_id`;
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 });
}
/** 该仓最近的订单日期(线上 + 全民客户 取最大 send_date 日期) */
async function loadOrderDate(warehouse) {
const rows = await cloudDb.query(
`SELECT MAX(d) AS d FROM (
SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
FROM rpt.jst_Orders WHERE wms_co_id = @wmsCoId
UNION ALL
SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
FROM rpt.jst_qmcust_orderlist WHERE wms_co_id = @wmsCoId
) t`,
{ wmsCoId: warehouse.wmsCoId },
{ requestTimeout: 120000 }
);
return (rows && rows[0] && rows[0].d) || null;
}
/** 当日(订单日期)各 SKU 的订单量:4 个来源分支,组合装展开为子件。 */
async function loadTodayOrderMap(warehouse, orderDate) {
if (!orderDate) return new Map();
const { shipFrom, shipTo } = orderWindow(orderDate);
const sql = `
WITH ship AS (
-- 1) 线上店铺订单:普通商品行
SELECT oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id = @wmsCoId
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 oi.sku_id
UNION ALL
-- 2) 线上店铺订单:组合装行 → 展开为子件
SELECT ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
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 = @wmsCoId
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 ci.src_sku_id
UNION ALL
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
SELECT oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
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 = @wmsCoId
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 oi.sku_id
UNION ALL
-- 4) 全民客户订单:组合装行 → 展开为子件
SELECT ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
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 = @wmsCoId
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 ci.src_sku_id
)
SELECT skuId, SUM(orderQty) AS orderQty
FROM ship
GROUP BY skuId`;
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
const rows = await runQuery(
sql,
{ wmsCoId: warehouse.wmsCoId, shipFrom, shipTo },
{ requestTimeout: 300000 }
);
const map = new Map();
(rows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (skuId) map.set(skuId, toNumber(row.orderQty));
});
return map;
}
// ── 组装 ────────────────────────────────────────────────────────────
function buildSkuRows({ invRows, orderMap, orderDate }) {
const rows = [];
(invRows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (!skuId) return;
const pickQty = round2(row.pickQty);
const storageQty = round2(row.storageQty);
const orderQty = round2(toNumber(orderMap.get(skuId), 0));
const excess = Math.max(0, round2(orderQty - pickQty));
let status = STATUS.NO_ORDER;
let suggestQty = 0;
let shortfall = 0;
let pickEmpty = false;
if (orderQty > 0) {
if (pickQty <= 0) pickEmpty = true;
if (excess > 0) {
const can = Math.min(excess, storageQty);
suggestQty = round2(can);
if (storageQty >= excess) {
status = STATUS.NEED;
} else {
status = STATUS.SHORT;
shortfall = round2(excess - storageQty);
}
} else {
status = STATUS.ENOUGH;
}
}
rows.push({
skuId,
productName: toText(row.productName, 300) || '',
supplier: toText(row.supplier, 120) || '',
status,
statusLabel: STATUS_LABELS[status],
pickQty,
storageQty,
totalQty: round2(pickQty + storageQty),
orderQty,
excess,
suggestQty,
shortfall,
pickEmpty,
});
});
return rows;
}
function buildSummary(rows, orderSkuCount) {
const summary = {
skuTotal: rows.length,
need: 0,
short: 0,
enough: 0,
noOrder: 0,
pickEmpty: 0,
suggestQtyTotal: 0,
orderSkuCount: orderSkuCount || 0,
orderQtyTotal: 0,
exceedTotal: 0,
shortfallTotal: 0,
pickTotal: 0,
storageTotal: 0,
};
const STATUS_SUM_KEY = { need: 'need', short: 'short', enough: 'enough', no_order: 'noOrder' };
rows.forEach((row) => {
const key = STATUS_SUM_KEY[row.status];
if (key && summary[key] !== undefined) summary[key] += 1;
summary.suggestQtyTotal += row.suggestQty || 0;
summary.orderQtyTotal += row.orderQty || 0;
summary.exceedTotal += row.excess || 0;
summary.shortfallTotal += row.shortfall || 0;
summary.pickTotal += row.pickQty || 0;
summary.storageTotal += row.storageQty || 0;
if (row.pickEmpty) summary.pickEmpty += 1;
});
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
summary.orderQtyTotal = round2(summary.orderQtyTotal);
summary.exceedTotal = round2(summary.exceedTotal);
summary.shortfallTotal = round2(summary.shortfallTotal);
summary.pickTotal = round2(summary.pickTotal);
summary.storageTotal = round2(summary.storageTotal);
return summary;
}
async function loadDataset(warehouse, { refresh = false } = {}) {
const orderDate = await loadOrderDate(warehouse);
// 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL
const cacheKey = `${warehouse.key}::${orderDate || 'none'}`;
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 [invRows, orderMap] = await Promise.all([
loadReplicaInventory(warehouse),
loadTodayOrderMap(warehouse, orderDate).catch(() => new Map()),
]);
const rows = buildSkuRows({ invRows, orderMap, orderDate });
const dataset = {
at: now,
rows,
warehouse,
orderDate,
meta: {
rowCount: rows.length,
orderSkuCount: orderMap.size,
},
};
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
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' || key === 'replenish') {
return row.status === STATUS.NEED || row.status === STATUS.SHORT;
}
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)
|| (b.excess || 0) - (a.excess || 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 dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const filtered = sortRows(filterRows(dataset.rows, options));
const summary = buildSummary(dataset.rows, dataset.meta.orderSkuCount);
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),
orderDate: dataset.orderDate,
cacheHit: dataset.cacheHit,
updatedAt: new Date(dataset.at).toISOString(),
cacheTtlMs: resolveCacheTtlMs(),
};
}
async function getReplenishExportRows(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
return {
warehouse,
orderDate: dataset.orderDate,
rows: sortRows(filterRows(dataset.rows, options)),
summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount),
};
}
/** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */
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,
PICK_PREFIXES,
STORE_PREFIXES,
STATUS,
STATUS_LABELS,
WAREHOUSES,
clearPickBins: async () => ({ removed: 0 }),
getReplenishExportRows,
getReplenishList,
loadDataset,
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

@ -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,584 +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 与合并/拆单标记位,
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
// 🔥 2026-10-22 补 `Question` —— **但不能塞进这个数组**:
// 这个数组在「状态分类之前」就判定,会把状态表「异常」的单(订单表常是 Question)
// 从「异常待处理」清单里一起吞掉(实测 heldCount 472 → 10,少掉 462 单)。
// ⟹ Question 单独在 `state === '发货中'` 之后判,见 VOID_QUESTION_STATUS。
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
// 订单表 status=Question(聚水潭后台显示「已作废/问题单」)—— 业务 2026-10-22 确认:
// so_id 3655421011854634(9823980) / 3658479011191285(9880810) 在后台都已作废,
// 而状态表 order_status_name 仍停在「发货中」+ send_time 空、售后「暂无售后」
// ⟹ 上游状态没同步,只信状态表判不出来。仅在「发货中」分支生效,不影响异常清单。
const VOID_QUESTION_STATUS = 'Question';
// 订单表 `labels` 里出现这些关键词,等价于「客户发货前退款/取消」= 作废,不考核仓库。
// 实测 9823980 的 labels = 「发货前售后,大单待确认,店铺-京东,晚发赔,禁止拆分」;
// 已取消单(order_status_name='已取消')几乎全都带这个标签,可作交叉验证。
// ⚠️ `labels` 是长文本列,回订单表补它会让 backfill 变慢(见 service 里的投影说明),
// 所以只投影一个 `CASE WHEN ... THEN 1 ELSE 0 END` 布尔列,不把原文传回来。
const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
// 状态表 `aftersale_status_name` 的「已作废」—— 售后维度最权威的作废标记。
// 目前带这个值的单在订单表基本都是 Sent(已被其它判据剔除),这里是防御性兜底:
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
const AFTERSALE_VOID_STATUS = '已作废';
// 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,
VOID_QUESTION_STATUS,
VOID_ORDER_LABEL_KEYWORDS,
AFTERSALE_VOID_STATUS,
SHIP_TIMELINESS_HOURS,
toNumber,
};

View File

@ -1,802 +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,
VOID_QUESTION_STATUS,
VOID_ORDER_LABEL_KEYWORDS,
AFTERSALE_VOID_STATUS,
resolveCenter,
} = registry;
// `labels` 里命中任一关键词即视为作废(SQL 侧用 LIKE 拼,避免把长文本传回 Node)
const VOID_LABEL_SQL = VOID_ORDER_LABEL_KEYWORDS
.map((k) => `labels LIKE N'%${k}%'`)
.join(' OR ');
// 明细上限:只用于页面展示,超出部分用 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.aftersale_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,
CASE WHEN ${VOID_LABEL_SQL} THEN 1 ELSE 0 END AS presaleVoid
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,
// 作废(软):问题单 Question / 标签含「发货前售后」。
// ⚠️ 只能作用于「发货中」分支(见主循环),否则状态表「异常」的单
// 在订单表常也是 Question,会被一起吞掉 —— 实测 heldCount 472 → 10。
question: status === VOID_QUESTION_STATUS,
presaleVoid: Number(r.presaleVoid) === 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; // 其余状态(已发货/已取消)不展示
// 🔥 2026-10-22:状态表说「发货中」,但订单表/售后维度已有作废证据 → 不考核。
// ⚠️ 必须放在「发货中」之后:订单表 Question 的单里有一大批在状态表是「异常」,
// 若在状态分类前判,会把 462 单「异常待处理」从 heldOrders 里吞掉。
// 三类证据(业务 2026-10-22 确认「聚水潭后台已作废却仍算超时」):
// · 状态表 aftersale_status_name =「已作废」(售后维度最权威)
// · 订单表 status = Question(聚水潭后台显示「已作废/问题单」)—— 9823980 / 9880810
// · 订单表 labels 含「发货前售后」(客户发货前退款/取消)—— 9823980 也带
if (String(row.aftersale_status_name || '').trim() === AFTERSALE_VOID_STATUS
|| (fact && (fact.question || fact.presaleVoid))) {
voidCount += 1;
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

@ -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,497 +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('明细行:仓|快递单号|快递|异常类型(4 段,不带发货时间和待处理时长),字段里换行要压平', () => {
const line = job.buildDetailLine({
warehouseName: '崔桥工厂仓',
oId: '9845001',
trackingNo: 'JDVA00123456789',
carrier: '京东快递',
exceptionLabel: '已揽收未中转',
sendTime: '2026-10-21 16:00:00',
pendingHours: 17.25,
lastLogisticInfo: '第一行\n第二行',
}, 3);
assert.equal(line, '3. 崔桥工厂仓|JDVA00123456789|京东快递|已揽收未中转');
// 业务要求:第二段是快递单号,不是内部单号
assert.ok(!line.includes('9845001'), '明细行不该再显示内部单号');
// 业务要求:明细不带发货时间(哪怕传了 sendTime 也不能出现在消息里)
assert.ok(!line.includes('10-21'), '明细行不该再带发货时间');
assert.ok(!line.includes('16:00'), '明细行不该再带发货时间');
// 业务要求:末尾的待处理小时数也去掉(哪怕传了 pendingHours 也不能出现在消息里)
assert.ok(!line.includes('17.3h'), '明细行不该再带待处理时长');
assert.equal(line.split('|').length, 4, '明细必须正好 4 段');
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
// 未揽收/无物流单没有快递单号 → 退回内部单号(否则群里这条无法定位)
assert.equal(
job.buildDetailLine({ oId: '9845002', carrier: '中通' }, 1),
'1. 未知仓|9845002|中通|异常'
);
// 空值兜底:不能让消息里出现「undefined|null」
assert.equal(
job.buildDetailLine({}, 1),
'1. 未知仓|无单号|未知快递|异常'
);
});
test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => {
const rows = [
{ warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
{ warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
{ warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
];
const grouped = job.groupRowsByWarehouse(rows);
// 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序
assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']);
// 不丢行、不改行
assert.equal(grouped.length, rows.length);
const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text;
// 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来)
const detailBlock = text.split('明细前')[1] || '';
const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line));
const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, ''));
// 同仓必须相邻:切成连续段后不应出现重复仓名
const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]);
assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`);
assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']);
// 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号)
assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']);
});
test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => {
const rows = [
{ warehouseName: '', trackingNo: 'A1' },
{ warehouseName: '睢宁仓', trackingNo: 'B1' },
{ trackingNo: 'A2' },
];
const grouped = job.groupRowsByWarehouse(rows);
// 未知仓 2 条(含 undefined)> 睢宁 1 条
assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']);
});
test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => {
// 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它)
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
warehouseName: name,
trackingNo: `${name}-${i}`,
}));
const rows = [
...make('青岗集仓', 100),
...make('崔桥工厂仓', 20),
...make('睢宁仓', 8),
...make('新苏北仓', 6),
...make('横林二仓', 6),
];
const picked = job.pickDetailRows(rows, 40);
assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)');
const counts = picked.reduce((acc, row) => {
acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1;
return acc;
}, {});
// 每个仓都要露面,否则群里那个仓的人看不到自己的单
for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) {
assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`);
}
// 大仓仍然拿得多,但不能吃满额度
assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度');
assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细');
// 同仓仍然相邻
const names = picked.map((row) => row.warehouseName);
const runs = names.filter((name, i) => name !== names[i - 1]);
assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`);
});
test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => {
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
warehouseName: name,
trackingNo: `${name}-${i}`,
}));
const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)];
const picked = job.pickDetailRows(rows, 2);
assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']);
// 额度 0 / 超额度的边界
assert.deepEqual(job.pickDetailRows(rows, 0), []);
assert.equal(job.pickDetailRows(rows, 99).length, 17);
});
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,694 +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' }),
// ── 2026-10-22 作废单三类形态 ──
// 38:订单表 Question(聚水潭「已作废/问题单」)
38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }),
// 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转)
39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }),
// 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常
40: f('jst_Orders', { created: '2026-10-20 11:10: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',
// 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到
aftersale_status_name: '暂无售后',
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 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' }),
// ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)──
// 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810
mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 09:10:00',
last_oa_confirm_time: '2026-10-20 09:15:00' }),
// 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980
mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', pay_time: '2026-10-20 10:10:00',
last_oa_confirm_time: '2026-10-20 10:15:00' }),
// 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除
mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓',
order_status_name: '发货中', aftersale_status_name: '已作废',
pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15: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,
presaleVoid: x.presaleVoid || 0,
};
});
}
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(拆单父单) 都不能进任何清单
// 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废)
for (const id of ['9', '10', '11', '12', '38', '39', '40']) {
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} 不该进挂起清单`);
}
// 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类)
assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数');
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 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 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来)
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+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
`补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`);
assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u,
'多取一列就会把 0.2s 拖到 38s');
// labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文
assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔');
}
// ⚠️ 窗口参数走 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

@ -7,9 +7,8 @@
"test": "node --test test/*.test.mjs", "test": "node --test test/*.test.mjs",
"prebuild": "node ./scripts/write-build-version.mjs", "prebuild": "node ./scripts/write-build-version.mjs",
"dev": "node ./scripts/write-build-version.mjs && vite --configLoader native", "dev": "node ./scripts/write-build-version.mjs && vite --configLoader native",
"build": "node ./scripts/verify-router-lazy.mjs && vite build && node ./scripts/verify-planning-entry.mjs", "build": "vite build && node ./scripts/verify-planning-entry.mjs",
"check": "npm run lint && npm test && npm run build", "check": "npm run lint && npm test && npm run build",
"verify:router-lazy": "node ./scripts/verify-router-lazy.mjs",
"verify:public-build": "node ./scripts/verify-public-build.mjs", "verify:public-build": "node ./scripts/verify-public-build.mjs",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"

View File

@ -1,70 +0,0 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
/**
* 校验路由表里每个 renderLazyComponent(X) 用到的组件 X 都在同文件里
* 用 `const X = lazy(() => import(...))` 声明过。
*
* 为什么要这个检查(2026-10-22 生产事故):
* PR #74 新增「缺货补货监控」时,router/index.jsx 里写了
* `element: renderLazyComponent(BinReplenishMonitor)` 却漏了 lazy 声明。
* 这行在模块顶层执行 → 加载即抛 `ReferenceError: BinReplenishMonitor is not defined`
* → 整个 React 应用挂掉、**全站白屏,连登录页都渲染不出来**。
* 而 `vite build` 完全能构建成功(未声明标识符在 JS 里是合法语法),
* 所以必须在构建期用静态扫描拦住。
*/
export async function verifyRouterLazy(routerPath = path.resolve(process.cwd(), 'src/router/index.jsx')) {
const source = await readFile(routerPath, 'utf8');
const used = new Set();
for (const match of source.matchAll(/renderLazyComponent\s*\(\s*([A-Za-z_$][\w$]*)/g)) {
used.add(match[1]);
}
const declared = new Set();
for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*lazy\s*\(/g)) {
declared.add(match[1]);
}
const missing = [...used].filter((name) => !declared.has(name)).sort();
if (missing.length > 0) {
const lines = missing.map((name) => {
const idx = source.indexOf(`renderLazyComponent(${name}`);
const line = idx === -1 ? '?' : source.slice(0, idx).split('\n').length;
return ` - ${name} (router/index.jsx:${line} 使用,缺少 const ${name} = lazy(() => import('...')) 声明)`;
});
throw new Error(
'router/index.jsx 有组件被 renderLazyComponent 使用但未声明为 lazy,' +
'这会导致运行时 ReferenceError 并让整站白屏:\n' +
lines.join('\n'),
);
}
return { routerPath, used: [...used].sort(), declared: [...declared].sort() };
}
async function main() {
const routerArg = process.argv[2];
const routerPath = routerArg
? path.resolve(process.cwd(), routerArg)
: path.resolve(process.cwd(), 'src/router/index.jsx');
const result = await verifyRouterLazy(routerPath);
process.stdout.write(
`Verified router lazy declarations: ${result.used.length} used, ${result.declared.length} declared ` +
`(${path.relative(process.cwd(), result.routerPath)})\n`,
);
}
const isDirectExecution = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
: false;
if (isDirectExecution) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
});
}

View File

@ -1,20 +0,0 @@
import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 缺货补货监控(订单驱动版)
// 判断口径:当日订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
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,
}
);

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,419 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip,
message,
} from 'antd';
import {
DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined,
} from '@ant-design/icons';
import { exportBinReplenish, getBinReplenishList } 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 = {
short: { color: 'red', label: '存储不足' },
need: { color: 'gold', label: '需补货' },
enough: { color: 'green', label: '拣货位充足' },
no_order: { color: 'default', label: '今日无单' },
};
const STATUS_OPTIONS = [
{ label: '待补货', value: 'replenish' },
{ label: '存储不足', value: 'short' },
{ label: '拣货位充足', value: 'enough' },
{ label: '今日无单', value: 'no_order' },
{ label: '全部', value: 'all' },
];
const BinReplenishMonitor = () => {
const [warehouse, setWarehouse] = useState('cuiqiao');
const [status, setStatus] = useState('replenish');
const [keyword, setKeyword] = useState('');
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: [], orderDate: '', updatedAt: '', cacheHit: 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,
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 || [],
orderDate: payload.orderDate || '',
updatedAt: payload.updatedAt || '',
cacheHit: Boolean(payload.cacheHit),
});
if (isRefresh) message.success('已重新回源取数');
} catch (err) {
setError(err?.message || '加载失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [warehouse, status, keyword, page, pageSize]);
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [warehouse, status, page, pageSize]);
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 needCount = (summary.need || 0) + (summary.short || 0);
const metrics = useMemo(() => ([
{
key: 'replenish',
tone: 'red',
label: '待补货 SKU',
value: needCount,
unit: '个',
foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`,
tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来',
},
{
key: 'short',
tone: 'grey',
label: '其中存储不足',
value: summary.short || 0,
unit: '个',
foot: `存储缺口 ${num(summary.shortfallTotal)} 件`,
tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨',
},
{
key: 'exceed',
tone: 'orange',
label: '超出订单数合计',
value: summary.exceedTotal || 0,
unit: '件',
foot: '今日拣货位缺口订单量',
tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量',
},
{
key: 'order',
tone: 'blue',
label: '当日有单 SKU',
value: summary.orderSkuCount || 0,
unit: '个',
foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`,
tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)',
},
]), [summary, needCount]);
const handleExport = async () => {
try {
const response = await exportBinReplenish({ warehouse, status, keyword });
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_${data.orderDate || ''}.xlsx`);
} catch (error_) {
const text = await readBlobErrorMessage(error_);
message.error(text || '导出失败');
}
};
const columns = [
{
title: '状态',
dataIndex: 'status',
width: 100,
fixed: 'left',
render: (value) => {
const tag = STATUS_TAGS[value] || STATUS_TAGS.no_order;
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: 'pickQty',
width: 100,
align: 'right',
render: (value, row) => (
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>
{num(value)}{row.pickEmpty ? ' ⚠' : ''}
</span>
),
},
{
title: '存储位库存',
dataIndex: 'storageQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
title: '当日订单',
dataIndex: 'orderQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
title: '超出订单数',
dataIndex: 'excess',
width: 100,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--warn' : 'brp-qty brp-qty--zero'}>{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: 'shortfall',
width: 100,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--danger' : 'brp-qty brp-qty--zero'}>{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>{data.orderDate || '--'}</b>
<span className="brp-muted">(取该仓最近一个有订单的营业日)</span>
</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.noOrder)}</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(needCount)}`
: item.value === 'short'
? `${item.label} ${num(summary.short || 0)}`
: item.value === 'no_order'
? `${item.label} ${num(summary.noOrder || 0)}`
: item.label,
}))}
/>
</span>
<Input
allowClear
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜商品编码 / 品名 / 供应商"
prefix={<SearchOutlined />}
style={{ width: 260 }}
/>
<span className="brp-toolbar__spacer" />
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={refreshing}
onClick={() => load({ refresh: true })}
>
刷新数据
</Button>
</div>
<Alert
type="info"
showIcon
className="brp-alert"
message="货位口径(崔桥工厂仓约定)"
description={(
<span>
拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。
判断规则:<b>当日订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
</span>
)}
/>
{error ? (
<Alert
type="error"
showIcon
className="brp-alert"
message={error}
action={<Button size="small" onClick={() => load()}>重试</Button>}
/>
) : null}
<Spin
spinning={loading}
tip="正在聚合仓位库存 + 当日订单,首次约 30~90 秒"
wrapperClassName="brp-table-spin"
>
<Table
rowKey="skuId"
size="small"
className="brp-table"
columns={columns}
dataSource={data.rows}
scroll={{ x: 1500 }}
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),按商品编码(sku_id)聚合。
「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
</li>
<li>
<b>订单</b>:崔桥工厂仓当日订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B),
均按 send_date 落在「订单日期」当天;组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
</li>
<li>
<b>判断规则</b>:对每个 SKU,若 <b>当日订单量 &gt; 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
</li>
<li>
<b>订单日期</b>:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空;
页面顶部明确标注统计的是哪一天。
</li>
<li>
<b>时效</b>:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等 1 分钟左右。
</li>
</ul>
</div>
</SycmPage>
);
};
export default BinReplenishMonitor;

View File

@ -1,313 +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--warn {
color: var(--brp-orange);
font-weight: 600;
}
.brp-qty--danger {
color: var(--brp-red);
font-weight: 700;
}
.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

@ -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,16 +19,9 @@ 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'));
// ⚠️ 每个在下面路由表里被 renderLazyComponent(...) 用到的页面都必须先在这里声明。
// 漏一个 = 模块顶层抛 ReferenceError(xxx is not defined)→ 整个应用白屏、连登录页都进不去。
// PR #74 就漏了 BinReplenishMonitor,导致生产 10-22 全站白屏(2026-10-22 修复)。
const BinReplenishMonitor = lazy(() => import('../pages/BinReplenishMonitor'));
const lazyLoadingElement = <PageLoadingFallback />; const lazyLoadingElement = <PageLoadingFallback />;
@ -121,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),
@ -137,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 区分取哪个仓;
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线) // 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
{ {