Compare commits
40 Commits
codex/ssa-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 42f60eb0ce | |||
| f42f461430 | |||
| 4b02ad212d | |||
| 530d1ad27a | |||
| 6833b7bc79 | |||
| 893f4d6aca | |||
| 3d9b12eace | |||
| c9715c0e5b | |||
| c80d28c33f | |||
| 62cac712db | |||
| e5030d4864 | |||
| 0a2fe1c3a3 | |||
| 1d4eecb03c | |||
| 2bdc4b5713 | |||
| c6c7feaeea | |||
|
|
fa0e604bf8 | ||
| cf8d5f68ae | |||
| e15898693f | |||
| 214ede07a5 | |||
| 99fa402092 | |||
| 8f12b889c0 | |||
| 88a39975d3 | |||
| 195351deae | |||
| 31f184edd0 | |||
| ea2942f118 | |||
| 817cc74a40 | |||
| e71607386d | |||
| 7505a04e37 | |||
| 0afcb26119 | |||
| 70550b5499 | |||
| ada8d09c27 | |||
| a93691f5d8 | |||
| b0b618e7df | |||
| 30554720b2 | |||
| 901212a32c | |||
| f7fc7fef4f | |||
| 23e1b365d6 | |||
| 6c3b14fc10 | |||
| aeac4bcc81 | |||
| 38804f6624 |
153
backend/scripts/validateDailyInventoryFromBiz.js
Normal file
153
backend/scripts/validateDailyInventoryFromBiz.js
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
'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);
|
||||||
|
});
|
||||||
282
backend/src/api/pickingBinSalesAnalysis.js
Normal file
282
backend/src/api/pickingBinSalesAnalysis.js
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
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;
|
||||||
62
backend/src/api/warehouseShipTimeliness.js
Normal file
62
backend/src/api/warehouseShipTimeliness.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
'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;
|
||||||
@ -764,7 +764,20 @@ async function connectWithCandidate(candidate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function queryWithCandidateConnection(candidate, sqlText, params = {}, options = {}) {
|
async function queryWithCandidateConnection(candidate, sqlText, params = {}, options = {}) {
|
||||||
const nextPool = new sql.ConnectionPool(candidate.mssqlConfig);
|
// request.timeout(请求级)在 mssql 12.x 上实测不生效(始终按池配置的默认值掐断),
|
||||||
|
// 慢查询必须通过「池配置级 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();
|
||||||
@ -1093,6 +1106,28 @@ 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试连接
|
* 测试连接
|
||||||
*/
|
*/
|
||||||
@ -1143,6 +1178,7 @@ const reload = async () => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
getPool,
|
getPool,
|
||||||
query,
|
query,
|
||||||
|
querySlow,
|
||||||
queryWithFreshConnection,
|
queryWithFreshConnection,
|
||||||
createPinnedQueryClient,
|
createPinnedQueryClient,
|
||||||
test,
|
test,
|
||||||
|
|||||||
78
backend/src/config/pickingBinSalesAnalysis.js
Normal file
78
backend/src/config/pickingBinSalesAnalysis.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 拣选库位动销分析 —— 口径配置(纯规则,不含业务数值)
|
||||||
|
*
|
||||||
|
* 一级菜单:大货库存监控
|
||||||
|
* 二级菜单:拣选库位动销分析
|
||||||
|
*
|
||||||
|
* 表头(需求表原列为 所在仓库 | 仓位 | 数量 | 类型 | 动销等级,这里做了扩充与优化):
|
||||||
|
* 仓库 | 仓位 | 类型 | 商品编码 | 库存数量 | 近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,
|
||||||
|
};
|
||||||
@ -45,6 +45,11 @@ 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: '系统设置',
|
||||||
|
|||||||
@ -35,9 +35,11 @@ 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 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 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');
|
||||||
@ -671,9 +673,12 @@ 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);
|
||||||
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);
|
||||||
|
|||||||
@ -51,15 +51,18 @@
|
|||||||
// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。
|
// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。
|
||||||
// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(),
|
// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(),
|
||||||
// 只打一条查询,按编码缓存 10 分钟。
|
// 只打一条查询,按编码缓存 10 分钟。
|
||||||
// ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`;
|
// ⚠️ 关联列(09-19 修正,PR #22):仓名用 `jst_warehouseInfo.wms_co_id = p.wms_co_id`
|
||||||
// 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。
|
// —— 09-19 实测该表 30,236 行 `wms_co_id` **0 NULL、14 个仓取值**。
|
||||||
// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱**
|
// ~~旧结论「该表 wms_co_id 全表 NULL,必须用 jst_warehouseInfo.Id = p.wh_id」~~
|
||||||
// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、
|
// 只对 09-17 的表状态成立;误用 `w.Id = p.wh_id`(wh_id 仅 6 个取值、90% 为 1)
|
||||||
// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。
|
// 会让 727 个配件的仓位仓名被统一显示成「常州简尚家居有限公司」。
|
||||||
// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到
|
// 🔥 该表 **小时级增量刷新**,且 2026-09-18 被上游整表重建 ——
|
||||||
// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。
|
// 09-17 的「全表 85,152 行、仅 3 个箱、`bin` / `bin_group` 全表为空、与 MKH 配件
|
||||||
// → 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 汇总(**商品**库存,不是配件仓位)。
|
||||||
//
|
//
|
||||||
@ -67,8 +70,11 @@
|
|||||||
// 规则:近 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 = 15870495 品牌仓)实时库存;
|
// · 现有库存 rpt.jst_inventoryInfo(wms_co_id **仓白名单**)实时库存;
|
||||||
// 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①),
|
// 🔥 2026-10-17 修正为**多仓求和**(原来是单仓 15870495):商品档案 other_3 只记
|
||||||
|
// 「归属仓」,实物会整批搬到同体系其它发货仓 —— 只查归属仓会把有货的算成 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 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单**
|
||||||
@ -212,6 +218,28 @@
|
|||||||
// 若日后业务反馈「库存大面积停在某个时间点」,**先查 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 商品存在未完成的加工单,就不再列入补货预警**
|
||||||
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
|
// (工厂已经在做了,不重复提示补货),改标为「已排产」。
|
||||||
@ -303,13 +331,22 @@
|
|||||||
// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件
|
// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件
|
||||||
// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集:
|
// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集:
|
||||||
// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
|
// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量)
|
||||||
// · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓
|
// · 配件现有库存 **两级来源**(2026-09-30 修正,与配件监控页 `buildRealtimeStockSql` 同口径):
|
||||||
// (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。
|
// ① rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓(wms_co_id = 15718196)
|
||||||
// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**,
|
// —— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。
|
||||||
// 跨仓正库存为 0,所以不跨仓汇总。
|
// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**,
|
||||||
|
// 跨仓正库存为 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。
|
||||||
@ -398,8 +435,17 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%'
|
|||||||
)`;
|
)`;
|
||||||
|
|
||||||
// ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
|
// ── 补货预警参数(见文件头 ⑦)────────────────────────────────────
|
||||||
// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得)
|
// 成品库存仓白名单(rpt.jst_warehouseInfo 查得)。**多仓求和**:
|
||||||
const BRAND_WMS_CO_ID = 15870495;
|
// 商品档案的 other_3 只记录「归属仓」(仍是崔桥工厂品牌仓),但实物会成批搬到
|
||||||
|
// 同体系的其它发货仓,此时库存表里只有新仓有数、归属仓为 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 决定实际取数列,
|
||||||
@ -525,8 +571,10 @@ 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;
|
||||||
@ -1745,21 +1793,23 @@ async function loadReplenishIndex() {
|
|||||||
REPLENISH_PARAMS,
|
REPLENISH_PARAMS,
|
||||||
{ requestTimeout: 180000 }
|
{ requestTimeout: 180000 }
|
||||||
),
|
),
|
||||||
// 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条
|
// 实时库存(见文件头 ⑦-d):仓白名单多仓,**按 SKU 求和**(同一批货可能分散在多仓)。
|
||||||
|
// ⚠️ 不能再用「按 updateTime DESC 取首条」——那会只留一个仓的数,正好丢掉新仓。
|
||||||
cloudDatabase.query(
|
cloudDatabase.query(
|
||||||
`SELECT i.sku_id AS skuId, i.qty AS qty
|
`SELECT i.sku_id AS skuId, SUM(i.qty) AS qty
|
||||||
FROM dbo.jst_inventoryInfo i
|
FROM dbo.jst_inventoryInfo i
|
||||||
WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%'
|
WHERE i.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) AND i.sku_id LIKE N'MKH%'
|
||||||
ORDER BY i.sku_id ASC, i.updateTime DESC`,
|
GROUP BY i.sku_id`,
|
||||||
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 = @brandWmsCoId AND p.sku_id LIKE N'MKH%'
|
WHERE p.wms_co_id IN (${BRAND_WMS_CO_ID_LIST_SQL}) 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,
|
||||||
@ -1845,6 +1895,10 @@ 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,调用方**放弃判定**(宁可少标不误标),
|
||||||
* 且失败的分片不写缓存,下次调用重试
|
* 且失败的分片不写缓存,下次调用重试
|
||||||
@ -1872,25 +1926,42 @@ 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 i.sku_id AS code, SUM(i.qty) AS qty
|
`SELECT N'inv' AS src, 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
|
WHERE i.wms_co_id = @componentStockWmsCoId AND i.sku_id ${codeFilter}
|
||||||
AND i.sku_id IN (${placeholders.join(', ')})
|
GROUP BY i.sku_id
|
||||||
GROUP BY i.sku_id`,
|
UNION ALL
|
||||||
|
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 found = new Map();
|
const inventoryQty = new Map();
|
||||||
|
const packQty = new Map();
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const code = toText(row.code);
|
const code = toText(row.code);
|
||||||
if (code) found.set(code, toNumber(row.qty) ?? 0);
|
if (!code) return;
|
||||||
|
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) => {
|
||||||
const qty = found.get(code) ?? 0;
|
// 只补 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);
|
||||||
@ -2649,6 +2720,9 @@ 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,
|
||||||
|
|||||||
243
backend/src/services/pickingBinSalesAnalysis.js
Normal file
243
backend/src/services/pickingBinSalesAnalysis.js
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
'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,
|
||||||
|
};
|
||||||
@ -14,13 +14,20 @@
|
|||||||
* · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对;
|
* · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对;
|
||||||
* · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。
|
* · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。
|
||||||
*
|
*
|
||||||
* 仓位名 → 库存位 对照(2026-09-25 复测,条件 ISNULL(col,0) > 0):
|
* 仓位名 → 库存位 对照(2026-09-27 复测,条件 ISNULL(col,0) > 0):
|
||||||
* 箱及仓位库存 · 简尚-崔桥工厂仓 · 崔桥工厂客退临时上架仓 customize_qty_1 2 编码 / 2 件 ⚠
|
* 箱及仓位库存 · 简尚-崔桥工厂仓 · 崔桥工厂客退临时上架仓 customize_qty_1 349 编码 / 507 件
|
||||||
* 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 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='简尚-崔桥工厂仓销退仓')——即「退货有,客退上架库存
|
||||||
@ -58,10 +65,8 @@ const WAREHOUSE_LIST = [
|
|||||||
returnBinName: '崔桥工厂客退临时上架仓',
|
returnBinName: '崔桥工厂客退临时上架仓',
|
||||||
// 客退临时上架仓占用的自定义库存位
|
// 客退临时上架仓占用的自定义库存位
|
||||||
returnSlot: 'customize_qty_1',
|
returnSlot: 'customize_qty_1',
|
||||||
// ⚠ 2026-09-25 实测:该仓在副本 jst_inventoryInfo 里客退位只有 2 个编码 / 2 件
|
// 2026-09-27 复测:客退位已同步进副本 —— 349 编码 / 507 件(c1 合计),
|
||||||
//(qty 总库存 92,818 件;return_qty / c2 / c3 / sale_refund_qty 全为 0)。
|
// 退货单口径:ERP 售后单 3,115 条 + 奇门退款单 1,812 条(warehouse='简尚-崔桥工厂仓销退仓')。
|
||||||
// 退货单是有的(ERP 售后单 3,115 条 + 奇门退款单 1,812 条),
|
|
||||||
// 说明是「客退上架仓」这一仓位没同步进副本,不是业务上没有退货。
|
|
||||||
hasReturnStock: true,
|
hasReturnStock: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -64,6 +64,10 @@
|
|||||||
|
|
||||||
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,
|
||||||
@ -242,6 +246,7 @@ 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(
|
||||||
@ -367,10 +372,16 @@ 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 `
|
||||||
@ -392,7 +403,13 @@ 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}`;
|
||||||
|
|
||||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
// 默认 5 分钟:副本侧 ETL 约 10 分钟一批增量(2026-09-27 实测),
|
||||||
|
// 这里再压一层缓存只是防止多人反复翻页把副本打爆;想更快用 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();
|
||||||
@ -408,7 +425,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 < CACHE_TTL_MS) {
|
if (!refresh && cached && cached.rows && Date.now() - cached.at < resolveCacheTtlMs()) {
|
||||||
return cached.rows;
|
return cached.rows;
|
||||||
}
|
}
|
||||||
const running = inflightCache.get(key);
|
const running = inflightCache.get(key);
|
||||||
@ -437,7 +454,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 < CACHE_TTL_MS) {
|
if (!refresh && state?.value && Date.now() - state.at < resolveCacheTtlMs()) {
|
||||||
return state.value;
|
return state.value;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -567,6 +584,24 @@ 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 };
|
||||||
@ -795,6 +830,8 @@ 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 = [];
|
||||||
@ -821,6 +858,11 @@ 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;
|
||||||
@ -851,6 +893,11 @@ 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,
|
||||||
});
|
});
|
||||||
@ -870,6 +917,9 @@ 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),
|
||||||
@ -895,6 +945,11 @@ 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,
|
||||||
});
|
});
|
||||||
@ -1057,10 +1112,11 @@ async function getReturnStockWarningList(options = {}) {
|
|||||||
const priceLevel = resolvePriceLevelFilter(options.priceLevel);
|
const priceLevel = resolvePriceLevelFilter(options.priceLevel);
|
||||||
const today = formatLocalDate();
|
const today = formatLocalDate();
|
||||||
|
|
||||||
const [replicaRows, overrides, latestImport] = await Promise.all([
|
const [replicaRows, overrides, latestImport, exitDates] = 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);
|
||||||
@ -1079,6 +1135,7 @@ 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));
|
||||||
@ -1129,6 +1186,12 @@ 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1148,7 +1211,8 @@ const buildExportColumns = (warehouse = DEFAULT_WAREHOUSE) => [
|
|||||||
{ key: 'amount', title: '总金额' },
|
{ key: 'amount', title: '总金额' },
|
||||||
{ key: 'unitPriceLevelLabel', title: '单价预警' },
|
{ key: 'unitPriceLevelLabel', title: '单价预警' },
|
||||||
{ key: 'returnDeadlineLabel', title: '退厂时限' },
|
{ key: 'returnDeadlineLabel', title: '退厂时限' },
|
||||||
{ key: 'isOutboundLabel', title: '是否出库' },
|
{ key: 'supplierCoopLabel', 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: '数据来源' },
|
||||||
@ -1174,9 +1238,10 @@ async function getReturnStockWarningExportRows(options = {}) {
|
|||||||
const priceLevel = resolvePriceLevelFilter(options.priceLevel);
|
const priceLevel = resolvePriceLevelFilter(options.priceLevel);
|
||||||
const today = formatLocalDate();
|
const today = formatLocalDate();
|
||||||
|
|
||||||
const [replicaRows, overrides] = await Promise.all([
|
const [replicaRows, overrides, exitDates] = 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);
|
||||||
@ -1195,6 +1260,7 @@ 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));
|
||||||
@ -1208,7 +1274,10 @@ 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),
|
||||||
isOutboundLabel: row.isOutbound ? '是' : '否',
|
supplierCoopLabel: row.supplierCoopLabel || SUPPLIER_COOP_LABELS.unknown,
|
||||||
|
// 工作平台未接入时导出显示「待接入」,避免空列被误读成「没有退厂时间」
|
||||||
|
latestExitDateLabel: row.latestExitDateLabel
|
||||||
|
|| (row.latestExitDateAvailable ? '—' : '待接入'),
|
||||||
sourceLabel: row.createdAtSourceLabel || '',
|
sourceLabel: row.createdAtSourceLabel || '',
|
||||||
createdAtRaw: undefined,
|
createdAtRaw: undefined,
|
||||||
}));
|
}));
|
||||||
|
|||||||
140
backend/src/services/supplierExitDateProvider.js
Normal file
140
backend/src/services/supplierExitDateProvider.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
'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,
|
||||||
|
};
|
||||||
559
backend/src/services/warehouseShipTimelinessRegistry.js
Normal file
559
backend/src/services/warehouseShipTimelinessRegistry.js
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
||||||
|
* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 需求(yanhui 2026-10-20):
|
||||||
|
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
||||||
|
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||||
|
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||||
|
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
||||||
|
* 今天 07:00 前审核的订单 → 13:00 核验;**截止今天 16:00 前审核**的单(含今天之前未发的)→ 18:00 核验;
|
||||||
|
* 昨天及更早审核、至今未发的(历史未发单)两个轮次都算。
|
||||||
|
* (2026-10-20 三次调整:
|
||||||
|
* 二次 —— 核验时刻由 12:00/16:00 改为 13:00/18:00;
|
||||||
|
* 三次 —— ① **窗口按「审核时间」判**(不是创建时间):业务指出的 9818476
|
||||||
|
* (创建 10-18、审核 10-20 13:06)这类下午才审的单,不能进 13:00 轮;
|
||||||
|
* ② **取消「满 24 小时才算超时」的门槛**:到核验时刻未发即超时;
|
||||||
|
* ③ **历史未发单一律算**,不卡「今天 00:00 起」。
|
||||||
|
* 业务原话:「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到
|
||||||
|
* 16 点审核的订单 18:00 核验;然后再看历史未发的订单」。)
|
||||||
|
* 展示口径:**只显示超时未发**(业务确认)。
|
||||||
|
*
|
||||||
|
* 业务确认的口径(2026-10-20):
|
||||||
|
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东自营仓/运营C专用仓 等)
|
||||||
|
* 例外:「京仓-横林二仓京东云仓」业务指定**排除**,不计入任何主仓(见下)
|
||||||
|
* ② 崔桥工厂半成品仓 —— 系统无同名仓,**接入「简尚-崔桥工厂半成品包装项目仓」**(15718196)
|
||||||
|
* ③ 展示结构 —— **只显示超时未发**
|
||||||
|
* ④ 明细列 —— 要同时给「内部单号」(o_id) 与「线上订单号」(so_id)
|
||||||
|
*
|
||||||
|
* ── 数据源 ────────────────────────────────────────────────────────────────
|
||||||
|
* 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头)
|
||||||
|
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||||
|
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||||
|
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
||||||
|
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
|
||||||
|
* ⚠️ 不是 `order_date`(平台下单时间);
|
||||||
|
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
||||||
|
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
||||||
|
* 实际下单在 10-13~10-18),拿它当创建时间会让所有单的时间被抹平;
|
||||||
|
* ⚠️ 也不是 `created/createTime/updateTime`(数仓 5 分钟批量灌库时间戳)。
|
||||||
|
* 待出库的单在出库单表里还没有记录 → 回退 `order_date` 并在接口里标注来源。
|
||||||
|
* · 已出库:销售出库单表能查到该 o_id(有 `io_date`),**或**订单 `send_date`
|
||||||
|
* 非空,**或**订单状态已是 `Sent` / `Delivering`。
|
||||||
|
* · 待出库:以上三条都不满足。
|
||||||
|
* · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓)
|
||||||
|
* ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属,
|
||||||
|
* 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。
|
||||||
|
*
|
||||||
|
* ── 仓库归属(按名称包含匹配,最长匹配优先)────────────────────────────────
|
||||||
|
* `rpt.jst_warehouseInfo` 实测(2026-10-20):
|
||||||
|
* 横林二仓 15033302 + 15301814/15835893/16040225/16132681
|
||||||
|
* ⚠️ 15197138「京仓-横林二仓京东云仓」**业务指定排除**(见 EXCLUDED_WAREHOUSE_KEYWORDS)
|
||||||
|
* 横林三仓 15043561
|
||||||
|
* 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**)
|
||||||
|
* 崔桥工厂仓 15038426 + 15301815/16040410
|
||||||
|
* 崔桥工厂品牌仓 15870495
|
||||||
|
* 崔桥工厂半成品包装项目仓 15718196(原名「简尚-崔桥工厂半成品包装项目仓」)
|
||||||
|
* 新苏北仓 15038347 + 15315031/15301812
|
||||||
|
* 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911
|
||||||
|
* 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076
|
||||||
|
* 山东品牌仓 15182705
|
||||||
|
* 河南洛宁仓 15539084
|
||||||
|
* ⟹ 仓库 ID → 主仓 的映射**在启动时从库表读取并缓存**(不用手写 ID 常量,
|
||||||
|
* 避免上游新增子仓后要改代码;新增子仓只要名字里带主仓关键词就会自动归入)。
|
||||||
|
* 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。
|
||||||
|
*
|
||||||
|
* ── 时效判定 ──────────────────────────────────────────────────────────────
|
||||||
|
* 两个时刻**必须区分**(2026-10-20 二次调整后最容易搞混的地方):
|
||||||
|
* `verifyHour` —— 几点核验(13:00 / 18:00)。判定「发了没发」「超时多久」都用它。
|
||||||
|
* `windowEndHour` —— 订单窗口上界(07:00 / 16:00)。只核验该时刻**前审核**的单。
|
||||||
|
* 取当前已生效且最晚的那一轮;未到 13:00 时无核验轮次:
|
||||||
|
* 13:00 → 核验「今天 00:00~07:00 审核」的订单 + 历史未发
|
||||||
|
* 18:00 → 核验「截止今天 16:00 前审核」的订单 + 今天之前审核仍未发的(历史未发)
|
||||||
|
* ⚠️ 不是「今天 00:00~16:00」这一个自然日切片,历史未发也在内;
|
||||||
|
* 山东两仓窗口上界 15:00、河南洛宁 13:00(核验时刻仍是 18:00)。
|
||||||
|
* 判定(2026-10-20 三次修正 —— **没有「满 24 小时」门槛**):
|
||||||
|
* · 超时未出库 = 到核验时刻仍未发货
|
||||||
|
* (出库单表查不到 + `send_time` 为空 + 状态不是 Sent/Delivering)
|
||||||
|
* 🔥 第七版错在加了「创建满 24h 才算超时」,导致 13:00 轮永远
|
||||||
|
* 看不到当天 7 点那批(才等了 6 小时),业务直接打回。
|
||||||
|
* · 已出库 = 出库单表有记录 / send_date 非空 / 状态 Sent、Delivering
|
||||||
|
* · 拆单父单(is_split=1)、被合并单(is_merge=1)**一律排除** ——
|
||||||
|
* 它们自己永不发货,货由子单/主单发出,算进来就是冤枉仓库。
|
||||||
|
* 业务只要「超时未出库」,所以列表只返回待出库的超时单。
|
||||||
|
*
|
||||||
|
* ── 时区 ──────────────────────────────────────────────────────────────────
|
||||||
|
* 订单时间列存的是**东八区本地时间**(实测 `order_date` 与 `created` 相差十几秒,
|
||||||
|
* 均为北京时间)。因此 SQL 里统一用 `CAST(GETDATE() AS date)` 取「今天」,
|
||||||
|
* 服务器与数据库都在东八区;**不要**在 Node 侧用 UTC 日期做判断。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
|
|
||||||
|
// ── 主仓定义(业务给定的 11 个)──────────────────────────────────────────
|
||||||
|
// keywords:该主仓在 rpt.jst_warehouseInfo.name 里应包含的关键词(任一命中即归属)
|
||||||
|
// 顺序即展示顺序;`match` 用最长关键词优先,所以「横林二仓品牌仓」不会被「横林二仓」抢走。
|
||||||
|
const CENTERS = Object.freeze([
|
||||||
|
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
|
||||||
|
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
|
||||||
|
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
|
||||||
|
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
|
||||||
|
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
|
||||||
|
// 2026-10-20 二次调整:原先「崔桥工厂半成品仓」在仓库表里没有同名仓,只能留空占位;
|
||||||
|
// 业务指定用「简尚-崔桥工厂半成品包装项目仓」(wms_co_id=15718196, Id=198) 替代接入。
|
||||||
|
// ⚠️ 该 wms_co_id 同时也是配件仓(MKH 配件库存来源),但这里是**订单发货仓**维度,
|
||||||
|
// 统计的是「从该仓发出的订单」,与配件库存口径无关,不要混。
|
||||||
|
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
|
||||||
|
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
|
||||||
|
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
|
||||||
|
// 🔥 山东两个仓 18:00 轮的窗口更早(15:00 前,不是 16:00)——
|
||||||
|
// 业务 2026-10-20:「山东青岗集仓和山东品牌仓,这 2 个仓库时间不是 16:00 前,
|
||||||
|
// 是 15:00 前订单」。核验时刻仍是 18:00,只是卡单的范围窄一小时。
|
||||||
|
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'], windowEndHourByRound: { AFTERNOON: 15 } },
|
||||||
|
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'], windowEndHourByRound: { AFTERNOON: 15 } },
|
||||||
|
// 🔥 河南洛宁仓更早:18:00 轮只看 13:00 前创建的单(业务同一条指令)
|
||||||
|
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'], windowEndHourByRound: { AFTERNOON: 13 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 各主仓在某一轮的窗口上界小时。
|
||||||
|
// 返回 null 表示「用轮次默认值」(ROUNDS[].windowEndHour)。
|
||||||
|
// ⚠️ 13:00 轮没有例外,全部按 07:00;只有 18:00 轮有三处更早的上界。
|
||||||
|
function centerWindowEndHour(centerKey, roundKey) {
|
||||||
|
const center = CENTERS.find((c) => c.key === centerKey);
|
||||||
|
const override = center && center.windowEndHourByRound
|
||||||
|
? center.windowEndHourByRound[roundKey]
|
||||||
|
: undefined;
|
||||||
|
return Number.isFinite(override) ? override : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该主仓在这一轮的窗口上界('YYYY-MM-DD HH:mm:00'),没有例外就用轮次默认 */
|
||||||
|
function centerWindowEnd(centerKey, round) {
|
||||||
|
const hour = centerWindowEndHour(centerKey, round.key);
|
||||||
|
if (hour === null) return round.windowEnd;
|
||||||
|
return `${round.date} ${String(hour).padStart(2, '0')}:00:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段,
|
||||||
|
// 靠关键词长度倒序匹配即可(见 resolveCenter)。
|
||||||
|
const SORTED_KEYWORDS = CENTERS
|
||||||
|
.filter((c) => c.keywords.length)
|
||||||
|
.flatMap((c) => c.keywords.map((kw) => ({ kw, center: c })))
|
||||||
|
.sort((a, b) => b.kw.length - a.kw.length);
|
||||||
|
|
||||||
|
// ── 排除仓(业务指定,2026-10-20)──────────────────────────────────────────
|
||||||
|
// 「京仓-横林二仓京东云仓」(wms_co_id=15197138, Id=129) 虽然名字里带「横林二仓」,
|
||||||
|
// 但属于京东云仓代运营,**不纳入横林二仓的时效考核**(业务明确要求排除)。
|
||||||
|
// ⚠️ 用完整片段「横林二仓京东云仓」而不是「京东云仓」:
|
||||||
|
// 仓库表里还有「京仓-崔桥工厂京东云仓」(15283032)、
|
||||||
|
// 「京仓-山东青岗集京东云仓」(15283033),写短了会把它们一起排掉;
|
||||||
|
// 也不会误伤「简尚-横林二仓京东仓」(16040225) 与「京仓-横林二仓京东自营仓」(15835893)。
|
||||||
|
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze([
|
||||||
|
'横林二仓京东云仓',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 长关键词优先,避免以后加了「XX京东云仓」被短词抢先
|
||||||
|
const SORTED_EXCLUSIONS = [...EXCLUDED_WAREHOUSE_KEYWORDS].sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
|
// ── 时效轮次(业务给定,2026-10-20 二次调整)──────────────────────────────
|
||||||
|
// verifyHour :当天几点开始核验(13:00 / 18:00)
|
||||||
|
// windowEndHour :只核验「**审核时间**不晚于该时刻」的订单(07:00 / 16:00)
|
||||||
|
// ⚠️ 两者**不是同一个东西**,别再用一个字段兼着当:
|
||||||
|
// 旧实现拿 12:00 同时当核验时刻和窗口上界,导致「12:00 轮」实际把
|
||||||
|
// 07:00~12:00 创建的单也算进来了,与页面上写的「核验 07:00 前」不一致。
|
||||||
|
// ⚠️ 也不能在 windowEnd 之后再叠「满 N 小时」门槛 —— 13:00 轮里 07:00 审核的单
|
||||||
|
// 只等了 6 小时,加门槛就永远不出现在超时列表里(第七版被打回的原因)。
|
||||||
|
const ROUNDS = Object.freeze([
|
||||||
|
{ key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEndHour: 7 },
|
||||||
|
{ key: 'AFTERNOON', label: '18:00 轮', verifyHour: 18, windowEndHour: 16 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── 订单窗口下界(含历史的天数,**业务可调**)────────────────────────────
|
||||||
|
// 业务口径(2026-10-20 三次纠偏):「截止某时刻创建、至今仍未发货」—— 不按自然日切,
|
||||||
|
// 昨天、前天乃至更早挂着没发的单都要算进来(滚动累积的待发池)。
|
||||||
|
//
|
||||||
|
// ⚠️ 但不能无限往前:rpt 下全是视图、没有索引,扫得越远越慢。2026-10-20 实测
|
||||||
|
// (jst_Orders + jst_qmcust 并行,含已发单的窗口全量):
|
||||||
|
// 近 7 天 → 约 5s 近 30 天 → 约 45s
|
||||||
|
// 近 90 天 → 约 130s 近 365 天 → 约 500s(基本等于页面卡死)
|
||||||
|
// 所以默认取 30 天:覆盖「今日 + 近一个月累积未发」,兼顾口径与可用性。
|
||||||
|
//
|
||||||
|
// 同时实测 28 个目标仓的**全部未发单**:共 1,228 条,其中近 7 天 257、
|
||||||
|
// 近 30 天 496、近 90 天 792、更早 436(最早 2025-10-24)。
|
||||||
|
// ⟹ 30 天能看到约 40%;要看更久的老单,把下面数字改大,代价是首屏更慢。
|
||||||
|
const HISTORY_LOOKBACK_DAYS = 30;
|
||||||
|
|
||||||
|
// 只排除取消/合并单;Question 仍计入(与 mkhBomService 待发货口径一致)
|
||||||
|
//
|
||||||
|
// 🔥 Split(拆单):**必须排除**。拆单是「父单被拆成子单」的中间态,父单本身
|
||||||
|
// 不会发货(`send_date` 永远为空),子单才是真正进仓发货的那一单。
|
||||||
|
// 2026-10-20 实测:不加这个条件,一年前的 Split 父单全被算成「超时未发」,
|
||||||
|
// 全库未发 Split 单占比极高 —— 会让页面变成上万条的垃圾列表。
|
||||||
|
// ⚠️ 与 mkhBomService 的差异:那边是「预警今天要发的货」,Split 父单不带真实
|
||||||
|
// 发货需求,被算进去也无害;这边是「考核仓库时效」,误算就要背锅,必须排除。
|
||||||
|
const EXCLUDED_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split')`;
|
||||||
|
|
||||||
|
// ── 「已发货 / 待发货」状态口径(2026-10-20 四次修正,业务反馈数据错误)────
|
||||||
|
// 🔥 之前只排除了 Cancelled/Merged/Split,靠 `send_date IS NULL` 判「未发」,
|
||||||
|
// 实测**大量已发货的单被算成超时未发**(业务一眼就看出来了)。原因有三个:
|
||||||
|
// ① `send_date` 在部分店铺/平台不回写,订单状态已经是 Sent/Delivering,
|
||||||
|
// send_date 却还是 NULL(样本 9838341:Delivering,send_date 空);
|
||||||
|
// ② 拆单父单 `status` 不是 'Split' 而是 'Delivering'(父单随子单状态走),
|
||||||
|
// `is_split=1` 才是真标记(样本 9838537 就是这样);
|
||||||
|
// ③ 合并单 `is_merge=1` 同理,被并入主单,自己永不发货。
|
||||||
|
// 订单状态实测分布(2026-10-20,近 7 天,两表合计):
|
||||||
|
// Sent 61,833 / Cancelled 22,292 / Split 4,927 / Delivering 2,567 /
|
||||||
|
// Question 1,500 / Merged 682 / WaitOuterSent 30 / WaitConfirm 12
|
||||||
|
// ⟹ 判定「已发货」= status ∈ (Sent, Delivering) **或** send_date 非空
|
||||||
|
// **或** 销售出库单表能查到该 o_id(见 OUTBOUND_TABLES)
|
||||||
|
const SHIPPED_STATUS = Object.freeze(['Sent', 'Delivering']);
|
||||||
|
|
||||||
|
// 待出库候选:把「已发货」状态一并排除。判定顺序是
|
||||||
|
// ① SQL 先粗筛(下面这个条件 + send_date IS NULL + 非拆单/合并)
|
||||||
|
// ② Node 侧再查销售出库单表精确剔除(有出库记录 = 其实已出库)
|
||||||
|
// ③ Node 侧再把 Question(疑问单)分流到「业务挂起」,**不计超时**
|
||||||
|
// —— 注意 Question 必须留在候选里,否则统计不出挂起单数;
|
||||||
|
// 它只是不进 orders(超时明细),走 heldCount / heldOrders。
|
||||||
|
const PENDING_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split', N'Sent', N'Delivering')`;
|
||||||
|
|
||||||
|
// 拆单父单 / 被合并单:自己**永不发货**,货由子单或主单发出。
|
||||||
|
// 只排 status 不够(父单状态会跟着子单变成 Delivering),必须看标记位。
|
||||||
|
// ⚠️ is_split / is_merge 是 bit 且可能为 NULL —— 必须 ISNULL 兜底,
|
||||||
|
// 否则 `o.is_split = 0` 会把 NULL 行全部丢掉(实测会少掉大半候选)。
|
||||||
|
const NOT_SPLIT_MERGED_SQL = `ISNULL(o.is_split, 0) = 0 AND ISNULL(o.is_merge, 0) = 0`;
|
||||||
|
|
||||||
|
// ── 销售出库单表(聚水潭「销售出库单」页面的数据来源)──────────────────────
|
||||||
|
// 业务明确要求(2026-10-20):「已出库 / 待出库」与「创建时间」都以聚水潭
|
||||||
|
// **销售出库单**页面为准,不再用订单表的 send_date 猜。
|
||||||
|
//
|
||||||
|
// 两张表与订单表同构、店铺互斥:
|
||||||
|
// rpt.jst_salesOutboundInfo ← jst_Orders(京东/拼多多/抖音/得物/唯品会),103.7 万行
|
||||||
|
// rpt.jst_qmcust_salesoutbound ← jst_qmcust_orderlist(天猫/淘宝/1688),39.8 万行
|
||||||
|
// 字段口径(2026-10-20 实测确认):
|
||||||
|
// · `created` —— **出库单创建时间**(业务在聚水潭出库单页面看到的那个),
|
||||||
|
// 时点分布自然(8~23 点均匀,凌晨 4 点有一波补录),可用;
|
||||||
|
// · `io_date` —— **出库时间**(真正发货那一刻);
|
||||||
|
// · `modified` —— ⚠️ 会被数仓每天凌晨批量刷新(实测全被刷成 02:2x / 04:3x),
|
||||||
|
// 与订单表的 modified 一样**不可当作创建时间**;
|
||||||
|
// · **两张表都只含「已出库」的单**:103.7 万行里 io_date 为 0 条空值。
|
||||||
|
// ⟹ 「待出库」无法从出库单表直接查,只能靠「查不到 = 还没出库」反推。
|
||||||
|
const OUTBOUND_TABLES = Object.freeze({
|
||||||
|
jst_Orders: 'jst_salesOutboundInfo',
|
||||||
|
jst_qmcust_orderlist: 'jst_qmcust_salesoutbound',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 聚水潭「订单实时状态」表(2026-10-20 五次修正:按审核时间核算)──────────
|
||||||
|
// 🔥 前面三版的根因都在这里:`jst_Orders.status` / `jst_qmcust_orderlist.status`
|
||||||
|
// 是**下单那一刻的快照**,之后聚水潭侧取消、售后、审核都不会回写。
|
||||||
|
// 实测(2026-10-20):业务反馈「聚水潭查不到」的 6 单,订单表里
|
||||||
|
// status 全是 `WaitConfirm`,而在本表里真实状态是 **已取消**(发货前售后);
|
||||||
|
// 另有 1 单真实状态是 **异常**。上一版把它们当成「待出库超时」报了出来。
|
||||||
|
//
|
||||||
|
// `rpt.jst_order_express_details`(402 万行,query_time 准实时,最近一次今天 11:55)
|
||||||
|
// 是聚水潭订单状态的准实时镜像,字段语义(近 30 天实测覆盖率):
|
||||||
|
// · `order_status_name` —— 聚水潭真实订单状态
|
||||||
|
// 已发货 269,991(send_time 100%)|已取消 64,440|异常 6,669
|
||||||
|
// 发货中 2,448(first_oa 99.96%、send_time 0)|已支付待审核 833
|
||||||
|
// · `first_oa_confirm_time` —— **订单审核时间**(首次 OA 确认)= 业务要的起算点
|
||||||
|
// · `last_oa_confirm_time` —— 最后一次审核(重新审核会刷新)
|
||||||
|
// · `send_time` —— **发货时间**(已发货单 100% 有值,未发单必为空)
|
||||||
|
// · `warehouse_name` —— 发货仓名称(可直接套 CENTERS 关键词匹配)
|
||||||
|
// · `logistic_status_name` —— 物流状态(无物流单 / 未揽收 / 运输中 …)
|
||||||
|
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
|
||||||
|
const ORDER_STATE_TABLE = 'jst_order_express_details';
|
||||||
|
|
||||||
|
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
|
||||||
|
const SHIPPING_STATUS = '发货中';
|
||||||
|
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
|
||||||
|
const UNAUDITED_STATUS = '已支付待审核';
|
||||||
|
// 「异常」= 订单异常(缺货/地址/风控等),有审核时间的单列「异常待处理」,不计仓库超时
|
||||||
|
const ABNORMAL_STATUS = '异常';
|
||||||
|
// 不进考核也不展示的状态:已发货(已发)、已取消(业务侧取消/发货前售后)、
|
||||||
|
// 等供销商|外仓发货(不是本仓发)
|
||||||
|
// 🔥 「已取消」(业务口头叫**作废单**)必须排除(2026-10-20 业务定:「作废订单不用考核」):
|
||||||
|
// 这类单是客户付款后取消 / 发货前售后(标签带「发货前售后」),仓库根本不会发货。
|
||||||
|
// 2026-10-20 实测:近 30 天已取消 64,440 单,量级远大于真正该考核的「发货中」2,448 单,
|
||||||
|
// 漏掉这个排除,整个页面会被作废单淹没。
|
||||||
|
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
||||||
|
|
||||||
|
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
|
||||||
|
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
|
||||||
|
//
|
||||||
|
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补:
|
||||||
|
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。
|
||||||
|
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51),
|
||||||
|
// 是**同步落库时间**,用了会把起算点整体推后几小时。
|
||||||
|
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
|
||||||
|
// 要换掉。
|
||||||
|
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
|
||||||
|
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
|
||||||
|
// 这才是聚水潭订单列表里「创建时间」那一列。
|
||||||
|
//
|
||||||
|
// 性能(2026-10-20 实测 9,676 个候选):
|
||||||
|
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。
|
||||||
|
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。
|
||||||
|
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||||
|
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
||||||
|
const CREATED_BACKFILL_BATCH = 200;
|
||||||
|
|
||||||
|
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
|
||||||
|
// 业务 2026-10-20 反馈 so_id 5127276315127080023(内部单号 9486414)是作废单,
|
||||||
|
// 却出现在超时未发货里。四张表对照后的真相:
|
||||||
|
// · 状态表 jst_order_express_details:order_status_name=**发货中**、send_time 空
|
||||||
|
// ⟹ 只信状态表的话,这单就是「超时未发」—— 我们就是这么误判的;
|
||||||
|
// · 订单表 jst_qmcust_orderlist:status=**Merged**、send_date=2026-09-22 06:54:49;
|
||||||
|
// · 销售出库单:两张表**都有**出库记录(io_date 2026-09-22 10:59:55)。
|
||||||
|
// ⟹ 根因:状态表的 `send_time` 对**被合并单 / 拆单父单不回写**,
|
||||||
|
// 单子早随主单发走了,状态表还停在「发货中」。
|
||||||
|
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
||||||
|
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
||||||
|
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
||||||
|
|
||||||
|
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||||
|
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||||
|
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||||
|
const SHIP_TIMELINESS_HOURS = 24;
|
||||||
|
|
||||||
|
const WAREHOUSE_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||||
|
let warehouseCache = { at: 0, rows: [] };
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 北京时间(UTC+8)偏移,分钟
|
||||||
|
const BEIJING_OFFSET_MINUTES = 480;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「按北京时间读出来」的当前时刻。
|
||||||
|
*
|
||||||
|
* 🔥 为什么不能直接 `new Date()`(2026-10-21 生产实测踩坑):
|
||||||
|
* 生产容器 `/etc/timezone = Etc/UTC`(date = UTC),而 MSSQL `GETDATE()` 返回**北京时间**,
|
||||||
|
* mssql 驱动 `useUTC:false` 又把 DB 值构造成「墙钟当 UTC」的 Date。
|
||||||
|
* 于是「服务器 now」比「库里的时间」慢 8 小时 —— **北京时间每天 00:00~08:00,
|
||||||
|
* 服务端取到的「今天」是昨天**,轮次窗口 / 核验时刻整体错位
|
||||||
|
* (实测 08:04 跑 13:00 轮得 overdueUnshipped=3895,本地北京时区同一轮只有 6 单)。
|
||||||
|
*
|
||||||
|
* 做法:把真实时刻平移成一个「本地 getter 读出来正好是北京时间」的 Date,
|
||||||
|
* 这样 `getHours()/getDate()/formatDateTime()` 与库里的字符串可以直接比较。
|
||||||
|
* 本机已是东八区时平移量为 0,行为不变(测试无需改)。
|
||||||
|
*
|
||||||
|
* @param {Date} [base] 真实时刻,默认 `new Date()`
|
||||||
|
* @returns {Date} 本地 getter = 北京时间的 Date
|
||||||
|
*/
|
||||||
|
function beijingNow(base = new Date()) {
|
||||||
|
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
|
||||||
|
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用「关键词最长匹配」把仓库名归到主仓。
|
||||||
|
*
|
||||||
|
* ⚠️ **先判排除、再判归属**:被排除的仓(如「京仓-横林二仓京东云仓」)
|
||||||
|
* 即使名字里带主仓关键词,也直接返回 null —— 它的订单不计入任何主仓。
|
||||||
|
*
|
||||||
|
* @returns {object|null} CENTERS 里的元素;无法归属或被排除返回 null
|
||||||
|
*/
|
||||||
|
function resolveCenter(warehouseName) {
|
||||||
|
if (!warehouseName) return null;
|
||||||
|
const name = String(warehouseName);
|
||||||
|
for (const kw of SORTED_EXCLUSIONS) {
|
||||||
|
if (name.includes(kw)) return null;
|
||||||
|
}
|
||||||
|
for (const item of SORTED_KEYWORDS) {
|
||||||
|
if (name.includes(item.kw)) return item.center;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取仓库表并建立「主仓 → wms_co_id 列表」映射。
|
||||||
|
* 缓存 1 小时;仓库表变更不频繁,且新增子仓会自动被关键词捞到。
|
||||||
|
*/
|
||||||
|
async function loadCenterWarehouseMap(force = false) {
|
||||||
|
const fresh = Date.now() - warehouseCache.at < WAREHOUSE_CACHE_TTL_MS;
|
||||||
|
if (!force && fresh && warehouseCache.rows.length) {
|
||||||
|
return warehouseCache.rows;
|
||||||
|
}
|
||||||
|
// ⚠️ cloudDatabase.query() 直接返回 recordset 数组,不是 {recordset} 对象
|
||||||
|
const rows = (await cloudDatabase.query(
|
||||||
|
`SELECT Id, name, wms_co_id
|
||||||
|
FROM dbo.jst_warehouseInfo`,
|
||||||
|
{},
|
||||||
|
{ requestTimeout: 60000 }
|
||||||
|
)) || [];
|
||||||
|
const byCenter = new Map(CENTERS.map((c) => [c.key, []]));
|
||||||
|
const unmatched = [];
|
||||||
|
const excluded = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = String(row.name || '');
|
||||||
|
// 被业务排除的仓单独记录,方便日后核对「是不是排多了/排少了」
|
||||||
|
if (SORTED_EXCLUSIONS.some((kw) => name.includes(kw))) {
|
||||||
|
excluded.push({ wmsCoId: Number(row.wms_co_id), name });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const center = resolveCenter(name);
|
||||||
|
if (center && byCenter.has(center.key)) {
|
||||||
|
byCenter.get(center.key).push({
|
||||||
|
wmsCoId: Number(row.wms_co_id),
|
||||||
|
warehouseId: Number(row.Id),
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unmatched.push({ wmsCoId: Number(row.wms_co_id), name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const list = CENTERS.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
placeholder: Boolean(c.placeholder),
|
||||||
|
warehouses: byCenter.get(c.key) || [],
|
||||||
|
}));
|
||||||
|
warehouseCache = {
|
||||||
|
at: Date.now(),
|
||||||
|
rows: list,
|
||||||
|
unmatchedCount: unmatched.length,
|
||||||
|
excluded,
|
||||||
|
};
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWarehouseCache() {
|
||||||
|
warehouseCache = { at: 0, rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算「当前已生效」的核验轮次。
|
||||||
|
*
|
||||||
|
* 13:00 之前 → **未到核验时间,返回 null**。
|
||||||
|
* 业务明确要求(2026-10-20 确认):未到核验时刻不做核验,
|
||||||
|
* 前端显示「未到核验时间」,**不要回填昨天的数据** ——
|
||||||
|
* 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。
|
||||||
|
* 13:00~17:59 → 用 13:00 轮(今天 00:00~07:00 **审核** + 历史未发)
|
||||||
|
* 18:00 之后 → 用 18:00 轮(截止 16:00 前**审核** + 今天之前未发的历史单)
|
||||||
|
*
|
||||||
|
* 🔥 「含历史」是 2026-10-20 业务第三次纠偏:
|
||||||
|
* 原实现把下界卡在「今天 00:00」,只统计当天新单,把昨天及更早挂着没发的
|
||||||
|
* 单全漏掉了。业务口径是「……然后再看历史未发的订单」——
|
||||||
|
* 这是一个滚动累积的待发池,不按自然日切。
|
||||||
|
*
|
||||||
|
* @param {Date} now 北京时间(默认 `beijingNow()`,容器是 UTC 时也安全)
|
||||||
|
* @returns {object|null} null 表示当前不在任何核验时段内
|
||||||
|
*/
|
||||||
|
function resolveActiveRound(now = beijingNow()) {
|
||||||
|
const hour = now.getHours();
|
||||||
|
const minute = now.getMinutes();
|
||||||
|
const minutes = hour * 60 + minute;
|
||||||
|
let active = null;
|
||||||
|
for (const round of ROUNDS) {
|
||||||
|
if (minutes >= round.verifyHour * 60) active = round;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13:00 前:首轮核验还没开始,不做判定
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = pad2(now.getMonth() + 1);
|
||||||
|
const d = pad2(now.getDate());
|
||||||
|
// 护栏起点(不是业务下界,见 HISTORY_LOOKBACK_DAYS)
|
||||||
|
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
|
||||||
|
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
|
||||||
|
return {
|
||||||
|
...active,
|
||||||
|
// deadline = 核验时刻:判「发了没发 / 超时多久」都用它
|
||||||
|
deadline: `${y}-${m}-${d} ${pad2(active.verifyHour)}:00:00`,
|
||||||
|
windowStart: floorStr,
|
||||||
|
// windowEnd = 订单窗口上界:SQL 的取数上界,**不是** deadline
|
||||||
|
windowEnd: `${y}-${m}-${d} ${pad2(active.windowEndHour)}:00:00`,
|
||||||
|
// 含历史未发单 —— 前端据此把「统计窗口 00:00~07:00」改成「截止 07:00 前(含历史)」
|
||||||
|
includeHistory: true,
|
||||||
|
date: `${y}-${m}-${d}`,
|
||||||
|
settled: false,
|
||||||
|
isPreviousDay: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
|
||||||
|
*/
|
||||||
|
function describeRounds(now = beijingNow()) {
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = pad2(now.getMonth() + 1);
|
||||||
|
const d = pad2(now.getDate());
|
||||||
|
const date = `${y}-${m}-${d}`;
|
||||||
|
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
|
||||||
|
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
|
||||||
|
return ROUNDS.map((r) => ({
|
||||||
|
key: r.key,
|
||||||
|
label: r.label,
|
||||||
|
date,
|
||||||
|
verifyHour: r.verifyHour,
|
||||||
|
deadline: `${date} ${pad2(r.verifyHour)}:00:00`,
|
||||||
|
windowStart: floorStr,
|
||||||
|
windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`,
|
||||||
|
includeHistory: true,
|
||||||
|
active: minutes >= r.verifyHour * 60,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下一轮的核验时刻。
|
||||||
|
*
|
||||||
|
* ⚠️ 2026-10-20 第九版起**不再使用**:轮次归属本身就是按审核时间卡的,
|
||||||
|
* 「审核晚于 13:00 的单」根本进不了 13:00 轮,会自然落到 18:00 轮,
|
||||||
|
* 不需要额外顺延。函数保留是因为「按下一轮核验时刻算」这个业务表述
|
||||||
|
* 未来若用于别的口径(例如按创建时间分轮),逻辑还是这一套。
|
||||||
|
*
|
||||||
|
* @param {object} round resolveActiveRound / describeRounds 的返回项(需带 date/key)
|
||||||
|
* @returns {string} 'YYYY-MM-DD HH:mm:00'
|
||||||
|
*/
|
||||||
|
function nextRoundDeadline(round) {
|
||||||
|
const idx = ROUNDS.findIndex((r) => r.key === round.key);
|
||||||
|
if (idx >= 0 && idx + 1 < ROUNDS.length) {
|
||||||
|
return `${round.date} ${pad2(ROUNDS[idx + 1].verifyHour)}:00:00`;
|
||||||
|
}
|
||||||
|
// 当天最后一轮 → 次日首轮
|
||||||
|
const [y, m, d] = String(round.date).split('-').map(Number);
|
||||||
|
const next = new Date(y, (m || 1) - 1, (d || 1) + 1);
|
||||||
|
return `${next.getFullYear()}-${pad2(next.getMonth() + 1)}-${pad2(next.getDate())}`
|
||||||
|
+ ` ${pad2(ROUNDS[0].verifyHour)}:00:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CENTERS,
|
||||||
|
ROUNDS,
|
||||||
|
HISTORY_LOOKBACK_DAYS,
|
||||||
|
EXCLUDED_WAREHOUSE_KEYWORDS,
|
||||||
|
resolveCenter,
|
||||||
|
centerWindowEndHour,
|
||||||
|
centerWindowEnd,
|
||||||
|
nextRoundDeadline,
|
||||||
|
loadCenterWarehouseMap,
|
||||||
|
clearWarehouseCache,
|
||||||
|
getWarehouseCache: () => warehouseCache,
|
||||||
|
resolveActiveRound,
|
||||||
|
describeRounds,
|
||||||
|
beijingNow,
|
||||||
|
BEIJING_OFFSET_MINUTES,
|
||||||
|
EXCLUDED_STATUS_SQL,
|
||||||
|
SHIPPED_STATUS,
|
||||||
|
PENDING_STATUS_SQL,
|
||||||
|
NOT_SPLIT_MERGED_SQL,
|
||||||
|
OUTBOUND_TABLES,
|
||||||
|
ORDER_STATE_TABLE,
|
||||||
|
SHIPPING_STATUS,
|
||||||
|
UNAUDITED_STATUS,
|
||||||
|
ABNORMAL_STATUS,
|
||||||
|
IGNORED_STATES,
|
||||||
|
CREATED_SOURCE_TABLES,
|
||||||
|
CREATED_BACKFILL_BATCH,
|
||||||
|
VOID_ORDER_STATUS,
|
||||||
|
SHIP_TIMELINESS_HOURS,
|
||||||
|
toNumber,
|
||||||
|
};
|
||||||
775
backend/src/services/warehouseShipTimelinessService.js
Normal file
775
backend/src/services/warehouseShipTimelinessService.js
Normal file
@ -0,0 +1,775 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仓储中心 24 小时发货时效 —— 服务层
|
||||||
|
*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* 口径(2026-10-20 第九次修正,业务最终定版)
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* 业务原话(按时间顺序,最后一条为准):
|
||||||
|
* 17:12「当日 7 点前订单(7 点前订单**按照审核时间算**),13:00 核验未发就算超时」
|
||||||
|
* 17:5x「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 16 点审核的订单 18:00 核验;
|
||||||
|
* 然后再看历史未发的订单」
|
||||||
|
*
|
||||||
|
* ⟹ 三条铁律:
|
||||||
|
* ① **轮次归属看「审核时间」**(`last_oa_confirm_time`,聚水潭显示值):
|
||||||
|
* 今天 00:00~07:00 审核 → 13:00 轮
|
||||||
|
* **截止今天 16:00 前审核**的单 → 18:00 轮
|
||||||
|
* (业务 2026-10-20 补正:18:00 核验的是 16:00 前**所有**审核过的单,
|
||||||
|
* 不是「今天 00:00~16:00」这一个自然日切片 —— 含今天之前审核、
|
||||||
|
* 至今没发的历史未发单,也含 7 点前审核到 13:00 还没发出的;
|
||||||
|
* 山东两仓 15:00、河南洛宁 13:00 是仓级例外,见 registry)
|
||||||
|
* ⚠️ **不看创建时间**:业务亲自指出的 9818476(创建 10-18 12:49 / 审核 10-20 13:06)
|
||||||
|
* 和 9823980(创建 10-18 20:40 / 审核 10-20 09:15)都是下午/上午才审的,
|
||||||
|
* 按创建时间会被错算进 13:00 轮 —— 第八版就是这么错的。
|
||||||
|
* ② **历史未发的单一律算**:昨天及更早审核、至今没发货的,两个轮次都要看到
|
||||||
|
* (它们早就该发了,不该等今天的分段窗口)。
|
||||||
|
* ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。
|
||||||
|
* 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。
|
||||||
|
* ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充):
|
||||||
|
* 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」),
|
||||||
|
* 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。
|
||||||
|
*
|
||||||
|
* 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。
|
||||||
|
* 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)——
|
||||||
|
* 这就是聚水潭订单列表「审核时间」那一列显示的值,业务拿它对账。
|
||||||
|
* 之前取首次审核,业务抽查 3 单(5127368691073091434 等)立刻发现不一致:
|
||||||
|
* 首次 10-18 13:15 vs 聚水潭 10-20 13:38。**别再改成 first_oa**。
|
||||||
|
* 因为窗口也按审核时间卡,「审核 13:06 的单」只会进 18:00 轮,
|
||||||
|
* 已等自然用 18:00 算,不会出现「0 分」。
|
||||||
|
* 明细列表并列「审核时间 / 发货时间」—— 业务 2026-10-20 要求**去掉创建时间**,
|
||||||
|
* 改成看各订单的发货时间(未发的显示「未发货」),对账时直接和聚水潭发货时间比。
|
||||||
|
*
|
||||||
|
* 🔥 「已发 / 未发」还是以聚水潭「销售订单」的发货状态为准(= 状态表 send_time)。
|
||||||
|
* **不要**改用运单号(logistic_id)判已发:实测同一店铺里,
|
||||||
|
* 「已发货」的单独有运单号 + 未揽收,「发货中」的单也有运单号 + 未揽收 —— 判不出来。
|
||||||
|
* 运单号只用来在明细里标「已打单 · 未揽收」,给业务一个「仓库干到哪一步」的线索。
|
||||||
|
*
|
||||||
|
* 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行)
|
||||||
|
*
|
||||||
|
* 🔥 时区(2026-10-21 生产实测踩坑,别再裸用 `new Date()`):
|
||||||
|
* · 生产容器 `/etc/timezone = Etc/UTC`,而 MSSQL `GETDATE()` 返回**北京时间**,
|
||||||
|
* mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
|
||||||
|
* · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**,
|
||||||
|
* 轮次日期 / 核验窗口整体错位(实测 08:04 跑 13:00 轮得 3895 单,本地仅 6 单)。
|
||||||
|
* · 统一用 `registry.beijingNow()` 取「现在」(本机已是东八区时平移量为 0,行为不变)。
|
||||||
|
* **不要**去改容器 TZ —— 会波及全部既有定时任务。
|
||||||
|
*
|
||||||
|
* · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值;
|
||||||
|
* 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。
|
||||||
|
* · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created`
|
||||||
|
* 🔥 状态表里**没有**这一列,必须回订单表按 `o_id = inner_order_id` 补:
|
||||||
|
* · `createdAt`(状态表那个 datetime2)= 同步落库时间,晚 1.5~3.5h,**不能用**
|
||||||
|
* · `created` 才是聚水潭订单列表里的「创建时间」(比下单晚 15~30 秒)
|
||||||
|
* · 已发货 = `send_time` 非空(已发货单 100% 有值)
|
||||||
|
* · 待发货 = `send_time` 为空 且 `order_status_name = 发货中`
|
||||||
|
* · 仓库 = `warehouse_name`(直接套 CENTERS 关键词归属主仓)
|
||||||
|
*
|
||||||
|
* ── 为什么弃用 jst_Orders.status(前三版全栽在这)──────────────────────────
|
||||||
|
* 订单表的 `status` 是**下单那一刻的快照**,之后聚水潭侧取消、发货前售后、
|
||||||
|
* 审核通过都不会回写。实测(2026-10-20)业务反馈「聚水潭查不到」的 6 单:
|
||||||
|
* 订单表 status 全是 `WaitConfirm`(看起来像待发货),
|
||||||
|
* 状态表真实状态是 **已取消**(标签带「发货前售后」),另 1 单是 **异常**。
|
||||||
|
* ⟹ 所以「状态 / 是否已发」一律以状态表为准,**订单表只用来补创建时间**。
|
||||||
|
*
|
||||||
|
* 时间列语义(已逐一实测,别再猜):
|
||||||
|
* · `order_date` / `pay_date` —— 平台下单 / 付款时间(客户那边的时间)
|
||||||
|
* · `created`(订单表) —— ✅ **聚水潭创建时间**(展示用,比下单晚 15~30 秒)
|
||||||
|
* · `createTime`/`updateTime` —— 数仓灌库时间(带毫秒、批量、滞后约 5 分钟)
|
||||||
|
* · `modified` —— ⚠️ 最后修改时间,每天凌晨被批量刷新,不可用
|
||||||
|
* · `first_oa_confirm_time` —— 首次审单时间(审核时间缺失时的第二顺位回退)
|
||||||
|
* · `last_oa_confirm_time` —— ✅ **最新审核时间(本版考核基准)**
|
||||||
|
* · `send_time` —— **发货时间**
|
||||||
|
* · `query_time` —— 本表快照时间(判数据新鲜度)
|
||||||
|
*
|
||||||
|
* ── 三态分流(互斥)─────────────────────────────────────────────────────
|
||||||
|
* 超时未发货 = 发货中 + send_time 空 + 审核时间已过本轮窗口上界(07:00/16:00)
|
||||||
|
* → orders(考核仓库)
|
||||||
|
* 不计考核 = 已支付待审核(没审单)/ 异常(订单异常) → heldOrders
|
||||||
|
* 不展示 = 已发货 / 已取消(作废)/ 等供销商|外仓发货 → 直接丢弃
|
||||||
|
* 🔥 作废单(已取消)不考核:近 30 天 64,440 单,是「发货中」2,448 单的 26 倍,
|
||||||
|
* 不排除的话整页都是客户取消掉的单。
|
||||||
|
*
|
||||||
|
* ── 核验轮次(业务给定)──────────────────────────────────────────────────
|
||||||
|
* `verifyHour` —— 几点核验(13:00 / 18:00),判「超时多久」用它
|
||||||
|
* `windowEndHour` —— 只看该时刻**前审核**的单(07:00 / 16:00,仓级例外见 registry)
|
||||||
|
* ⚠️ 两者不是同一个东西,别合成一个字段
|
||||||
|
* ⚠️ 也**不要**再引入「满 N 小时才算超时」的门槛(第七版踩过,会被业务打回)
|
||||||
|
* ⚠️ 窗口**要**卡审核时间(第九版定版)。第六版曾因「卡审核时间 13:00 轮只剩 2 单」
|
||||||
|
* 退回创建时间,结果 9818476 / 9823980 这类下午才审的单被错算进 13:00 轮 →
|
||||||
|
* 业务亲自纠正:「7 点前订单**按照审核时间算**」。审核时间少是因为 13:00 轮本来就
|
||||||
|
* 只该有早上那批,不是数据不对。
|
||||||
|
*
|
||||||
|
* ── 性能(2026-10-20 实测,四个坑都踩过)──────────────────────────────────
|
||||||
|
* ① **不要 SELECT 宽列**。同一 WHERE 只取 2 列 0.7s,取 15 列(含
|
||||||
|
* `order_labels` / `buyer_message` / `remark`)**54s** —— 视图没法做
|
||||||
|
* 覆盖扫描,多取一列就是几万次基表回表。本表只投影 10 个窄列。
|
||||||
|
* ② **不要回订单表补金额**。`o_id IN (400 个)` 打两张订单宽视图,单条实测
|
||||||
|
* **38.7s**。发货时效不需要金额,直接不做。
|
||||||
|
* (但补 `created` **可以**:只取两列时 9,676 个候选只要 14.5s + 2.9s。)
|
||||||
|
* ③ **不要额外查新鲜度**。`MAX(send_time)` 又是一次 402 万行扫描(21.1s)。
|
||||||
|
* 快照时间直接从主查询的 `query_time` 在 Node 里取 MAX。
|
||||||
|
* ④ `SELECT *` 更是灾难 —— 单 form 单号查一次 **5 分钟**不返回。
|
||||||
|
* ⑤ 走 `querySlow`,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。
|
||||||
|
*
|
||||||
|
* ── 时区 ─────────────────────────────────────────────────────────────────
|
||||||
|
* 所有时间列都是**东八区本地时间**;服务与库都在东八区。
|
||||||
|
* **不要**在 Node 侧用 UTC 日期判断。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
|
const registry = require('./warehouseShipTimelinessRegistry');
|
||||||
|
|
||||||
|
const {
|
||||||
|
ORDER_STATE_TABLE,
|
||||||
|
SHIPPING_STATUS,
|
||||||
|
UNAUDITED_STATUS,
|
||||||
|
ABNORMAL_STATUS,
|
||||||
|
IGNORED_STATES,
|
||||||
|
CREATED_SOURCE_TABLES,
|
||||||
|
CREATED_BACKFILL_BATCH,
|
||||||
|
VOID_ORDER_STATUS,
|
||||||
|
resolveCenter,
|
||||||
|
} = registry;
|
||||||
|
|
||||||
|
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
|
||||||
|
const DEFAULT_DETAIL_LIMIT = 500;
|
||||||
|
const MAX_DETAIL_LIMIT = 2000;
|
||||||
|
|
||||||
|
// 状态表快照超过这个分钟数没刷新 → 页面提示「数据滞后」。
|
||||||
|
// 正常 ETL 每小时至少刷一轮(每小时约 1000 行有变化),90 分钟足够容错。
|
||||||
|
const STATE_STALE_MINUTES = 90;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不参与发货时效考核的快递(业务 2026-10-20 指定):
|
||||||
|
* 自提 —— 客户自己来拉,仓库不负责派送
|
||||||
|
* 中铁 —— 中铁物流(大件专线)
|
||||||
|
* 壹米滴答 —— 专线物流(大件)
|
||||||
|
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
|
||||||
|
* 只影响「超时未发」判定,不影响「已发」判定。
|
||||||
|
*/
|
||||||
|
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。
|
||||||
|
*
|
||||||
|
* 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」——
|
||||||
|
* 销售订单页面显示「已发货」= status `Sent`。`send_date` 很多店铺不回写
|
||||||
|
* (实测 103 单里 103 单都是空),所以必须把 status 也当依据。
|
||||||
|
* ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」,
|
||||||
|
* 正是本页要考核的对象;算进来整页就空了。
|
||||||
|
*/
|
||||||
|
const ORDER_SHIPPED_STATUS = ['Sent'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结果缓存。
|
||||||
|
*
|
||||||
|
* 这张视图单次查询实测 10~60 秒(取决于缓存温度与库负载),页面又是
|
||||||
|
* `AUTO_REFRESH_MS` 定时轮询 —— 不缓存的话每轮轮询都会重压一次库,
|
||||||
|
* 页面也会一直转圈。缓存 key 含「轮次 + 日期 + limit」,
|
||||||
|
* 业务点「刷新」时前端带 `force=1` 绕过缓存强制重查。
|
||||||
|
*/
|
||||||
|
const CACHE_TTL_MS = Number(process.env.CANGKU_WST_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||||
|
let resultCache = { key: '', at: 0, data: null };
|
||||||
|
|
||||||
|
/** 清缓存(切轮次 / 手工排查时用) */
|
||||||
|
function clearShipTimelinessCache() {
|
||||||
|
resultCache = { key: '', at: 0, data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 datetime / Date / 字符串统一成 'YYYY-MM-DD HH:mm:ss'(东八区,不做时区换算) */
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
if (value instanceof Date) {
|
||||||
|
if (Number.isNaN(value.getTime())) return '';
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}`
|
||||||
|
+ ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`;
|
||||||
|
}
|
||||||
|
const s = String(value);
|
||||||
|
if (!s) return '';
|
||||||
|
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 两个时间文本相差多少分钟(to - from);任一为空返回 0 */
|
||||||
|
function diffMinutes(fromText, toText) {
|
||||||
|
const a = formatDateTime(fromText);
|
||||||
|
const b = formatDateTime(toText);
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
const pa = new Date(a.replace(' ', 'T')).getTime();
|
||||||
|
const pb = new Date(b.replace(' ', 'T')).getTime();
|
||||||
|
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return 0;
|
||||||
|
return Math.floor((pb - pa) / 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「聚水潭订单状态」里所有未发货的单(发货中 / 待审核 / 异常)。
|
||||||
|
*
|
||||||
|
* 为什么不用 wms_co_id 过滤:本表只有 `warehouse_name`,没有仓 ID。
|
||||||
|
* 近 30 天三类合计约 1 万行,一次取回在 Node 侧按仓库名归属主仓,代价可接受。
|
||||||
|
*
|
||||||
|
* ⚠️ 只投影 11 个**窄列**:`order_labels`/`buyer_message`/`remark` 这类长文本
|
||||||
|
* 会把 0.7s 的查询拖到 54s(视图无法覆盖扫描,每行都要回基表)。
|
||||||
|
* ⚠️ `send_time IS NULL` 是关键:已发货单在这里 100% 有值。
|
||||||
|
*
|
||||||
|
* 🔥 2026-10-20 第八版:**不再把「创建 ≤ 核验−24h」下推到 SQL**。
|
||||||
|
* 本版口径是「到核验时刻未发即超时」,`send_time IS NULL` 就是候选;
|
||||||
|
* 窗口上界改在 Node 侧按**审核时间**判。SQL 里那两个时间列只是用来
|
||||||
|
* 判断窗口归属,不再参与「等了多少小时」的门槛。
|
||||||
|
*/
|
||||||
|
async function loadPendingStates(round) {
|
||||||
|
const rows = await cloudDatabase.querySlow(
|
||||||
|
// 🔥 时间列直接取原始 datetime,**不要**在 SQL 里 CONVERT 成字符串:
|
||||||
|
// CONVERT 会作用到扫描到的每一行(谓词下推不到视图里),实测把点查拖到 40s+;
|
||||||
|
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
|
||||||
|
// 就是库里存的北京时间,不会偏移 8 小时。
|
||||||
|
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
||||||
|
e.order_status_name, e.logistic_status_name,
|
||||||
|
e.logistic_id, e.logistic_company_name,
|
||||||
|
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
|
||||||
|
e.send_time, e.query_time
|
||||||
|
FROM dbo.${ORDER_STATE_TABLE} e
|
||||||
|
WHERE e.send_time IS NULL
|
||||||
|
AND e.pay_time >= @windowStart
|
||||||
|
AND e.order_status_name IN (@shipStatus, @st1, @st2)`,
|
||||||
|
{
|
||||||
|
shipStatus: SHIPPING_STATUS,
|
||||||
|
st1: UNAUDITED_STATUS,
|
||||||
|
st2: ABNORMAL_STATUS,
|
||||||
|
windowStart: round.windowStart,
|
||||||
|
},
|
||||||
|
// 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s)
|
||||||
|
{ connectRequestTimeoutMs: 300000 }
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 并发跑任务,最多 `limit` 个同时在飞(不把连接池打满) */
|
||||||
|
async function mapLimit(items, limit, worker) {
|
||||||
|
const out = [];
|
||||||
|
let cursor = 0;
|
||||||
|
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
for (;;) {
|
||||||
|
const i = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
if (i >= items.length) return;
|
||||||
|
out[i] = await worker(items[i], i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回订单表补「起算点 + 作废事实」。
|
||||||
|
*
|
||||||
|
* 状态表 `jst_order_express_details` 里没有聚水潭创建时间(`createdAt` 是同步落库
|
||||||
|
* 时间,比付款晚 1.5~3.5 小时),只能回两张订单表按 `o_id = inner_order_id` 取
|
||||||
|
* `created`(稳定比下单晚 15~30 秒,即聚水潭订单列表里的「创建时间」)。
|
||||||
|
*
|
||||||
|
* 🔥 同一趟把**作废事实**也取回来(业务 2026-10-20 反馈作废单被算成超时):
|
||||||
|
* 状态表的 `send_time` 对**被合并单 / 拆单父单不回写** —— 单子早随主单发走了,
|
||||||
|
* 状态表却还是「发货中 + send_time 空」。所以必须再看订单表的
|
||||||
|
* `status` / `send_date` / `is_merge` / `is_split`(见 registry.VOID_ORDER_STATUS)。
|
||||||
|
*
|
||||||
|
* ⚠️ 只投影这 6 个**窄列**。实测 9,676 个候选:窄列 14.5s + 2.9s,
|
||||||
|
* 一旦连 pay_amount / labels 一起取,单批 400 个就要 38.7s。
|
||||||
|
*
|
||||||
|
* @param {number[]} oids 内部单号
|
||||||
|
* @returns {Promise<Map<number, {createdAt:string, status:string, sendDate:string, void:boolean, shipped:boolean}>>}
|
||||||
|
*/
|
||||||
|
async function backfillOrderFacts(oids) {
|
||||||
|
const map = new Map();
|
||||||
|
if (!oids.length) return map;
|
||||||
|
|
||||||
|
let remaining = oids;
|
||||||
|
for (const table of CREATED_SOURCE_TABLES) {
|
||||||
|
if (!remaining.length) break;
|
||||||
|
const batches = [];
|
||||||
|
for (let i = 0; i < remaining.length; i += CREATED_BACKFILL_BATCH) {
|
||||||
|
batches.push(remaining.slice(i, i + CREATED_BACKFILL_BATCH));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await mapLimit(batches, 4, async (batch) => {
|
||||||
|
try {
|
||||||
|
return await cloudDatabase.querySlow(
|
||||||
|
`SELECT o_id, created, status, send_date, is_merge, is_split
|
||||||
|
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
|
||||||
|
{},
|
||||||
|
{ connectRequestTimeoutMs: 300000 }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 补不到不致命:调用方会回退到付款时间,不整页失败
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rows of results) {
|
||||||
|
for (const r of rows || []) {
|
||||||
|
const id = Number(r.o_id);
|
||||||
|
if (!Number.isFinite(id)) continue;
|
||||||
|
const status = String(r.status || '');
|
||||||
|
const sendDate = formatDateTime(r.send_date);
|
||||||
|
map.set(id, {
|
||||||
|
createdAt: formatDateTime(r.created),
|
||||||
|
status,
|
||||||
|
sendDate,
|
||||||
|
// 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货
|
||||||
|
void: VOID_ORDER_STATUS.includes(status)
|
||||||
|
|| Number(r.is_merge) === 1
|
||||||
|
|| Number(r.is_split) === 1,
|
||||||
|
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
|
||||||
|
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
|
||||||
|
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining = remaining.filter((n) => !map.has(n));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySummary() {
|
||||||
|
return {
|
||||||
|
// 超时未发货(考核仓库的部分)
|
||||||
|
overdueUnshipped: 0,
|
||||||
|
overdueCenterCount: 0,
|
||||||
|
oldestDays: 0,
|
||||||
|
// 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任
|
||||||
|
heldCount: 0,
|
||||||
|
// 已剔除的作废 / 已发单(页面只作说明,不进考核)
|
||||||
|
voidCount: 0,
|
||||||
|
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答)
|
||||||
|
carrierSkipped: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmptyCenters(centerList) {
|
||||||
|
return centerList.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
placeholder: c.placeholder,
|
||||||
|
warehouseCount: c.warehouses.length,
|
||||||
|
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
|
||||||
|
overdueUnshipped: 0,
|
||||||
|
oldestDays: 0,
|
||||||
|
heldCount: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getShipTimeliness(options = {}) {
|
||||||
|
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
|
||||||
|
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
|
||||||
|
const now = options.now instanceof Date ? options.now : registry.beijingNow();
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||||
|
MAX_DETAIL_LIMIT
|
||||||
|
);
|
||||||
|
|
||||||
|
// ① 主仓清单(用于展示「含子仓 N 个」;归属判定走 warehouse_name 关键词)
|
||||||
|
const centerList = await registry.loadCenterWarehouseMap();
|
||||||
|
const pad2 = (n) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// ② 决定本轮截止时刻
|
||||||
|
const rounds = registry.describeRounds(now);
|
||||||
|
let round = null;
|
||||||
|
if (options.round) {
|
||||||
|
const target = registry.ROUNDS.find((r) => r.key === String(options.round).toUpperCase());
|
||||||
|
if (!target) {
|
||||||
|
const err = new Error(`未知的核验轮次: ${options.round}`);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
round = rounds.find((r) => r.key === target.key);
|
||||||
|
if (!round) {
|
||||||
|
const err = new Error(`无法构造核验窗口: ${options.round}`);
|
||||||
|
err.statusCode = 500;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
round = registry.resolveActiveRound(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 13:00 前没有轮次生效(业务口径):直接返回「未到核验时间」,不查库
|
||||||
|
if (!round) {
|
||||||
|
const nextRound = rounds.find((r) => !r.active) || rounds[0];
|
||||||
|
return {
|
||||||
|
generatedAt: formatDateTime(now),
|
||||||
|
date: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`,
|
||||||
|
round: null,
|
||||||
|
notStarted: true,
|
||||||
|
isPreviousDay: false,
|
||||||
|
rounds,
|
||||||
|
nextRoundAt: nextRound ? nextRound.deadline : '',
|
||||||
|
nextWindowEnd: nextRound ? nextRound.windowEnd : '',
|
||||||
|
deadline: '',
|
||||||
|
window: null,
|
||||||
|
summary: emptySummary(),
|
||||||
|
centers: buildEmptyCenters(centerList),
|
||||||
|
orders: [],
|
||||||
|
heldOrders: [],
|
||||||
|
truncated: false,
|
||||||
|
detailLimit: limit,
|
||||||
|
detailDegraded: false,
|
||||||
|
freshness: null,
|
||||||
|
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时
|
||||||
|
// 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用
|
||||||
|
const force = options.force === true
|
||||||
|
|| ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase());
|
||||||
|
const cacheKey = `${round.key}|${round.date}|${limit}`;
|
||||||
|
if (!force && resultCache.data && resultCache.key === cacheKey
|
||||||
|
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
|
||||||
|
return {
|
||||||
|
...resultCache.data,
|
||||||
|
cached: true,
|
||||||
|
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let stateRows = [];
|
||||||
|
try {
|
||||||
|
stateRows = await loadPendingStates(round);
|
||||||
|
} catch (e) {
|
||||||
|
const err = new Error(`聚水潭订单状态表查询失败:${e.message}`);
|
||||||
|
err.statusCode = 502;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③-b 补起算点「聚水潭创建时间」+ 作废事实(状态表两样都没有,回订单表取)
|
||||||
|
const oids = [...new Set(
|
||||||
|
stateRows.map((r) => Number(r.inner_order_id)).filter((n) => Number.isFinite(n))
|
||||||
|
)];
|
||||||
|
const orderFacts = await backfillOrderFacts(oids);
|
||||||
|
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
||||||
|
let voidCount = 0;
|
||||||
|
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账
|
||||||
|
let carrierSkipped = 0;
|
||||||
|
|
||||||
|
// ④ 分桶
|
||||||
|
const acc = new Map(centerList.map((c) => [c.key, {
|
||||||
|
pendingCount: 0,
|
||||||
|
oldestDays: 0,
|
||||||
|
heldCount: 0,
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const pendingRows = [];
|
||||||
|
const heldRows = [];
|
||||||
|
const seenSo = new Set();
|
||||||
|
let snapshotAt = '';
|
||||||
|
|
||||||
|
for (const row of stateRows) {
|
||||||
|
// 快照时间:状态表最后一次刷新(顺带算,不再单独扫一遍 402 万行表)
|
||||||
|
const qt = formatDateTime(row.query_time);
|
||||||
|
if (qt > snapshotAt) snapshotAt = qt;
|
||||||
|
|
||||||
|
const center = resolveCenter(row.warehouse_name);
|
||||||
|
if (!center) continue; // 非 11 主仓(代发仓/未指定仓/POP 仓…)
|
||||||
|
const bucket = acc.get(center.key);
|
||||||
|
if (!bucket) continue;
|
||||||
|
|
||||||
|
const state = String(row.order_status_name || '');
|
||||||
|
// 🔥 作废单不考核 —— 业务 2026-10-20 明确「作废订单不用考核」。
|
||||||
|
// 状态表的 send_time 对**被合并单 / 拆单父单不回写**(案例 5127276315127080023:
|
||||||
|
// 状态表「发货中 + send_time 空」,订单表却是 Merged 且 send_date 有值、
|
||||||
|
// 出库单也早就生成了),所以必须回订单表再判一次,光信状态表会被冤枉。
|
||||||
|
const fact = orderFacts.get(Number(row.inner_order_id));
|
||||||
|
if (fact && (fact.void || fact.shipped)) {
|
||||||
|
voidCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉
|
||||||
|
if (IGNORED_STATES.includes(state)) continue;
|
||||||
|
|
||||||
|
// 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 ——
|
||||||
|
// 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
|
||||||
|
// 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。
|
||||||
|
// 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。
|
||||||
|
const carrier = String(row.logistic_company_name || '').trim();
|
||||||
|
const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k));
|
||||||
|
if (hitCarrier) {
|
||||||
|
carrierSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 窗口归属 = **审核时间**(业务 2026-10-20 最终定版,两次纠偏后的结论)────
|
||||||
|
// 业务原话:「当日 7 点前订单(7 点前订单**按照审核时间算**)……」
|
||||||
|
// 实测案例(业务亲自指出的两单):
|
||||||
|
// · 9818476:创建 10-18 12:49,但**审核 10-20 13:06** → 不是 7 点前的单,13:00 轮不能出现
|
||||||
|
// · 9823980:创建 10-18 20:40,**审核 10-20 09:15** → 同理,归 18:00 轮
|
||||||
|
// ⟹ 「7 点前/16 点前」卡的是**审核时间**:审核 ≤ 07:00 归 13:00 轮,
|
||||||
|
// 审核 ≤ 16:00 归 18:00 轮(山东两仓 15:00、河南洛宁 13:00,见 registry)。
|
||||||
|
// ⚠️ 别看创建时间:客户凌晨下单、仓库上午才审的单,按创建时间会提前算超时。
|
||||||
|
// ⚠️ 审核时间取**最后一次**(last_oa),见下面 458 行开始的说明。
|
||||||
|
const createdAt = fact ? fact.createdAt : '';
|
||||||
|
const payAt = formatDateTime(row.pay_time);
|
||||||
|
const auditFirst = formatDateTime(row.first_oa_confirm_time);
|
||||||
|
const auditLast = formatDateTime(row.last_oa_confirm_time);
|
||||||
|
// 🔥 取**最后一次**审核(= 聚水潭订单列表里「审核时间」那一列显示的值)。
|
||||||
|
// 业务 2026-10-20 20:0x 抽查 3 单指出「审核时间跟系统里的不一致」,
|
||||||
|
// 根因就是我们之前取错了列(first_oa = 首次审核):
|
||||||
|
// 5127368691073091434 首次 10-18 13:15 / 聚水潭显示 10-20 13:38
|
||||||
|
// 261019-670795046400872 首次 10-19 08:48 / 聚水潭显示 10-20 09:31
|
||||||
|
// 261019-558387771763179 首次 10-19 11:21 / 聚水潭显示 10-20 09:11
|
||||||
|
// ⟹ 页面必须跟聚水潭对齐,否则业务一对账就说「跑错了」。
|
||||||
|
const auditAt = auditLast || auditFirst;
|
||||||
|
const windowAt = auditAt || createdAt || payAt;
|
||||||
|
const benchmarkAt = windowAt;
|
||||||
|
const benchmarkSource = auditAt
|
||||||
|
? (auditLast ? 'last_audit' : 'first_audit')
|
||||||
|
: (createdAt ? 'created' : 'pay');
|
||||||
|
|
||||||
|
// 同一 so_id 可能因拆包出现多行(实测有),按 so_id + 窗口时间 去重
|
||||||
|
const dedupeKey = `${row.so_id}|${windowAt}`;
|
||||||
|
if (seenSo.has(dedupeKey)) continue;
|
||||||
|
seenSo.add(dedupeKey);
|
||||||
|
|
||||||
|
// ── 本轮核验范围(业务 2026-10-20 最终定版)────────────────────────────
|
||||||
|
// ① **今天的单按审核时间分段**:
|
||||||
|
// 今天 00:00 ~ 07:00 审核 → 13:00 轮核验
|
||||||
|
// **截止今天 16:00 前审核**的单 → 18:00 轮核验
|
||||||
|
// (= 16:00 前所有审核过的单,含 7 点前没发出的;不是「07:00~16:00」那一段。
|
||||||
|
// 山东两仓 15:00、河南洛宁 13:00 为仓级例外)
|
||||||
|
// ⟹ 今天 09:15 审核的单(9823980)、13:06 审核的单(9818476)
|
||||||
|
// **不进 13:00 轮**,只在 18:00 轮出现(业务亲自指出的两单)
|
||||||
|
// ② **历史未发的单一律算**(昨天及更早审核、至今没发货)—— 它们已经超时,
|
||||||
|
// 两个轮次都该看到,不受「今天 00:00 起」的限制。
|
||||||
|
// ③ 30 天护栏只为查询性能,不是业务边界。
|
||||||
|
const windowEndAt = registry.centerWindowEnd(center.key, round);
|
||||||
|
const todayStart = `${round.date} 00:00:00`;
|
||||||
|
if (windowAt && windowAt < round.windowStart) continue; // 超出 30 天护栏
|
||||||
|
if (windowAt && windowAt >= todayStart && windowAt > windowEndAt) continue;
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
row,
|
||||||
|
center,
|
||||||
|
state,
|
||||||
|
createdAt,
|
||||||
|
payAt,
|
||||||
|
auditAt,
|
||||||
|
windowAt,
|
||||||
|
benchmarkAt,
|
||||||
|
benchmarkSource,
|
||||||
|
warehouseName: String(row.warehouse_name || ''),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 不计考核:没审单(已支付待审核)或订单异常,都不该算仓库超时 ──────
|
||||||
|
if (state === UNAUDITED_STATUS || state === ABNORMAL_STATUS) {
|
||||||
|
bucket.heldCount += 1;
|
||||||
|
heldRows.push({
|
||||||
|
...base,
|
||||||
|
holdReason: state === UNAUDITED_STATUS ? '待审核' : '订单异常',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
|
||||||
|
|
||||||
|
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
|
||||||
|
if (!benchmarkAt) {
|
||||||
|
bucket.heldCount += 1;
|
||||||
|
heldRows.push({ ...base, holdReason: '缺审核时间' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// waited = 审核时间 → 本轮核验时刻(13:00 / 18:00)
|
||||||
|
// ⚠️ 因为窗口本身按审核时间卡(审核 ≤ 07:00 / 16:00),benchmark 必然 ≤ deadline,
|
||||||
|
// 这里不需要再处理「审核晚于核验」的情况 —— 那种单会自然落到下一轮:
|
||||||
|
// 审核 13:06 的单进不了 13:00 轮(07:00 上界),只在 18:00 轮出现,已等 = 18:00 − 13:06。
|
||||||
|
const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline));
|
||||||
|
|
||||||
|
bucket.pendingCount += 1;
|
||||||
|
const days = Math.floor(waited / 1440);
|
||||||
|
if (days > bucket.oldestDays) bucket.oldestDays = days;
|
||||||
|
pendingRows.push({ ...base, waited });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ 排序:最久的排前面(业务一眼看最急的)
|
||||||
|
pendingRows.sort((a, b) => {
|
||||||
|
if (a.windowAt < b.windowAt) return -1;
|
||||||
|
if (a.windowAt > b.windowAt) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
heldRows.sort((a, b) => {
|
||||||
|
const x = a.windowAt || '';
|
||||||
|
const y = b.windowAt || '';
|
||||||
|
if (x < y) return -1;
|
||||||
|
if (x > y) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailRows = pendingRows.slice(0, limit);
|
||||||
|
const heldDetail = heldRows.slice(0, limit);
|
||||||
|
|
||||||
|
const orders = detailRows.map((x) => {
|
||||||
|
const r = x.row;
|
||||||
|
return {
|
||||||
|
oId: String(r.inner_order_id || ''),
|
||||||
|
soId: String(r.so_id || ''),
|
||||||
|
shopName: String(r.shop_name || ''),
|
||||||
|
// 创建时间 = 聚水潭订单创建时间(业务 2026-10-20 已从明细列去掉,仅保留字段做回退基准)
|
||||||
|
createdAt: x.createdAt,
|
||||||
|
// 发货时间:本列表只取 send_time IS NULL 的单,所以这里恒为空 → 前端显示「未发货」
|
||||||
|
sendTime: formatDateTime(r.send_time),
|
||||||
|
// 付款时间(并列展示,方便对账)
|
||||||
|
payTime: x.payAt,
|
||||||
|
// 审核时间 = **考核基准**:决定进 13:00 轮还是 18:00 轮,已等时长也从它算
|
||||||
|
auditTime: x.auditAt,
|
||||||
|
// 基准时间实际取值(审核缺失时回退创建/付款)+ 来源标记
|
||||||
|
benchmarkTime: x.benchmarkAt,
|
||||||
|
benchmarkSource: x.benchmarkSource,
|
||||||
|
state: x.state,
|
||||||
|
logisticStatus: String(r.logistic_status_name || ''),
|
||||||
|
// 运单号 / 快递公司:有值说明仓库**已打单**(但还没揽收/回传发货时间)。
|
||||||
|
// 业务 2026-10-20 报「聚水潭显示已发货、页面显示未发货」,靠这两列能一眼看出卡在哪一步。
|
||||||
|
logisticId: String(r.logistic_id || '').trim(),
|
||||||
|
logisticCompany: String(r.logistic_company_name || '').trim(),
|
||||||
|
centerKey: x.center.key,
|
||||||
|
centerName: x.center.name,
|
||||||
|
warehouseName: x.warehouseName,
|
||||||
|
// 已等时长(小时,保留 1 位)与分钟(排序都用分钟)
|
||||||
|
waitedHours: Math.round((x.waited / 60) * 10) / 10,
|
||||||
|
overdueMinutes: x.waited,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const heldOrders = heldDetail.map((x) => {
|
||||||
|
const r = x.row;
|
||||||
|
const from = x.benchmarkAt || '';
|
||||||
|
return {
|
||||||
|
oId: String(r.inner_order_id || ''),
|
||||||
|
soId: String(r.so_id || ''),
|
||||||
|
shopName: String(r.shop_name || ''),
|
||||||
|
createdAt: x.createdAt,
|
||||||
|
sendTime: formatDateTime(r.send_time),
|
||||||
|
payTime: x.payAt,
|
||||||
|
auditTime: x.auditAt,
|
||||||
|
benchmarkTime: x.benchmarkAt,
|
||||||
|
benchmarkSource: x.benchmarkSource,
|
||||||
|
state: x.state,
|
||||||
|
holdReason: x.holdReason,
|
||||||
|
logisticStatus: String(r.logistic_status_name || ''),
|
||||||
|
logisticId: String(r.logistic_id || '').trim(),
|
||||||
|
logisticCompany: String(r.logistic_company_name || '').trim(),
|
||||||
|
centerKey: x.center.key,
|
||||||
|
centerName: x.center.name,
|
||||||
|
warehouseName: x.warehouseName,
|
||||||
|
heldDays: from ? Math.floor(diffMinutes(from, round.deadline) / 1440) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const centers = centerList.map((c) => {
|
||||||
|
const b = acc.get(c.key) || { pendingCount: 0, oldestDays: 0, heldCount: 0 };
|
||||||
|
return {
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
placeholder: c.placeholder,
|
||||||
|
warehouseCount: c.warehouses.length,
|
||||||
|
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
|
||||||
|
overdueUnshipped: b.pendingCount,
|
||||||
|
oldestDays: b.oldestDays,
|
||||||
|
heldCount: b.heldCount,
|
||||||
|
// 该仓在本轮的窗口上界(可能是仓级例外,如山东两仓 15:00、河南洛宁 13:00)
|
||||||
|
windowEnd: registry.centerWindowEnd(c.key, round),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 本轮有「仓级例外窗口」的仓(给前端提示用;没有就是空数组)
|
||||||
|
const windowOverrides = centerList
|
||||||
|
.filter((c) => registry.centerWindowEndHour(c.key, round.key) !== null)
|
||||||
|
.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
name: c.name,
|
||||||
|
hour: registry.centerWindowEndHour(c.key, round.key),
|
||||||
|
windowEnd: registry.centerWindowEnd(c.key, round),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = centers.reduce((a, c) => {
|
||||||
|
a.overdueUnshipped += c.overdueUnshipped;
|
||||||
|
if (c.overdueUnshipped > 0) a.overdueCenterCount += 1;
|
||||||
|
if (c.oldestDays > a.oldestDays) a.oldestDays = c.oldestDays;
|
||||||
|
a.heldCount += c.heldCount || 0;
|
||||||
|
return a;
|
||||||
|
}, emptySummary());
|
||||||
|
summary.voidCount = voidCount;
|
||||||
|
summary.carrierSkipped = carrierSkipped;
|
||||||
|
|
||||||
|
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
|
||||||
|
// 🔥 2026-10-20 业务报「已发货的单还显示未发货」时才发现这里曾写死 stale:false ——
|
||||||
|
// 状态表是**准实时镜像**,上游 ETL 一停(实测当天 15:48 之后 0 行刷新),
|
||||||
|
// 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。
|
||||||
|
// 必须把滞后暴露到页面上,否则业务只会以为判定写错。
|
||||||
|
const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null;
|
||||||
|
const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES;
|
||||||
|
// 🔥 更狠的一条:快照**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
|
||||||
|
// 实测 2026-10-20 18:54:快照停在 15:48,18:00 核验时早已发出的单全被判「未发货」,
|
||||||
|
// 业务连续报了 3 单都是这个原因。这种时候数字必然偏大,必须红字告警。
|
||||||
|
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
|
||||||
|
const freshness = {
|
||||||
|
stateMaxQuery: snapshotAt,
|
||||||
|
ageMinutes: snapshotAgeMin,
|
||||||
|
stale,
|
||||||
|
coversDeadline,
|
||||||
|
hint: [
|
||||||
|
stale
|
||||||
|
? `聚水潭订单状态镜像最新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。`
|
||||||
|
: '',
|
||||||
|
!coversDeadline
|
||||||
|
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
|
||||||
|
+ '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。'
|
||||||
|
: '',
|
||||||
|
].filter(Boolean).join(' '),
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
generatedAt: formatDateTime(now),
|
||||||
|
date: round.date,
|
||||||
|
round: {
|
||||||
|
key: round.key,
|
||||||
|
label: round.label,
|
||||||
|
verifyHour: round.verifyHour,
|
||||||
|
windowEnd: round.windowEnd,
|
||||||
|
},
|
||||||
|
notStarted: false,
|
||||||
|
isPreviousDay: false,
|
||||||
|
rounds,
|
||||||
|
deadline: round.deadline,
|
||||||
|
// boundary='audit':轮次归属、已等时长都按**首次审核时间**算
|
||||||
|
window: {
|
||||||
|
start: round.windowStart,
|
||||||
|
end: round.windowEnd,
|
||||||
|
includeHistory: true,
|
||||||
|
boundary: 'audit',
|
||||||
|
measuredBy: 'audit',
|
||||||
|
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
|
||||||
|
overrides: windowOverrides,
|
||||||
|
},
|
||||||
|
summary,
|
||||||
|
centers,
|
||||||
|
orders,
|
||||||
|
// 不考核的快递(前端文案直接用,避免两边各写一份)
|
||||||
|
excludedCarriers: EXCLUDED_CARRIERS,
|
||||||
|
// 不计考核清单(待审核 / 异常)—— 与 orders 互斥
|
||||||
|
heldOrders,
|
||||||
|
truncated: pendingRows.length > orders.length,
|
||||||
|
detailLimit: limit,
|
||||||
|
detailDegraded: false,
|
||||||
|
freshness,
|
||||||
|
cached: false,
|
||||||
|
cacheAgeSec: 0,
|
||||||
|
note: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
resultCache = { key: cacheKey, at: Date.now(), data: payload };
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getShipTimeliness,
|
||||||
|
clearShipTimelinessCache,
|
||||||
|
DEFAULT_DETAIL_LIMIT,
|
||||||
|
MAX_DETAIL_LIMIT,
|
||||||
|
};
|
||||||
@ -525,6 +525,74 @@ 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');
|
||||||
@ -1286,6 +1354,117 @@ 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');
|
||||||
|
|||||||
171
backend/test/pickingBinSalesAnalysis.test.js
Normal file
171
backend/test/pickingBinSalesAnalysis.test.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
'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');
|
||||||
|
});
|
||||||
667
backend/test/warehouseShipTimeliness.test.js
Normal file
667
backend/test/warehouseShipTimeliness.test.js
Normal file
@ -0,0 +1,667 @@
|
|||||||
|
/**
|
||||||
|
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版)
|
||||||
|
*
|
||||||
|
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义:
|
||||||
|
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走;
|
||||||
|
* ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、
|
||||||
|
* 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段;
|
||||||
|
* ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)——
|
||||||
|
* 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错;
|
||||||
|
* ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**:
|
||||||
|
* 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。
|
||||||
|
* ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做,
|
||||||
|
* 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废;
|
||||||
|
* ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。
|
||||||
|
* 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回;
|
||||||
|
* ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃;
|
||||||
|
* **作废单(已取消 / 合并 / 拆单父单)不考核**;
|
||||||
|
* ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。
|
||||||
|
*
|
||||||
|
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
|
||||||
|
*/
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const registry = require('../src/services/warehouseShipTimelinessRegistry');
|
||||||
|
|
||||||
|
// ── ① 主仓归属:最长匹配 ────────────────────────────────────────────────
|
||||||
|
test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => {
|
||||||
|
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCenter 能捞到各种命名的子仓', () => {
|
||||||
|
const cases = [
|
||||||
|
['简尚-运营C横林二仓专用仓', 'HL2'],
|
||||||
|
['简尚-横林二仓京东仓', 'HL2'],
|
||||||
|
['京仓-横林二仓京东自营仓', 'HL2'],
|
||||||
|
['简尚-崔桥工厂仓唯品仓', 'CQ'],
|
||||||
|
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
|
||||||
|
['简尚-新苏北临时仓', 'XSB'],
|
||||||
|
['简尚-分销部睢宁专用仓', 'SN'],
|
||||||
|
['京仓-山东青岗集京东仓储', 'SD_QGJ'],
|
||||||
|
['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'],
|
||||||
|
['简尚-河南洛宁仓', 'HN_LN'],
|
||||||
|
];
|
||||||
|
for (const [name, expected] of cases) {
|
||||||
|
assert.equal(registry.resolveCenter(name)?.key, expected, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('「京仓-横林二仓京东云仓」业务指定排除,不计入横林二仓', () => {
|
||||||
|
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null);
|
||||||
|
// ⚠️ 不能连带排掉名字相近的仓 —— 关键词必须写完整片段
|
||||||
|
assert.equal(registry.resolveCenter('简尚-横林二仓京东仓')?.key, 'HL2');
|
||||||
|
assert.equal(registry.resolveCenter('京仓-横林二仓京东自营仓')?.key, 'HL2');
|
||||||
|
assert.equal(registry.resolveCenter('京仓-崔桥工厂仓京东云仓')?.key, 'CQ');
|
||||||
|
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
|
||||||
|
const shouldBeNull = [
|
||||||
|
'工厂-山东菏泽森美代发仓',
|
||||||
|
'工厂-山东恒思代发仓',
|
||||||
|
'工厂-河南千优代发仓',
|
||||||
|
'工厂-河北霸州派雅世佳代发仓',
|
||||||
|
'简尚-苏州福利品仓',
|
||||||
|
];
|
||||||
|
for (const name of shouldBeNull) {
|
||||||
|
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => {
|
||||||
|
const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP');
|
||||||
|
assert.ok(c, 'CQ_WIP 必须存在');
|
||||||
|
assert.equal(c.placeholder, undefined, '已接入真实仓,不能再是占位仓');
|
||||||
|
assert.equal(c.name, '崔桥工厂半成品包装项目仓');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓')?.key, 'CQ_WIP');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓')?.key, 'CQ');
|
||||||
|
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓')?.key, 'CQ_BRAND');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
|
||||||
|
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
|
||||||
|
|
||||||
|
test('13:00 前不做核验,返回 null(不回填昨日数据)', () => {
|
||||||
|
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
|
||||||
|
assert.equal(registry.resolveActiveRound(at(12, 59)), null);
|
||||||
|
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('13:00~17:59 用 13:00 轮,窗口是「截止今日 07:00 前创建(含历史)」', () => {
|
||||||
|
for (const [h, m] of [[13, 0], [15, 30], [17, 59]]) {
|
||||||
|
const r = registry.resolveActiveRound(at(h, m));
|
||||||
|
assert.ok(r, `${h}:${m} 应已进入首轮核验`);
|
||||||
|
assert.equal(r.key, 'NOON');
|
||||||
|
// 核验时刻 = 13:00(判定「发了没发 / 超时多久」用它)
|
||||||
|
assert.equal(r.deadline, '2026-10-20 13:00:00');
|
||||||
|
assert.equal(r.verifyHour, 13);
|
||||||
|
// 订单窗口上界 = 07:00(只核验该时刻前创建的单)
|
||||||
|
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
|
||||||
|
assert.notEqual(r.deadline, r.windowEnd, '核验时刻与窗口上界必须是两个值');
|
||||||
|
assert.equal(r.includeHistory, true);
|
||||||
|
assert.notEqual(r.windowStart, '2026-10-20 00:00:00', '下界不能卡在当天 00:00');
|
||||||
|
assert.equal(r.windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => {
|
||||||
|
const r = registry.resolveActiveRound(at(18, 0));
|
||||||
|
assert.equal(r.key, 'AFTERNOON');
|
||||||
|
assert.equal(r.deadline, '2026-10-20 18:00:00');
|
||||||
|
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
|
||||||
|
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于「等 18:00 轮」)', () => {
|
||||||
|
const rounds = registry.describeRounds(at(16, 5));
|
||||||
|
assert.deepEqual(
|
||||||
|
rounds.map((r) => [r.key, r.deadline.slice(11, 16), r.windowEnd.slice(11, 16)]),
|
||||||
|
[['NOON', '13:00', '07:00'], ['AFTERNOON', '18:00', '16:00']]
|
||||||
|
);
|
||||||
|
assert.equal(rounds[0].active, true);
|
||||||
|
assert.equal(rounds[1].active, false, '16:05 还没到 18:00 轮');
|
||||||
|
assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('审核晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => {
|
||||||
|
const [noon, afternoon] = registry.describeRounds(at(16, 5));
|
||||||
|
assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00');
|
||||||
|
assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00',
|
||||||
|
'18:00 是当天最后一轮 → 次日 13:00');
|
||||||
|
// 跨月边界也要对
|
||||||
|
assert.equal(
|
||||||
|
registry.nextRoundDeadline({ key: 'AFTERNOON', date: '2026-10-31' }),
|
||||||
|
'2026-11-01 13:00:00'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ③ 常量口径 ──────────────────────────────────────────────────────────
|
||||||
|
test('数据源必须是聚水潭实时状态表,不是订单表', () => {
|
||||||
|
assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => {
|
||||||
|
assert.equal(registry.SHIPPING_STATUS, '发货中');
|
||||||
|
assert.equal(registry.UNAUDITED_STATUS, '已支付待审核');
|
||||||
|
assert.equal(registry.ABNORMAL_STATUS, '异常');
|
||||||
|
// 页面/路由仍叫「24 小时发货时效」,但判定是 13:00/18:00 到点即超时
|
||||||
|
assert.equal(registry.SHIP_TIMELINESS_HOURS, 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('作废单(已取消)明确不考核', () => {
|
||||||
|
assert.ok(
|
||||||
|
registry.IGNORED_STATES.includes('已取消'),
|
||||||
|
'「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍'
|
||||||
|
);
|
||||||
|
assert.ok(registry.IGNORED_STATES.includes('已发货'));
|
||||||
|
assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建时间要从订单表补,且只补窄列', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
[...registry.CREATED_SOURCE_TABLES],
|
||||||
|
['jst_Orders', 'jst_qmcust_orderlist'],
|
||||||
|
'两张订单表都要查(店铺互斥,交集为 0)'
|
||||||
|
);
|
||||||
|
assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '必须分批,不能一次 IN 上万个');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||||||
|
function stubDb(dbPath, scenario = 'default') {
|
||||||
|
const wmsId = 15038426;
|
||||||
|
|
||||||
|
// 创建时间(展示)+ 作废事实来源:o_id → 订单表行
|
||||||
|
// 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间
|
||||||
|
const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o });
|
||||||
|
|
||||||
|
const defaultFacts = {
|
||||||
|
1: f('jst_Orders', { created: '2026-09-25 00:50:00' }),
|
||||||
|
2: f('jst_Orders', { created: '2026-10-20 16:20:00' }),
|
||||||
|
6: f('jst_Orders', { created: '2026-10-20 17:00:00' }),
|
||||||
|
7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }),
|
||||||
|
9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }),
|
||||||
|
// 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例
|
||||||
|
// 5127276315127080023(内部单号 9486414)就是这样被误判成超时的
|
||||||
|
10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }),
|
||||||
|
// 11:状态表 send_time 空,订单表 send_date 有值(已发)
|
||||||
|
11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }),
|
||||||
|
// 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货)
|
||||||
|
// —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」
|
||||||
|
37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }),
|
||||||
|
// 12:拆单父单,status 不是 Split 但 is_split=1
|
||||||
|
12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }),
|
||||||
|
13: f('jst_Orders', { created: '2026-10-20 15:00:00' }),
|
||||||
|
// 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮)
|
||||||
|
31: f('jst_Orders', { created: '2026-10-20 15:30:00' }),
|
||||||
|
32: f('jst_Orders', { created: '2026-10-20 14:30:00' }),
|
||||||
|
33: f('jst_Orders', { created: '2026-10-20 13:30:00' }),
|
||||||
|
34: f('jst_Orders', { created: '2026-10-20 12:30:00' }),
|
||||||
|
// 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476)
|
||||||
|
// 只能进 18:00 轮,不能进 13:00 轮
|
||||||
|
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
||||||
|
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
|
||||||
|
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
|
||||||
|
const noonFacts = {
|
||||||
|
21: f('jst_Orders', { created: '2026-10-20 06:45:00' }),
|
||||||
|
22: f('jst_Orders', { created: '2026-10-20 05:55:00' }),
|
||||||
|
23: f('jst_Orders', { created: '2026-10-19 19:55:00' }),
|
||||||
|
24: f('jst_Orders', { created: '2026-10-20 07:30:00' }),
|
||||||
|
// 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算
|
||||||
|
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
||||||
|
// 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算
|
||||||
|
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const factsMap = scenario === 'noon' ? noonFacts : defaultFacts;
|
||||||
|
|
||||||
|
const mk = (o) => ({
|
||||||
|
shop_name: '测试店',
|
||||||
|
logistic_status_name: '',
|
||||||
|
query_time: '2026-10-20 11:18:27',
|
||||||
|
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
|
||||||
|
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultRows = [
|
||||||
|
// 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时
|
||||||
|
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 01:00:00' }),
|
||||||
|
// 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间)
|
||||||
|
mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 16:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 16:30:00' }),
|
||||||
|
// 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时
|
||||||
|
mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-26 00:10:00',
|
||||||
|
last_oa_confirm_time: '2026-09-26 00:15:00' }),
|
||||||
|
// 4:已支付待审核(还没审单、没有审核时间)→ 不计考核
|
||||||
|
mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00',
|
||||||
|
last_oa_confirm_time: null }),
|
||||||
|
// 5:异常 → 不计考核
|
||||||
|
mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-23 01:05:00' }),
|
||||||
|
// 6:审核 17:05 晚于窗口上界 → 本轮不该核验
|
||||||
|
mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 16:55:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 17:05:00' }),
|
||||||
|
// 7:被业务排除的京东云仓 → 不归属任何主仓
|
||||||
|
mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-25 00:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 00:10:00' }),
|
||||||
|
// 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重
|
||||||
|
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 01:00:00' }),
|
||||||
|
// 9:作废单(已取消)—— 业务定性不考核
|
||||||
|
mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '已取消', pay_time: '2026-09-21 08:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-21 08:10:00' }),
|
||||||
|
// 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除
|
||||||
|
mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-21 09:59:00',
|
||||||
|
last_oa_confirm_time: '2026-09-21 10:05:00' }),
|
||||||
|
// 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除
|
||||||
|
mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-21 10:59:00',
|
||||||
|
last_oa_confirm_time: '2026-09-21 11:05:00' }),
|
||||||
|
// 12:拆单父单(is_split=1)—— 自己不发货,必须剔除
|
||||||
|
mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-21 11:59:00',
|
||||||
|
last_oa_confirm_time: '2026-09-21 12:05:00' }),
|
||||||
|
// 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉,
|
||||||
|
// 本版必须算超时(业务口径:到点未发就算超时)
|
||||||
|
mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 14:59:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 15:30:00' }),
|
||||||
|
// ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ──
|
||||||
|
// 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验
|
||||||
|
mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 15:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 15:35:00' }),
|
||||||
|
// 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时
|
||||||
|
mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 14:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 14:35:00' }),
|
||||||
|
// 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验
|
||||||
|
mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 13:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:35:00' }),
|
||||||
|
// 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时
|
||||||
|
mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 12:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 12:35:00' }),
|
||||||
|
// 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。
|
||||||
|
// 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分
|
||||||
|
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
|
||||||
|
first_oa_confirm_time: '2026-10-20 13:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:10:00' }),
|
||||||
|
// 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10)
|
||||||
|
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
|
||||||
|
first_oa_confirm_time: '2026-10-19 10:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:30:00' }),
|
||||||
|
// 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。
|
||||||
|
// 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped
|
||||||
|
mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-25 01:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }),
|
||||||
|
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
|
||||||
|
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
|
||||||
|
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
|
||||||
|
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
|
||||||
|
last_oa_confirm_time: '2026-09-22 09:05:00' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
|
||||||
|
const noonRows = [
|
||||||
|
// 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分
|
||||||
|
mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 06:40:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 07:20:00' }),
|
||||||
|
// 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛)
|
||||||
|
mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 05:50:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 06:40:00' }),
|
||||||
|
// 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到
|
||||||
|
mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-19 19:50:00',
|
||||||
|
last_oa_confirm_time: '2026-10-19 20:10:00' }),
|
||||||
|
// 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间)
|
||||||
|
mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-20 07:29:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 07:40:00' }),
|
||||||
|
// 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。
|
||||||
|
// 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分
|
||||||
|
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
|
||||||
|
first_oa_confirm_time: '2026-10-19 10:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:30:00' }),
|
||||||
|
// 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算,
|
||||||
|
// 不能显示 0 分(真实案例:创建 10-18 的单今天才审)
|
||||||
|
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
|
||||||
|
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
|
||||||
|
first_oa_confirm_time: '2026-10-20 13:10:00',
|
||||||
|
last_oa_confirm_time: '2026-10-20 13:10:00' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = scenario === 'noon' ? noonRows : defaultRows;
|
||||||
|
|
||||||
|
const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] };
|
||||||
|
|
||||||
|
const handler = async (sql, params) => {
|
||||||
|
const text = String(sql);
|
||||||
|
if (/jst_warehouseInfo/u.test(text)) {
|
||||||
|
return [
|
||||||
|
{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId },
|
||||||
|
{ Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 },
|
||||||
|
{ Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 },
|
||||||
|
{ Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表
|
||||||
|
if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) {
|
||||||
|
calls.backfill += 1;
|
||||||
|
calls.backfillSql.push(text);
|
||||||
|
const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders';
|
||||||
|
const m = text.match(/IN \(([0-9,\s]+)\)/u);
|
||||||
|
const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : [];
|
||||||
|
return ids
|
||||||
|
.filter((id) => factsMap[id] && factsMap[id].table === table)
|
||||||
|
.map((id) => {
|
||||||
|
const x = factsMap[id];
|
||||||
|
return {
|
||||||
|
o_id: id,
|
||||||
|
created: x.created,
|
||||||
|
status: x.status,
|
||||||
|
send_date: x.send_date,
|
||||||
|
is_merge: x.is_merge,
|
||||||
|
is_split: x.is_split,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
calls.state += 1;
|
||||||
|
calls.sql.push(text);
|
||||||
|
calls.params.push(params || {});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
require.cache[dbPath] = {
|
||||||
|
id: dbPath,
|
||||||
|
filename: dbPath,
|
||||||
|
loaded: true,
|
||||||
|
exports: { query: handler, querySlow: handler },
|
||||||
|
};
|
||||||
|
return { calls, wmsId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStubbedService(fn, scenario = 'default') {
|
||||||
|
return async () => {
|
||||||
|
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||||
|
const realDb = require.cache[dbPath];
|
||||||
|
const ctx = stubDb(dbPath, scenario);
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||||
|
try {
|
||||||
|
const svc = require('../src/services/warehouseShipTimelinessService');
|
||||||
|
await fn(svc, ctx);
|
||||||
|
} finally {
|
||||||
|
if (realDb) require.cache[dbPath] = realDb;
|
||||||
|
else delete require.cache[dbPath];
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||||
|
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => {
|
||||||
|
// 18:05 → 18:00 轮:窗口上界 16:00(按审核时间),核验时刻 18:00
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
|
||||||
|
const c = data.centers.find((x) => x.key === 'CQ');
|
||||||
|
assert.ok(c, '应能归到崔桥工厂仓');
|
||||||
|
|
||||||
|
// 超时 = 1(09-25 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时)
|
||||||
|
// + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核)
|
||||||
|
// = 6 单
|
||||||
|
// 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发
|
||||||
|
// 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的
|
||||||
|
assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时');
|
||||||
|
assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天');
|
||||||
|
assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单');
|
||||||
|
|
||||||
|
assert.equal(data.summary.overdueUnshipped, 7);
|
||||||
|
assert.equal(data.summary.overdueCenterCount, 3);
|
||||||
|
assert.equal(data.summary.oldestDays, 25);
|
||||||
|
assert.equal(data.summary.heldCount, 2);
|
||||||
|
|
||||||
|
// 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示
|
||||||
|
assert.equal(data.orders.length, 7);
|
||||||
|
assert.equal(String(data.orders[0].oId), '1');
|
||||||
|
assert.equal(data.orders[0].auditTime, '2026-09-25 01:00:00', '基准是审核时间');
|
||||||
|
assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00', '创建时间只作展示');
|
||||||
|
assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00');
|
||||||
|
assert.equal(data.orders[0].benchmarkSource, 'last_audit');
|
||||||
|
// 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟
|
||||||
|
assert.equal(data.orders[0].overdueMinutes, 37020);
|
||||||
|
|
||||||
|
// 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算
|
||||||
|
const fresh = data.orders.find((o) => String(o.oId) === '13');
|
||||||
|
assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)');
|
||||||
|
assert.equal(fresh.overdueMinutes, 150);
|
||||||
|
assert.equal(fresh.waitedHours, 2.5);
|
||||||
|
|
||||||
|
// 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290
|
||||||
|
const lateAudit = data.orders.find((o) => String(o.oId) === '26');
|
||||||
|
assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)');
|
||||||
|
assert.equal(lateAudit.overdueMinutes, 290);
|
||||||
|
|
||||||
|
// 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10
|
||||||
|
const reAudited = data.orders.find((o) => String(o.oId) === '25');
|
||||||
|
assert.ok(reAudited, '重审单也要算');
|
||||||
|
assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核');
|
||||||
|
assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30');
|
||||||
|
|
||||||
|
// 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间
|
||||||
|
const third = data.orders.find((o) => String(o.oId) === '3');
|
||||||
|
assert.ok(third, '补不到创建时间的单也要算超时');
|
||||||
|
assert.equal(third.createdAt, '', '确实没补到');
|
||||||
|
assert.equal(third.benchmarkSource, 'last_audit');
|
||||||
|
assert.equal(third.payTime, '2026-09-26 00:10:00');
|
||||||
|
|
||||||
|
// 创建时间晚于窗口上界 → 本轮不核验(2 号:创建 16:20、6 号:创建 17:00)
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验');
|
||||||
|
|
||||||
|
// ── 仓级例外窗口(业务 2026-10-20:山东两仓 15:00、河南洛宁 13:00)──
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '31'),
|
||||||
|
'山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界,18:00 轮不核验');
|
||||||
|
assert.ok(data.orders.some((o) => String(o.oId) === '32'),
|
||||||
|
'山东品牌仓 14:30 创建 → 早于该仓 15:00 上界,要核验');
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '33'),
|
||||||
|
'河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界,18:00 轮不核验');
|
||||||
|
assert.ok(data.orders.some((o) => String(o.oId) === '34'),
|
||||||
|
'河南洛宁仓 12:30 创建 → 早于该仓 13:00 上界,要核验');
|
||||||
|
assert.equal(data.window.overrides.length, 3, '山东两仓 + 河南洛宁 三处例外');
|
||||||
|
assert.deepEqual(
|
||||||
|
data.window.overrides.map((o) => [o.key, o.hour]).sort(),
|
||||||
|
[['HN_LN', 13], ['SD_BRAND', 15], ['SD_QGJ', 15]]
|
||||||
|
);
|
||||||
|
// 例外只作用在 18:00 轮;13:00 轮没有例外(见下一个用例)
|
||||||
|
assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u);
|
||||||
|
// 被排除的仓不能算
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
|
||||||
|
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
|
||||||
|
for (const id of ['9', '10', '11', '12']) {
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
|
||||||
|
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
|
||||||
|
}
|
||||||
|
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
|
||||||
|
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
|
||||||
|
|
||||||
|
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
|
||||||
|
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
|
||||||
|
assert.ok(
|
||||||
|
!data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)),
|
||||||
|
'不考核快递的单不能出现在超时明细里'
|
||||||
|
);
|
||||||
|
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']);
|
||||||
|
|
||||||
|
// 不计考核清单
|
||||||
|
assert.equal(data.heldOrders.length, 2);
|
||||||
|
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
|
||||||
|
assert.deepEqual(reasons, ['待审核', '订单异常'].sort());
|
||||||
|
|
||||||
|
// 新鲜度直接取自主查询的 query_time,不再额外扫表
|
||||||
|
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27');
|
||||||
|
// 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新,
|
||||||
|
// 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。
|
||||||
|
assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟');
|
||||||
|
assert.equal(
|
||||||
|
data.freshness.stale,
|
||||||
|
data.freshness.ageMinutes > 90,
|
||||||
|
'滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)'
|
||||||
|
);
|
||||||
|
if (data.freshness.stale) {
|
||||||
|
assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示');
|
||||||
|
}
|
||||||
|
// 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」
|
||||||
|
assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false');
|
||||||
|
assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚');
|
||||||
|
|
||||||
|
// ── 性能护栏 ────────────────────────────────────────────────────────
|
||||||
|
assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)');
|
||||||
|
const sql = calls.sql[0];
|
||||||
|
assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表');
|
||||||
|
assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判');
|
||||||
|
// 本版基准是审核时间 → 两列审核时间都必须在投影里
|
||||||
|
assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列');
|
||||||
|
assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底');
|
||||||
|
// 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消)
|
||||||
|
assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数');
|
||||||
|
assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断');
|
||||||
|
assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用');
|
||||||
|
// 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s
|
||||||
|
assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列');
|
||||||
|
assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列');
|
||||||
|
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
|
||||||
|
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
|
||||||
|
|
||||||
|
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
|
||||||
|
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
|
||||||
|
for (const bs of calls.backfillSql) {
|
||||||
|
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
|
||||||
|
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
|
||||||
|
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
|
||||||
|
'多取一列就会把 0.2s 拖到 38s');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ 窗口参数走 params,不在 SQL 里手写
|
||||||
|
assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
|
||||||
|
assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除');
|
||||||
|
assert.equal(calls.params[0].shipStatus, '发货中');
|
||||||
|
|
||||||
|
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
|
||||||
|
assert.equal(data.round.key, 'AFTERNOON');
|
||||||
|
assert.match(String(data.round.windowEnd), /16:00:00$/u);
|
||||||
|
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端');
|
||||||
|
assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算');
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('13:00 轮:只核验「今天 00:00~07:00 审核」的单 + 历史未发', withStubbedService(async (svc) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
|
||||||
|
assert.equal(data.round.key, 'NOON');
|
||||||
|
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
|
||||||
|
|
||||||
|
// 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验)
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '21'),
|
||||||
|
'今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现');
|
||||||
|
// 24 号:今天 07:40 审核 → 同理不核验
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '24'),
|
||||||
|
'今天 07:40 审核 → 不进 13:00 轮');
|
||||||
|
// 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '26'),
|
||||||
|
'下午才审核的老单不能进 13:00 轮');
|
||||||
|
|
||||||
|
// 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛)
|
||||||
|
const b = data.orders.find((o) => String(o.oId) === '22');
|
||||||
|
assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时');
|
||||||
|
assert.equal(b.benchmarkSource, 'last_audit');
|
||||||
|
assert.equal(b.overdueMinutes, 380);
|
||||||
|
assert.equal(b.waitedHours, 6.3);
|
||||||
|
// 23 号:昨天审核、至今未发 → 历史未发池也要看到
|
||||||
|
const d = data.orders.find((o) => String(o.oId) === '23');
|
||||||
|
assert.ok(d, '含历史未发单(历史不受 00:00 起限制)');
|
||||||
|
assert.equal(d.overdueMinutes, 1010);
|
||||||
|
|
||||||
|
// 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验
|
||||||
|
assert.ok(!data.orders.some((o) => String(o.oId) === '25'),
|
||||||
|
'重审到 13:30 的单归 18:00 轮,不进 13:00 轮');
|
||||||
|
|
||||||
|
// 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单
|
||||||
|
assert.equal(data.summary.overdueUnshipped, 2);
|
||||||
|
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判');
|
||||||
|
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
|
||||||
|
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
|
||||||
|
}, 'noon'));
|
||||||
|
|
||||||
|
test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbedService(async (svc, { calls }) => {
|
||||||
|
const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 });
|
||||||
|
assert.equal(data.notStarted, true);
|
||||||
|
assert.equal(data.round, null);
|
||||||
|
assert.equal(data.orders.length, 0);
|
||||||
|
assert.equal(data.summary.overdueUnshipped, 0);
|
||||||
|
// 11 个主仓仍然列出来(结构不能塌),但数字全为 0
|
||||||
|
assert.equal(data.centers.length, registry.CENTERS.length);
|
||||||
|
assert.ok(data.centers.every((x) => x.overdueUnshipped === 0));
|
||||||
|
assert.equal(calls.state, 0, '未到核验时间不应查库');
|
||||||
|
assert.equal(calls.backfill, 0, '未到核验时间也不该回订单表');
|
||||||
|
assert.match(data.note, /13:00/u);
|
||||||
|
assert.equal(data.nextRoundAt, '2026-10-20 13:00:00');
|
||||||
|
assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00');
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── ⑧ 时区:生产容器是 UTC,库里时间是北京时间 ──────────────────────────
|
||||||
|
// 2026-10-21 实测:容器 /etc/timezone=Etc/UTC,mssql useUTC:false 把 GETDATE()
|
||||||
|
// 构造成「墙钟当 UTC」的 Date → 比真实 now 快 8 小时。北京时间 00:00~08:00
|
||||||
|
// 服务端取到的是「昨天」,13:00 轮因此把整个历史未发池算进来(3895 单 vs 本地 6 单)。
|
||||||
|
test('beijingNow:任何机器时区下读出来的本地时间都等于北京时间', () => {
|
||||||
|
// 真实时刻 2026-10-21T00:04:00Z = 北京时间 2026-10-21 08:04
|
||||||
|
const base = new Date('2026-10-21T00:04:00Z');
|
||||||
|
const bj = registry.beijingNow(base);
|
||||||
|
const stamp = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
+ `-${String(d.getDate()).padStart(2, '0')} `
|
||||||
|
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
assert.equal(stamp(bj), '2026-10-21 08:04', '平移后本地 getter 必须读出北京时间');
|
||||||
|
|
||||||
|
// 跨日场景:真实时刻 2026-10-20T23:48:00Z = 北京时间 2026-10-21 07:48(UTC 上还是「昨天」)
|
||||||
|
const midnight = registry.beijingNow(new Date('2026-10-20T23:48:00Z'));
|
||||||
|
assert.equal(stamp(midnight).slice(0, 10), '2026-10-21',
|
||||||
|
'北京时间 00:00~08:00 不能取成前一天');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器时区', () => {
|
||||||
|
// 北京时间 2026-10-21 07:48(UTC 上是 10-20 23:48)→ 还没到 13:00,应当 notStarted
|
||||||
|
const round = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-20T23:48:00Z')));
|
||||||
|
assert.equal(round, null, '北京时间 07:48 未到 13:00 首轮核验');
|
||||||
|
// 北京时间 2026-10-21 13:30 → 13:00 轮生效,窗口上界 07:00、核验 13:00
|
||||||
|
const noon = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-21T05:30:00Z')));
|
||||||
|
assert.equal(noon.key, 'NOON');
|
||||||
|
assert.equal(noon.date, '2026-10-21');
|
||||||
|
assert.equal(noon.deadline, '2026-10-21 13:00:00');
|
||||||
|
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
|
||||||
|
});
|
||||||
16
frontend/src/api/pickingBinSalesAnalysis.js
Normal file
16
frontend/src/api/pickingBinSalesAnalysis.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
23
frontend/src/api/warehouseShipTimeliness.js
Normal file
23
frontend/src/api/warehouseShipTimeliness.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
@ -36,11 +36,13 @@ 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: '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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
91
frontend/src/constants/pickingBinSalesAnalysis.js
Normal file
91
frontend/src/constants/pickingBinSalesAnalysis.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/** 拣选库位动销分析 —— 表头与口径说明(粒度:仓位 × 商品编码) */
|
||||||
|
|
||||||
|
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=存货)不参与统计。',
|
||||||
|
];
|
||||||
@ -6,6 +6,7 @@ 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 },
|
||||||
|
|||||||
@ -119,8 +119,14 @@ 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',
|
||||||
|
},
|
||||||
'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' },
|
||||||
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' },
|
||||||
|
|||||||
@ -953,6 +953,7 @@ 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>
|
||||||
|
|||||||
288
frontend/src/pages/PickingBinSalesAnalysis/index.jsx
Normal file
288
frontend/src/pages/PickingBinSalesAnalysis/index.jsx
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
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;
|
||||||
95
frontend/src/pages/PickingBinSalesAnalysis/style.css
Normal file
95
frontend/src/pages/PickingBinSalesAnalysis/style.css
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
@ -71,6 +71,25 @@ 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 同口径)。
|
||||||
|
|
||||||
@ -348,11 +367,42 @@ const COLUMNS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '是否出库',
|
title: '供应商是否合作',
|
||||||
dataIndex: 'isOutbound',
|
dataIndex: 'supplierCoop',
|
||||||
width: 90,
|
width: 136,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (value) => (value ? <Tag color="green">是</Tag> : <Tag>否</Tag>),
|
sorter: (a, b) => (SUPPLIER_COOP_RANK[a.supplierCoop] || 0) - (SUPPLIER_COOP_RANK[b.supplierCoop] || 0),
|
||||||
|
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: '创建时间来源',
|
||||||
@ -405,6 +455,8 @@ 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);
|
||||||
@ -443,6 +495,7 @@ 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 || '加载客退数据监控失败');
|
||||||
|
|||||||
@ -648,6 +648,11 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表格「最晚退厂时间」列:数据源已接入但该供应商无日期时的占位符 */
|
||||||
|
.rsw-exit-date-empty {
|
||||||
|
color: var(--sycm-text-quaternary, #bfbfbf);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 退货库存预警:图表区 ─────────────────────────────────── */
|
/* ── 退货库存预警:图表区 ─────────────────────────────────── */
|
||||||
.rsw-charts {
|
.rsw-charts {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
546
frontend/src/pages/WarehouseShipTimeliness/index.jsx
Normal file
546
frontend/src/pages/WarehouseShipTimeliness/index.jsx
Normal file
@ -0,0 +1,546 @@
|
|||||||
|
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;
|
||||||
255
frontend/src/pages/WarehouseShipTimeliness/style.css
Normal file
255
frontend/src/pages/WarehouseShipTimeliness/style.css
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,8 +19,10 @@ 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 DailyInventory = lazy(() => import('../pages/DailyInventory'));
|
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
|
||||||
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
|
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
|
||||||
|
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
|
||||||
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
|
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
|
||||||
|
|
||||||
const lazyLoadingElement = <PageLoadingFallback />;
|
const lazyLoadingElement = <PageLoadingFallback />;
|
||||||
@ -114,6 +116,10 @@ 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/daily-inventory',
|
path: 'planning/operation/daily-inventory',
|
||||||
element: renderLazyComponent(DailyInventory),
|
element: renderLazyComponent(DailyInventory),
|
||||||
@ -122,6 +128,10 @@ 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),
|
||||||
|
},
|
||||||
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
|
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
|
||||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user