Compare commits
No commits in common. "main" and "codex/warehouse-ship-timeliness-1020" have entirely different histories.
main
...
codex/ware
@ -6,55 +6,36 @@
|
||||
* 需求(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 核验;然后再看历史未发的订单」。)
|
||||
* 崔桥工厂半成品仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单创建时间**算是否及时。
|
||||
* 每天 12:00 核验「当天创建、7:00 前」的订单是否已发货;
|
||||
* 每天 16:00 核验「当天创建、16:00 前」的订单是否已发货。
|
||||
* 展示口径:**只显示超时未发**(业务确认)。
|
||||
*
|
||||
* 业务确认的口径(2026-10-20):
|
||||
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东自营仓/运营C专用仓 等)
|
||||
* 例外:「京仓-横林二仓京东云仓」业务指定**排除**,不计入任何主仓(见下)
|
||||
* ② 崔桥工厂半成品仓 —— 系统无同名仓,**接入「简尚-崔桥工厂半成品包装项目仓」**(15718196)
|
||||
* 业务确认的口径(2026-10-20 AskUserQuestion):
|
||||
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东云仓/京东自营仓/运营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`。
|
||||
* · 待出库:以上三条都不满足。
|
||||
* 字段口径:
|
||||
* · 创建时间:`order_date`(订单创建时间,业务要求按此算时效)
|
||||
* · 发货时间:`send_date`(为空 = 未发货)
|
||||
* · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓)
|
||||
* ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属,
|
||||
* 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。
|
||||
*
|
||||
* ── 仓库归属(按名称包含匹配,最长匹配优先)────────────────────────────────
|
||||
* `rpt.jst_warehouseInfo` 实测(2026-10-20):
|
||||
* 横林二仓 15033302 + 15301814/15835893/16040225/16132681
|
||||
* ⚠️ 15197138「京仓-横林二仓京东云仓」**业务指定排除**(见 EXCLUDED_WAREHOUSE_KEYWORDS)
|
||||
* 横林二仓 15033302 + 15197138/15301814/15835893/16040225/16132681
|
||||
* 横林三仓 15043561
|
||||
* 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**)
|
||||
* 崔桥工厂仓 15038426 + 15301815/16040410
|
||||
* 崔桥工厂品牌仓 15870495
|
||||
* 崔桥工厂半成品包装项目仓 15718196(原名「简尚-崔桥工厂半成品包装项目仓」)
|
||||
* 崔桥工厂半成品仓 —— 无同名仓,留空
|
||||
* 新苏北仓 15038347 + 15315031/15301812
|
||||
* 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911
|
||||
* 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076
|
||||
@ -65,23 +46,15 @@
|
||||
* 缓存 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)**一律排除** ——
|
||||
* 它们自己永不发货,货由子单/主单发出,算进来就是冤枉仓库。
|
||||
* 业务只要「超时未出库」,所以列表只返回待出库的超时单。
|
||||
* 截止时刻(每日两轮,取**当前已生效且最晚**的那一轮;未到 12:00 时无截止):
|
||||
* 12:00 → 核验「当天 00:00 ~ 07:00 创建」的订单
|
||||
* 16:00 → 核验「当天 00:00 ~ 16:00 创建」的订单
|
||||
* 判定:
|
||||
* · 超时未发 = `send_date IS NULL` 且 `order_date <= 截止时刻`
|
||||
* · 按时已发 = `send_date IS NOT NULL` 且 `send_date <= 截止时刻` → **不计入超时**
|
||||
* · 已发但超时 = `send_date > 截止时刻` → 也算超时(已发,但没赶上截止)
|
||||
* 业务只要「超时未发」,所以列表只返回 `send_date IS NULL` 的超时单;
|
||||
* 但 summary 里仍给出 `overdueShipped`,避免「已发但超时」被当成没超时。
|
||||
*
|
||||
* ── 时区 ──────────────────────────────────────────────────────────────────
|
||||
* 订单时间列存的是**东八区本地时间**(实测 `order_date` 与 `created` 相差十几秒,
|
||||
@ -100,40 +73,16 @@ const CENTERS = Object.freeze([
|
||||
{ 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: ['崔桥工厂半成品'] },
|
||||
// 系统内无同名仓(最接近的「简尚-崔桥工厂半成品包装项目仓」15718196 是配件仓,
|
||||
// 业务确认先留空占位,不并进来)
|
||||
{ key: 'CQ_WIP', name: '崔桥工厂半成品仓', keywords: [], placeholder: true },
|
||||
{ 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 } },
|
||||
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] },
|
||||
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'] },
|
||||
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] },
|
||||
]);
|
||||
|
||||
// 各主仓在某一轮的窗口上界小时。
|
||||
// 返回 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
|
||||
@ -141,48 +90,13 @@ const SORTED_KEYWORDS = CENTERS
|
||||
.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 小时,加门槛就永远不出现在超时列表里(第七版被打回的原因)。
|
||||
// ── 时效轮次(业务给定)──────────────────────────────────────────────────
|
||||
// cutoffHour:当天几点截止;windowEndHour:只核验「创建时间不晚于该时刻」的订单
|
||||
const ROUNDS = Object.freeze([
|
||||
{ key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEndHour: 7 },
|
||||
{ key: 'AFTERNOON', label: '18:00 轮', verifyHour: 18, windowEndHour: 16 },
|
||||
{ key: 'NOON', label: '12:00 轮', cutoffHour: 12, windowEndHour: 7 },
|
||||
{ key: 'AFTERNOON', label: '16:00 轮', cutoffHour: 16, 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(拆单):**必须排除**。拆单是「父单被拆成子单」的中间态,父单本身
|
||||
@ -193,127 +107,6 @@ const HISTORY_LOOKBACK_DAYS = 30;
|
||||
// 发货需求,被算进去也无害;这边是「考核仓库时效」,误算就要背锅,必须排除。
|
||||
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: [] };
|
||||
|
||||
@ -326,47 +119,14 @@ 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
|
||||
* @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;
|
||||
if (warehouseName.includes(item.kw)) return item.center;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -389,23 +149,16 @@ async function loadCenterWarehouseMap(force = false) {
|
||||
)) || [];
|
||||
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);
|
||||
const center = resolveCenter(String(row.name || ''));
|
||||
if (center && byCenter.has(center.key)) {
|
||||
byCenter.get(center.key).push({
|
||||
wmsCoId: Number(row.wms_co_id),
|
||||
warehouseId: Number(row.Id),
|
||||
name,
|
||||
name: String(row.name || ''),
|
||||
});
|
||||
} else {
|
||||
unmatched.push({ wmsCoId: Number(row.wms_co_id), name });
|
||||
unmatched.push({ wmsCoId: Number(row.wms_co_id), name: String(row.name || '') });
|
||||
}
|
||||
}
|
||||
const list = CENTERS.map((c) => ({
|
||||
@ -414,12 +167,7 @@ async function loadCenterWarehouseMap(force = false) {
|
||||
placeholder: Boolean(c.placeholder),
|
||||
warehouses: byCenter.get(c.key) || [],
|
||||
}));
|
||||
warehouseCache = {
|
||||
at: Date.now(),
|
||||
rows: list,
|
||||
unmatchedCount: unmatched.length,
|
||||
excluded,
|
||||
};
|
||||
warehouseCache = { at: Date.now(), rows: list, unmatchedCount: unmatched.length };
|
||||
return list;
|
||||
}
|
||||
|
||||
@ -430,48 +178,36 @@ function clearWarehouseCache() {
|
||||
/**
|
||||
* 计算「当前已生效」的核验轮次。
|
||||
*
|
||||
* 13:00 之前 → **未到核验时间,返回 null**。
|
||||
* 业务明确要求(2026-10-20 确认):未到核验时刻不做核验,
|
||||
* 12:00 之前 → **未到核验时间,返回 null**。
|
||||
* 业务明确要求(2026-10-20 确认):12:00 之前不做核验,
|
||||
* 前端显示「未到核验时间」,**不要回填昨天的数据** ——
|
||||
* 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。
|
||||
* 13:00~17:59 → 用 13:00 轮(今天 00:00~07:00 **审核** + 历史未发)
|
||||
* 18:00 之后 → 用 18:00 轮(截止 16:00 前**审核** + 今天之前未发的历史单)
|
||||
* 12:00~15:59 → 用 12:00 轮(窗口 00:00~07:00)
|
||||
* 16:00 之后 → 用 16:00 轮(窗口 00:00~16:00,覆盖范围更大)
|
||||
*
|
||||
* 🔥 「含历史」是 2026-10-20 业务第三次纠偏:
|
||||
* 原实现把下界卡在「今天 00:00」,只统计当天新单,把昨天及更早挂着没发的
|
||||
* 单全漏掉了。业务口径是「……然后再看历史未发的订单」——
|
||||
* 这是一个滚动累积的待发池,不按自然日切。
|
||||
*
|
||||
* @param {Date} now 北京时间(默认 `beijingNow()`,容器是 UTC 时也安全)
|
||||
* @param {Date} now 以**东八区本地时间**为准的 Date(生产/本地机器均为东八区)
|
||||
* @returns {object|null} null 表示当前不在任何核验时段内
|
||||
*/
|
||||
function resolveActiveRound(now = beijingNow()) {
|
||||
function resolveActiveRound(now = new Date()) {
|
||||
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;
|
||||
if (minutes >= round.cutoffHour * 60) active = round;
|
||||
}
|
||||
|
||||
// 13:00 前:首轮核验还没开始,不做判定
|
||||
// 12: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
|
||||
deadline: `${y}-${m}-${d} ${pad2(active.cutoffHour)}:00:00`,
|
||||
windowStart: `${y}-${m}-${d} 00:00:00`,
|
||||
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,
|
||||
@ -481,79 +217,30 @@ function resolveActiveRound(now = beijingNow()) {
|
||||
/**
|
||||
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
|
||||
*/
|
||||
function describeRounds(now = beijingNow()) {
|
||||
function describeRounds(now = new Date()) {
|
||||
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,
|
||||
deadline: `${date} ${pad2(r.cutoffHour)}:00:00`,
|
||||
windowStart: `${date} 00:00:00`,
|
||||
windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`,
|
||||
includeHistory: true,
|
||||
active: minutes >= r.verifyHour * 60,
|
||||
active: now.getHours() * 60 + now.getMinutes() >= r.cutoffHour * 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,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,21 +1,14 @@
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版)
|
||||
* 仓储中心 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,不得投影宽列、不得回订单表补金额。
|
||||
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定四条关键语义:
|
||||
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走,
|
||||
* 否则品牌仓的订单会被重复计入两个仓;
|
||||
* ② 核验轮次的时刻选择 —— 12:00 前**不做核验(返回 null)**、12:00~15:59 用
|
||||
* 7:00 窗口、16:00 后用 16:00 窗口;
|
||||
* ③ 状态排除表 —— Cancelled / Merged / Split 必须排除(Split 是拆单父单,
|
||||
* 永不发货,不排除会把一年前的单全算成超时未发);
|
||||
* ④ 服务层分桶算术 —— pending/shippedLate/onTime 三态互斥,按时率不为负。
|
||||
*
|
||||
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
|
||||
*/
|
||||
@ -36,9 +29,9 @@ test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走',
|
||||
|
||||
test('resolveCenter 能捞到各种命名的子仓', () => {
|
||||
const cases = [
|
||||
['京仓-横林二仓京东云仓', 'HL2'],
|
||||
['简尚-运营C横林二仓专用仓', 'HL2'],
|
||||
['简尚-横林二仓京东仓', 'HL2'],
|
||||
['京仓-横林二仓京东自营仓', 'HL2'],
|
||||
['简尚-崔桥工厂仓唯品仓', 'CQ'],
|
||||
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
|
||||
['简尚-新苏北临时仓', 'XSB'],
|
||||
@ -52,616 +45,189 @@ test('resolveCenter 能捞到各种命名的子仓', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
|
||||
// 这是最容易翻车的地方:仓库表里有上百个「工厂-山东XX代发仓」,
|
||||
// 如果关键词写成「山东」就会全被并进山东品牌仓/青岗集仓。
|
||||
const shouldBeNull = [
|
||||
'工厂-山东菏泽森美代发仓',
|
||||
'工厂-山东恒思代发仓',
|
||||
'工厂-河南千优代发仓',
|
||||
'工厂-河北霸州派雅世佳代发仓',
|
||||
'简尚-苏州福利品仓',
|
||||
'简尚-崔桥工厂半成品包装项目仓',
|
||||
];
|
||||
for (const name of shouldBeNull) {
|
||||
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
|
||||
}
|
||||
});
|
||||
|
||||
test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => {
|
||||
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');
|
||||
assert.ok(c, 'CQ_WIP 必须存在(业务要求列出来)');
|
||||
assert.equal(c.placeholder, true);
|
||||
assert.deepEqual(c.keywords, []);
|
||||
});
|
||||
|
||||
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
|
||||
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
|
||||
|
||||
test('13:00 前不做核验,返回 null(不回填昨日数据)', () => {
|
||||
test('12:00 前不做核验,返回 null(不回填昨日数据)', () => {
|
||||
// 业务口径(2026-10-20 确认):中午 12:00 才开始第一轮核验,
|
||||
// 12:00 之前页面显示「未到核验时间」,**不能用昨天的结算结果冒充今天**。
|
||||
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
|
||||
assert.equal(registry.resolveActiveRound(at(12, 59)), null);
|
||||
assert.equal(registry.resolveActiveRound(at(11, 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('12:00~15:59 用 12:00 轮,核验窗口是当天 00:00~07:00', () => {
|
||||
const r = registry.resolveActiveRound(at(12, 0));
|
||||
assert.ok(r);
|
||||
assert.equal(r.key, 'NOON');
|
||||
assert.equal(r.deadline, '2026-10-20 12:00:00');
|
||||
assert.equal(r.windowStart, '2026-10-20 00:00:00');
|
||||
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
|
||||
});
|
||||
|
||||
test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => {
|
||||
const r = registry.resolveActiveRound(at(18, 0));
|
||||
test('16:00 之后用 16:00 轮,核验窗口扩到当天 00:00~16:00', () => {
|
||||
const r = registry.resolveActiveRound(at(16, 0));
|
||||
assert.equal(r.key, 'AFTERNOON');
|
||||
assert.equal(r.deadline, '2026-10-20 18:00:00');
|
||||
assert.equal(r.deadline, '2026-10-20 16:00:00');
|
||||
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
|
||||
|
||||
// 深夜仍沿用当天 16: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 上万个');
|
||||
// ── ③ 状态排除 ──────────────────────────────────────────────────────────
|
||||
test('排除 Cancelled / Merged / Split,但保留 Question(仍算待发)', () => {
|
||||
const sql = registry.EXCLUDED_STATUS_SQL;
|
||||
assert.match(sql, /Cancelled/);
|
||||
assert.match(sql, /Merged/);
|
||||
assert.match(sql, /Split/);
|
||||
assert.doesNotMatch(sql, /Question/);
|
||||
});
|
||||
|
||||
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||||
function stubDb(dbPath, scenario = 'default') {
|
||||
function stubDb(dbPath, realDb) {
|
||||
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) => ({
|
||||
// 重构后服务层是「一趟取回窗口内全部订单,Node 侧分桶」,
|
||||
// 所以 stub 要返回**明细行**而不是聚合行。三态各造几条:
|
||||
// · 超时未发 3 单 / 合计 300
|
||||
// · 已发但超时 1 单(send_date 晚于 16:00)
|
||||
// · 按时 6 单(send_date 早于 16:00)
|
||||
const mk = (sendDate, amount, oId) => ({
|
||||
src: 'jst_Orders',
|
||||
wms_co_id: wmsId,
|
||||
o_id: oId,
|
||||
so_id: String(oId),
|
||||
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,
|
||||
order_date: '2026-10-20 01:00:00',
|
||||
send_date: sendDate,
|
||||
status: 'Delivering',
|
||||
shop_status: '',
|
||||
pay_amount: amount,
|
||||
labels: '',
|
||||
ob_io_date: null,
|
||||
});
|
||||
|
||||
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' }),
|
||||
const stubRows = [
|
||||
mk(null, 100, 1), mk(null, 100, 2), mk(null, 100, 3),
|
||||
mk('2026-10-20 18:30:00', 50, 4),
|
||||
mk('2026-10-20 09:00:00', 100, 5), mk('2026-10-20 09:00:00', 100, 6),
|
||||
mk('2026-10-20 10:00:00', 100, 7), mk('2026-10-20 10:00:00', 100, 8),
|
||||
mk('2026-10-20 11:00:00', 100, 9), mk('2026-10-20 11:00:00', 100, 10),
|
||||
];
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// 记录业务表被查了几次 —— 12:00 前应当为 0
|
||||
// ⚠️ 现在是**两表并行分别查**(jst_Orders + jst_qmcust_orderlist),
|
||||
// 所以两表都返回同一批 stubRows;服务层按 o_id 去重后仍应算 10 条,
|
||||
// 这也顺带验证了「跨表同 o_id 去重」逻辑。
|
||||
const calls = { order: 0 };
|
||||
require.cache[dbPath] = {
|
||||
id: dbPath,
|
||||
filename: dbPath,
|
||||
loaded: true,
|
||||
exports: { query: handler, querySlow: handler },
|
||||
exports: {
|
||||
query: async (sql) => {
|
||||
if (/jst_warehouseInfo/u.test(sql)) {
|
||||
return [{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }];
|
||||
}
|
||||
if (/jst_salesOutboundInfo/u.test(sql) && /MAX\(io_date\)/u.test(sql)) {
|
||||
// 新鲜度查询:返回空,走 stale 分支也不影响主结果
|
||||
return [{ outbound_max: '2026-10-19 23:00:00' }];
|
||||
}
|
||||
calls.order += 1;
|
||||
return stubRows;
|
||||
},
|
||||
},
|
||||
};
|
||||
void realDb;
|
||||
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);
|
||||
test('服务层三态分桶互斥,按时率不为负', async () => {
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
const realDb = require.cache[dbPath];
|
||||
stubDb(dbPath, realDb);
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||
|
||||
try {
|
||||
const svc = require('../src/services/warehouseShipTimelinessService');
|
||||
const data = await svc.getShipTimeliness({ now: at(16, 5), limit: 10 });
|
||||
const c = data.centers.find((x) => x.key === 'CQ');
|
||||
assert.ok(c, '应能归到崔桥工厂仓');
|
||||
assert.equal(c.overdueUnshipped, 3);
|
||||
assert.equal(c.overdueShipped, 1);
|
||||
assert.equal(c.windowOrderCount, 10, '总数 = pending + shippedLate + onTime');
|
||||
assert.equal(c.onTimeRate, 60, '按时 6/10 = 60%(跨表重复 o_id 必须去重,否则总数会翻倍)');
|
||||
|
||||
// 汇总口径与各仓一致,且按时率不会是负数
|
||||
assert.equal(data.summary.overdueUnshipped, 3);
|
||||
assert.equal(data.summary.windowOrderCount, 10);
|
||||
assert.equal(data.summary.onTimeRate, 60);
|
||||
assert.ok(data.summary.onTimeRate >= 0);
|
||||
|
||||
// 前端要展示「核验 16:00 前」,round 必须带出 windowEnd ——
|
||||
// 只回 key/label 的话页面会渲染成「核验 前」(2026-10-20 真渲染时抓到)
|
||||
assert.ok(data.round, 'round 不能为空');
|
||||
assert.equal(data.round.key, 'AFTERNOON');
|
||||
assert.equal(data.round.cutoffHour, 16);
|
||||
assert.match(String(data.round.windowEnd), /16:00:00$/, 'round.windowEnd 必须带出截止时刻');
|
||||
} 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')];
|
||||
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');
|
||||
test('服务层 12:00 前返回 notStarted,且不去查订单表', async () => {
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
const realDb = require.cache[dbPath];
|
||||
const { calls } = stubDb(dbPath, realDb);
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||
|
||||
try {
|
||||
const svc = require('../src/services/warehouseShipTimelinessService');
|
||||
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((c) => c.overdueUnshipped === 0));
|
||||
assert.equal(calls.order, 0, '未到核验时间不应扫订单表');
|
||||
assert.match(data.note, /12:00/);
|
||||
// 下一轮核验时刻要告诉前端,方便提示「12:00 开始」
|
||||
assert.equal(data.nextRoundAt, '2026-10-20 12:00:00');
|
||||
} 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')];
|
||||
}
|
||||
});
|
||||
|
||||
@ -36,7 +36,6 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
{ 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,
|
||||
children: [
|
||||
|
||||
@ -119,11 +119,6 @@ const SIDEBAR_ROUTE_MAP = {
|
||||
activePath: '/planning/operation/mkh-bom-data',
|
||||
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' },
|
||||
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
|
||||
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' },
|
||||
|
||||
146
frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx
Normal file
146
frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效 —— 列表左侧侧栏
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务需求(yanhui 2026-10-20):
|
||||
* 「在列表左侧加一栏仓储中心24小时发货时效,下面列 11 个仓…只显示超时未发」
|
||||
*
|
||||
* 设计取舍:
|
||||
* · **只显示超时未发** —— 数量为 0 的仓也保留一行(浅色显示「—」),
|
||||
* 这样业务能一眼看出「这个仓今天全发完了」,比直接隐藏更有信息量;
|
||||
* · 点击某个仓 → 把该仓的超时订单筛到右侧表格里(不跳页,避免业务来回切);
|
||||
* · 崔桥工厂半成品仓系统里没有同名仓,`placeholder=true` 时显示「未接入」。
|
||||
*
|
||||
* 数据来自 getWarehouseShipTimeliness,字段见后端 warehouseShipTimelinessService。
|
||||
*/
|
||||
function WarehouseShipTimelinessSidebar({
|
||||
data, loading, error, activeCenter, onSelectCenter, collapsed, onToggleCollapse,
|
||||
}) {
|
||||
const centers = data && Array.isArray(data.centers) ? data.centers : [];
|
||||
const summary = data && data.summary ? data.summary : null;
|
||||
const round = data && data.round ? data.round : null;
|
||||
const freshness = data && data.freshness ? data.freshness : null;
|
||||
const totalOverdue = summary ? summary.overdueUnshipped : 0;
|
||||
const hasRound = Boolean(round);
|
||||
// 12:00 之前后端返回 round=null + notStarted=true:
|
||||
// 业务口径是「12:00 才开始第一轮核验」,此时**不展示任何超时数字**,
|
||||
// 也不回填昨天的结算结果(否则会被误当成今天的超时)。
|
||||
const notStarted = Boolean(data && data.notStarted);
|
||||
// 核验截止时刻:12:00 轮截止 07:00、16:00 轮截止 16:00,直接展示,业务不用换算
|
||||
const cutoffText = String((round && round.windowEnd) || '').slice(11, 16);
|
||||
|
||||
return (
|
||||
<aside className={`mkh-ship-side${collapsed ? ' mkh-ship-side--collapsed' : ''}`}>
|
||||
<div className="mkh-ship-side__head">
|
||||
<div className="mkh-ship-side__title">
|
||||
<span>仓储中心24小时发货时效</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mkh-ship-side__collapse"
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? '展开侧栏' : '收起侧栏'}
|
||||
>
|
||||
{collapsed ? '»' : '«'}
|
||||
</button>
|
||||
</div>
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
<div className="mkh-ship-side__meta">
|
||||
{hasRound ? (
|
||||
<>
|
||||
<span className="mkh-ship-side__round">{round.label}</span>
|
||||
<span>核验 {cutoffText} 前</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="mkh-ship-side__round mkh-ship-side__round--idle">未到核验时间</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mkh-ship-side__total">
|
||||
<span className="mkh-ship-side__total-num" data-zero={totalOverdue === 0}>
|
||||
{notStarted ? '—' : totalOverdue}
|
||||
</span>
|
||||
<span className="mkh-ship-side__total-label">单超时未发</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
<ul className="mkh-ship-side__list">
|
||||
{centers.map((c) => {
|
||||
const isActive = activeCenter === c.key;
|
||||
const empty = !c.overdueUnshipped;
|
||||
// 12:00 前未开始核验时,所有仓都不能点(点了也筛不出东西,
|
||||
// 反而让人以为「这个仓没问题」)
|
||||
const disabled = c.placeholder || notStarted;
|
||||
return (
|
||||
<li key={c.key}>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={c.placeholder
|
||||
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
|
||||
: notStarted
|
||||
? '今日 12:00 首轮核验开始后才展示超时数据'
|
||||
: `${c.name}:超时未发 ${c.overdueUnshipped} 单`
|
||||
+ `(已发但超时 ${c.overdueShipped},窗口订单 ${c.windowOrderCount}`
|
||||
+ `${c.onTimeRate === null ? '' : `,按时率 ${c.onTimeRate}%`})`
|
||||
+ (c.warehouseCount > 1 ? `\n含子仓 ${c.warehouseCount} 个` : '')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'mkh-ship-side__item',
|
||||
isActive ? 'is-active' : '',
|
||||
empty ? 'is-empty' : '',
|
||||
disabled ? 'is-disabled' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onSelectCenter?.(isActive ? '' : c.key);
|
||||
}}
|
||||
>
|
||||
<span className="mkh-ship-side__name">{c.name}</span>
|
||||
{c.placeholder ? (
|
||||
<span className="mkh-ship-side__badge mkh-ship-side__badge--none">未接入</span>
|
||||
) : (
|
||||
<span className={`mkh-ship-side__badge${c.overdueUnshipped > 0 ? ' is-warn' : ''}`}>
|
||||
{c.overdueUnshipped > 0 ? c.overdueUnshipped : '—'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{error ? (
|
||||
<div className="mkh-ship-side__warn" title={error}>时效数据加载失败</div>
|
||||
) : null}
|
||||
|
||||
{freshness && freshness.stale ? (
|
||||
<div className="mkh-ship-side__warn" title={freshness.hint}>
|
||||
发货数据可能滞后
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 12:00 前:首轮核验还没开始,明确告诉业务「几点开始看」,
|
||||
而不是拿昨天的结算数字冒充今天。 */}
|
||||
{notStarted ? (
|
||||
<div className="mkh-ship-side__hint">
|
||||
{String(data.nextRoundAt || '').slice(11, 16) || '12:00'} 开始首轮核验
|
||||
(核验当日 07:00 前创建的订单)
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? <div className="mkh-ship-side__loading">加载中…</div> : null}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default WarehouseShipTimelinessSidebar;
|
||||
@ -19,6 +19,8 @@ import {
|
||||
} from '../../api/mkhBom';
|
||||
import { createRequestGate } from '../../utils/requestGate';
|
||||
import { requireMkhFreshContract } from '../../utils/mkhFreshContract';
|
||||
import WarehouseShipTimelinessSidebar from './WarehouseShipTimelinessSidebar';
|
||||
import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './style.css';
|
||||
|
||||
@ -33,6 +35,16 @@ const number = (value, digits = 0) => (value === null || value === undefined ||
|
||||
|
||||
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||||
|
||||
// 超时时长(分钟)→ 「3小时20分」。业务看的是「晚了多久」,不是精确到分钟的秒数。
|
||||
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;
|
||||
return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
|
||||
};
|
||||
|
||||
const EMPTY_FILTERS = { keyword: '', scope: 'warning' };
|
||||
// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询
|
||||
// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。
|
||||
@ -300,6 +312,28 @@ const MkhBomData = () => {
|
||||
const autoRefreshPendingRef = useRef(false);
|
||||
const [bodyHeight, setBodyHeight] = useState(420);
|
||||
|
||||
// ── 仓储中心 24 小时发货时效(左侧侧栏)────────────────────────────────
|
||||
// 与 MKH 列表**完全独立**的两套数据:列表来自 BOM/库存,侧栏来自订单+出库表。
|
||||
// 所以侧栏单独 load、单独失败,不能因为发货时效查询慢就把整个列表拖住。
|
||||
const [shipTimeliness, setShipTimeliness] = useState(null);
|
||||
const [shipLoading, setShipLoading] = useState(false);
|
||||
const [shipError, setShipError] = useState('');
|
||||
const [shipCollapsed, setShipCollapsed] = useState(false);
|
||||
// 选中的主仓:非空时右侧表格只显示该仓的超时订单
|
||||
const [shipCenter, setShipCenter] = useState('');
|
||||
|
||||
// 当前选中主仓的名字 + 该仓的超时订单(从同一次快照里筛,保证与侧栏数字一致)
|
||||
const shipCenterName = useMemo(() => {
|
||||
if (!shipCenter || !shipTimeliness) return '';
|
||||
const hit = (shipTimeliness.centers || []).find((c) => c.key === shipCenter);
|
||||
return hit ? hit.name : shipCenter;
|
||||
}, [shipCenter, shipTimeliness]);
|
||||
|
||||
const shipCenterOrders = useMemo(() => {
|
||||
if (!shipCenter || !shipTimeliness) return [];
|
||||
return (shipTimeliness.orders || []).filter((o) => o.centerKey === shipCenter);
|
||||
}, [shipCenter, shipTimeliness]);
|
||||
|
||||
const beginRefreshProgress = useCallback((title) => {
|
||||
refreshProgressGenerationRef.current += 1;
|
||||
const generation = refreshProgressGenerationRef.current;
|
||||
@ -404,6 +438,33 @@ const MkhBomData = () => {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// ── 发货时效侧栏加载 ────────────────────────────────────────────────────
|
||||
// 注意:这个接口要扫两张订单表 + 出库表(rpt 全是视图),冷启动实测 40~50 秒,
|
||||
// 所以**不能**跟着列表每 2 分钟的轮询一起跑,否则后端一直被打。
|
||||
// 独立节奏:进页面拉一次,之后每 10 分钟一次(核验点只有 12:00 / 16:00 两个)。
|
||||
const loadShipTimeliness = useCallback(async () => {
|
||||
setShipLoading(true);
|
||||
setShipError('');
|
||||
try {
|
||||
const result = await getWarehouseShipTimeliness({ limit: 500 });
|
||||
if (!result || result.success === false) {
|
||||
setShipError(result?.error || '加载发货时效失败');
|
||||
return;
|
||||
}
|
||||
setShipTimeliness(result.data || null);
|
||||
} catch (shipLoadError) {
|
||||
setShipError(shipLoadError?.response?.data?.error || shipLoadError?.message || '加载发货时效失败');
|
||||
} finally {
|
||||
setShipLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadShipTimeliness();
|
||||
const timer = window.setInterval(loadShipTimeliness, 10 * 60 * 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadShipTimeliness]);
|
||||
|
||||
// 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。
|
||||
// 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。
|
||||
useEffect(() => {
|
||||
@ -953,7 +1014,6 @@ const MkhBomData = () => {
|
||||
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<header className="mkh-header">
|
||||
<div className="mkh-header__heading">
|
||||
<Title level={3}>MKH-BOM 资料</Title>
|
||||
@ -987,6 +1047,73 @@ const MkhBomData = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
左侧「仓储中心24小时发货时效」侧栏 + 右侧原有内容,横向两栏。
|
||||
侧栏收起时只留一条窄条,主区自动占满 —— 用 flex,不用固定宽度判断。
|
||||
*/}
|
||||
<div className="mkh-with-side">
|
||||
<WarehouseShipTimelinessSidebar
|
||||
data={shipTimeliness}
|
||||
loading={shipLoading}
|
||||
error={shipError}
|
||||
activeCenter={shipCenter}
|
||||
onSelectCenter={setShipCenter}
|
||||
collapsed={shipCollapsed}
|
||||
onToggleCollapse={() => setShipCollapsed((v) => !v)}
|
||||
/>
|
||||
|
||||
<div className="mkh-with-side__main">
|
||||
{/*
|
||||
超时明细:只有点了左侧某个仓才展开。
|
||||
明细来自同一次 list 接口(已按 order_date 升序,最早的最急),
|
||||
不再单独请求 —— 两套数据必须同源同快照,否则数字对不上。
|
||||
*/}
|
||||
{shipCenter && shipTimeliness ? (
|
||||
<Card
|
||||
bordered={false}
|
||||
className="mkh-ship-detail"
|
||||
title={(
|
||||
<span>
|
||||
超时未发订单 · <b>{shipCenterName}</b>
|
||||
<span className="mkh-ship-detail__count">
|
||||
{shipCenterOrders.length} 单
|
||||
{shipTimeliness.truncated && shipCenterOrders.length >= (shipTimeliness.detailLimit || 0)
|
||||
? `(已截断,仅显示前 ${shipTimeliness.detailLimit} 条)`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
extra={<button type="button" className="mkh-ship-detail__clear" onClick={() => setShipCenter('')}>关闭</button>}
|
||||
>
|
||||
<Table
|
||||
rowKey={(r) => `${r.source}-${r.oId}`}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
|
||||
dataSource={shipCenterOrders}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'oId', width: 110 },
|
||||
{ title: '店铺', dataIndex: 'shopName', width: 190, ellipsis: true },
|
||||
{ title: '发货仓', dataIndex: 'warehouseName', width: 190, ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'orderDate', width: 150 },
|
||||
{
|
||||
title: '超时时长',
|
||||
dataIndex: 'overdueMinutes',
|
||||
width: 96,
|
||||
render: (v) => <span className="mkh-ship-detail__overdue">{formatOverdue(v)}</span>,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'payAmount',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
render: (v) => `¥${number(v, 2)}`,
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 110 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="mkh-filter-card" bordered={false}>
|
||||
<Space wrap size={[12, 12]}>
|
||||
<Input
|
||||
@ -1347,6 +1474,8 @@ const MkhBomData = () => {
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
@ -391,3 +391,280 @@
|
||||
.mkh-alert-form--inner {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ============ 仓储中心 24 小时发货时效(左侧侧栏,2026-10-20)============ */
|
||||
/* 左侧固定宽侧栏 + 右侧自适应主区。不用 float,避免表格宽度塌陷。
|
||||
侧栏 sticky 贴顶:表格很长时侧栏跟着滚走会看不到告警数,业务要一直看得见。 */
|
||||
.mkh-with-side {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mkh-with-side__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mkh-ship-side {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
width: 208px;
|
||||
/* 11 个仓 + 头部合计约 400px,给足高度;超出才滚动。
|
||||
不加 max-height 会被 flex 拉伸/压缩,撑开后又不该无限长(表格很长时)。 */
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
padding: 12px 10px 14px;
|
||||
border: 1px solid var(--sycm-border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--sycm-bg-container);
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.mkh-ship-side--collapsed {
|
||||
width: 44px;
|
||||
padding: 12px 6px;
|
||||
}
|
||||
|
||||
.mkh-ship-side__head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mkh-ship-side__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--sycm-text-strong);
|
||||
}
|
||||
|
||||
.mkh-ship-side--collapsed .mkh-ship-side__title > span {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mkh-ship-side__collapse {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--sycm-border-color);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mkh-ship-side__collapse:hover {
|
||||
color: var(--sycm-text-primary);
|
||||
border-color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.mkh-ship-side__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.mkh-ship-side__round {
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #0958d9;
|
||||
}
|
||||
|
||||
.mkh-ship-side__round--idle {
|
||||
background: rgba(136, 135, 128, 0.14);
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.mkh-ship-side__total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--sycm-border-color);
|
||||
}
|
||||
|
||||
.mkh-ship-side__total-num {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #cf1322;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mkh-ship-side__total-num[data-zero='true'] {
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.mkh-ship-side__total-label {
|
||||
font-size: 11px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.mkh-ship-side__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mkh-ship-side__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 5px 7px;
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
color: var(--sycm-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.mkh-ship-side__item:hover {
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.mkh-ship-side__item.is-active {
|
||||
border-color: rgba(22, 119, 255, 0.45);
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
.mkh-ship-side__item.is-empty .mkh-ship-side__name {
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.mkh-ship-side__item.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mkh-ship-side__name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mkh-ship-side__badge {
|
||||
flex: none;
|
||||
min-width: 22px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: rgba(136, 135, 128, 0.12);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mkh-ship-side__badge.is-warn {
|
||||
background: rgba(255, 77, 79, 0.16);
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.mkh-ship-side__badge--none {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.mkh-ship-side__warn {
|
||||
margin-top: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(250, 173, 20, 0.14);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: #ad4e00;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.mkh-ship-side__hint {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.mkh-ship-side__loading {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
/* 窄屏兜底(<1100px):此时右侧表格已经放不下,侧栏整体铺到上方。
|
||||
* ⚠️ 1100~1500px 之间**保持左侧竖栏** —— 那是业务最常用的笔记本分辨率
|
||||
* (1366×768 / 1440×900),侧栏必须还在左边,否则「列表左侧加一栏」的需求就不成立。
|
||||
* 列表始终竖向排列,绝不横排(横排后 11 个仓挤成一行完全没法读)。 */
|
||||
@media (max-width: 1100px) {
|
||||
.mkh-with-side {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mkh-ship-side,
|
||||
.mkh-ship-side--collapsed {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.mkh-ship-side--collapsed .mkh-ship-side__title > span {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中窄屏(1100~1500px,含 1366×768 笔记本):侧栏仍在左侧,只是收窄 */
|
||||
@media (max-width: 1500px) and (min-width: 1101px) {
|
||||
.mkh-ship-side {
|
||||
width: 176px;
|
||||
}
|
||||
|
||||
.mkh-ship-side__name {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超时明细卡(点左侧某个仓才出现) */
|
||||
.mkh-ship-detail {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mkh-ship-detail__count {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.mkh-ship-detail__clear {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #0958d9;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mkh-ship-detail__overdue {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@ -1,546 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Empty, Modal, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import { getWarehouseShipTimeliness } from '../../api/warehouseShipTimeliness';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// 核验点只有 13:00 / 18:00 两个,10 分钟刷新一次足够。
|
||||
// 接口要扫两张订单表 + 出库表(rpt 下全是视图),冷启动 40~60 秒,别跟着其它页面的轮询节奏跑。
|
||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
const number = (value, digits = 0) => (
|
||||
value === null || value === undefined || value === ''
|
||||
? '0'
|
||||
: Number(value).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
);
|
||||
|
||||
// 超时时长(分钟)→「3 小时 20 分」/「12 天 4 小时」。
|
||||
// ⚠️ 口径放开后(含历史未发单)会出现跨天的超时,必须给「天」,否则显示成「8640 小时」。
|
||||
const formatOverdue = (minutes) => {
|
||||
const m = Number(minutes);
|
||||
if (!Number.isFinite(m) || m <= 0) return '—';
|
||||
if (m < 60) return `${m} 分钟`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rest = m % 60;
|
||||
if (h < 24) return rest ? `${h} 小时 ${rest} 分` : `${h} 小时`;
|
||||
const d = Math.floor(h / 24);
|
||||
const hr = h % 24;
|
||||
return hr ? `${d} 天 ${hr} 小时` : `${d} 天`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 「不计考核」清单的列(待审核 / 订单异常)。
|
||||
* 业务反馈(2026-10-20):贴进来的一批单号在聚水潭查不到,实测真实状态是
|
||||
* 「已取消(发货前售后)」或「异常」—— 这类单不该算仓库超时。
|
||||
* 这里把它们单列出来,回答「为什么这些单没进超时列表」。
|
||||
*/
|
||||
const HELD_COLUMNS = [
|
||||
{ title: '内部单号', dataIndex: 'oId', width: 100 },
|
||||
{ title: '线上订单号', dataIndex: 'soId', width: 170, ellipsis: true },
|
||||
{ title: '店铺', dataIndex: 'shopName', width: 170, ellipsis: true },
|
||||
{ title: '发货仓', dataIndex: 'warehouseName', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '挂起原因',
|
||||
dataIndex: 'holdReason',
|
||||
width: 110,
|
||||
render: (v) => (
|
||||
<Tag bordered={false} color={v === '订单异常' ? 'red' : 'default'}>{v || '—'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '发货时间',
|
||||
dataIndex: 'sendTime',
|
||||
width: 178,
|
||||
ellipsis: true,
|
||||
render: (v, row) => {
|
||||
if (v) return v;
|
||||
const no = row?.logisticId;
|
||||
if (no) {
|
||||
return (
|
||||
<Tooltip title={`已打单:${row.logisticCompany || ''} ${no}(${row.logisticStatus || '未揽收'})`}>
|
||||
<Tag bordered={false} color="blue">已打单 · {row.logisticStatus || '未揽收'}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <Tag bordered={false} color="default">未发货</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '审核时间',
|
||||
dataIndex: 'auditTime',
|
||||
width: 152,
|
||||
ellipsis: true,
|
||||
render: (v) => (v || <span className="wst-detail__fallback">未审核</span>),
|
||||
},
|
||||
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '已挂起', dataIndex: 'heldDays', width: 88, render: (v) => (v > 0 ? `${v} 天` : '—') },
|
||||
];
|
||||
|
||||
const DETAIL_COLUMNS = [
|
||||
// 内部单号 = 聚水潭 o_id(系统内部编号,仓管在系统里按它查单)
|
||||
{ title: '内部单号', dataIndex: 'oId', width: 110 },
|
||||
// 线上订单号 = 平台 so_id(京东/抖音/天猫等平台单号,客服按它对接客户)
|
||||
{
|
||||
title: '线上订单号',
|
||||
dataIndex: 'soId',
|
||||
width: 178,
|
||||
ellipsis: true,
|
||||
render: (v) => (v ? <span className="wst-detail__soid">{v}</span> : '—'),
|
||||
},
|
||||
{
|
||||
title: '店铺',
|
||||
dataIndex: 'shopName',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v) => (v || '—'),
|
||||
},
|
||||
{
|
||||
title: '发货仓',
|
||||
dataIndex: 'warehouseName',
|
||||
width: 170,
|
||||
ellipsis: true,
|
||||
render: (v) => (v || '—'),
|
||||
},
|
||||
{
|
||||
// 发货时间 = 聚水潭回传的发货时间(业务 2026-10-20:明细里不要创建时间,要发货时间)
|
||||
title: (
|
||||
<Tooltip title="聚水潭回传的发货时间(send_time)。为空但有运单号的,说明仓库已打单、还没揽收/回传发货时间,会显示成「已打单」">
|
||||
<span>发货时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'sendTime',
|
||||
width: 178,
|
||||
ellipsis: true,
|
||||
render: (v, row) => {
|
||||
if (v) return v;
|
||||
const no = row?.logisticId;
|
||||
if (no) {
|
||||
return (
|
||||
<Tooltip title={`已打单:${row.logisticCompany || ''} ${no}(${row.logisticStatus || '未揽收'})。聚水潭还没回传发货时间,所以还没从超时列表里消失`}>
|
||||
<Tag bordered={false} color="blue">已打单 · {row.logisticStatus || '未揽收'}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <Tag bordered={false} color="default">未发货</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
// 审核时间 = **考核基准**,取聚水潭「审核时间」列(最后一次审核,重审会刷新)
|
||||
title: (
|
||||
<Tooltip title="= 聚水潭订单列表「审核时间」那一列(最后一次审核,重新审核会刷新)。今天 7:00 前审核的 13:00 核验;截止 16:00 前审核的(含今天之前未发的历史单)18:00 核验,已等时长也从它算">
|
||||
<span>审核时间</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'auditTime',
|
||||
width: 152,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (v
|
||||
? v
|
||||
: (
|
||||
<Tooltip title="没取到审核时间,已回退创建/付款时间参与分轮与计时">
|
||||
<span className="wst-detail__fallback">{row?.benchmarkTime || '—'} *</span>
|
||||
</Tooltip>
|
||||
)),
|
||||
},
|
||||
// 付款时间 = 客户付款那一刻(并列展示,方便对账)
|
||||
{ title: '付款时间', dataIndex: 'payTime', width: 152, ellipsis: true, render: (v) => v || '—' },
|
||||
{
|
||||
// 发货状态:聚水潭实时状态(发货中 = 已审核未发货,即考核对象)
|
||||
title: (
|
||||
<Tooltip title="聚水潭订单实时状态。发货中 = 已审核、仓库还没发货">
|
||||
<span>发货状态</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'state',
|
||||
width: 104,
|
||||
render: (v) => (
|
||||
<Tag bordered={false} color={v === '发货中' ? 'orange' : 'default'}>{v || '—'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '物流状态',
|
||||
dataIndex: 'logisticStatus',
|
||||
width: 100,
|
||||
render: (v) => (v ? <Tag bordered={false}>{v}</Tag> : '—'),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="从审核时间到本轮核验时刻(13:00 / 18:00)等了多久">
|
||||
<span>已等时长</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'overdueMinutes',
|
||||
width: 112,
|
||||
render: (v) => <span className="wst-detail__overdue">{formatOverdue(v)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效(独立页面)
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 业务口径(yanhui 2026-10-20):
|
||||
* · 11 个主仓,子仓并入主仓一起算;
|
||||
* · 今天 07:00 前**审核**的订单 13:00 核验,截止今天 16:00 前审核的单 18:00 核验
|
||||
* (含 7 点前未发出的、含今天之前审核至今未发的历史单;山东两仓 15:00、河南洛宁 13:00);
|
||||
* 今天之前审核仍未发的(历史未发)两个轮次都算;
|
||||
* **到核验时刻未发即超时**,没有「满 24 小时」门槛(业务纠偏点);
|
||||
* 轮次归属和「已等时长」都按**审核时间**算(聚水潭「审核时间」列,最后一次审核);
|
||||
* · **13:00 之前不核验**,直接显示「未到核验时间」,不拿昨天的结果冒充今天;
|
||||
* · 已出库 / 待出库以**聚水潭发货时间**为准(有 send_time = 已发);
|
||||
* · 只显示超时未出库(发完的仓显示「—」),点仓库可筛右侧明细。
|
||||
*
|
||||
* 数据来自 /api/warehouse-ship-timeliness/list(service 层见
|
||||
* backend/src/services/warehouseShipTimelinessService.js)。
|
||||
*/
|
||||
function WarehouseShipTimeliness() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// 选中的主仓:为空时右侧展示全部仓的超时明细
|
||||
const [activeCenter, setActiveCenter] = useState('');
|
||||
// 「业务挂起(疑问单)」明细弹窗
|
||||
const [heldOpen, setHeldOpen] = useState(false);
|
||||
|
||||
// force=true 时带 force=1 让后端绕过 5 分钟结果缓存重查库;
|
||||
// 定时轮询走缓存(这张视图单次要几十秒,每轮都重查会把库压死)
|
||||
const load = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getWarehouseShipTimeliness({ limit: 1000, force: force ? 1 : 0 });
|
||||
if (!result || result.success === false) {
|
||||
setError(result?.error || '加载发货时效失败');
|
||||
return;
|
||||
}
|
||||
setData(result.data || null);
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.error || loadError?.message || '加载发货时效失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(() => load(), AUTO_REFRESH_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const centers = useMemo(() => (Array.isArray(data?.centers) ? data.centers : []), [data]);
|
||||
const orders = useMemo(() => (Array.isArray(data?.orders) ? data.orders : []), [data]);
|
||||
// 业务挂起(疑问单)—— 与 orders 互斥,两者相加才是待出库池
|
||||
const heldOrders = useMemo(
|
||||
() => (Array.isArray(data?.heldOrders) ? data.heldOrders : []),
|
||||
[data]
|
||||
);
|
||||
const summary = data?.summary || {};
|
||||
const round = data?.round || null;
|
||||
const freshness = data?.freshness || null;
|
||||
// 13:00 之前后端返回 round=null + notStarted=true:此时**不展示任何超时数字**
|
||||
const notStarted = Boolean(data?.notStarted);
|
||||
const hasRound = Boolean(round);
|
||||
const cutoffText = String(round?.windowEnd || '').slice(11, 16);
|
||||
const windowEndText = String(data?.window?.end || '').slice(11, 16);
|
||||
// 仓级例外窗口:18:00 轮里 山东两仓 15:00、河南洛宁 13:00(业务 2026-10-20 指定)
|
||||
const windowOverrides = Array.isArray(data?.window?.overrides) ? data.window.overrides : [];
|
||||
const overrideText = windowOverrides
|
||||
.map((o) => `${o.name} ${String(o.hour).padStart(2, '0')}:00`)
|
||||
.join('、');
|
||||
// 未到核验时间时的提示文案,全部由后端给,前端不写死时刻
|
||||
const nextRoundText = String(data?.nextRoundAt || '').slice(11, 16) || '13:00';
|
||||
const nextWindowEndText = String(data?.nextWindowEnd || '').slice(11, 16) || '07:00';
|
||||
|
||||
const activeCenterName = useMemo(() => {
|
||||
if (!activeCenter) return '';
|
||||
const hit = centers.find((c) => c.key === activeCenter);
|
||||
return hit ? hit.name : activeCenter;
|
||||
}, [activeCenter, centers]);
|
||||
|
||||
const detailOrders = useMemo(() => (
|
||||
activeCenter ? orders.filter((o) => o.centerKey === activeCenter) : orders
|
||||
), [activeCenter, orders]);
|
||||
|
||||
return (
|
||||
<SycmPage className="wst-page">
|
||||
<header className="wst-header">
|
||||
<Title level={3}>仓储中心 24 小时发货时效</Title>
|
||||
<Text type="secondary">
|
||||
按聚水潭订单实时状态核验 11 个主仓是否在承诺时效内完成发货,只列超时未发货的单量。
|
||||
</Text>
|
||||
<Text type="secondary" className="wst-note">
|
||||
每天 <b>13:00</b> 核验「<b>今天 07:00 前审核</b>」、<b>18:00</b> 核验「<b>截止 16:00 前审核</b>」、
|
||||
到核验时刻仍未发货的订单;两个轮次都<b>包含今天之前审核、至今未发的历史未发单</b>
|
||||
(18:00 轮也会带上 7 点前审核、13:00 还没发出的那批)—— 滚动待发池,不按自然日切。
|
||||
13:00 之前不核验,显示「未到核验时间」。
|
||||
<b>到点还没发就算超时 —— 不设「满 24 小时」门槛</b>(早上 7 点前那份单,13:00 还没走就是超时)。
|
||||
<b>仓级例外</b>:18:00 轮里 <b>山东青岗集仓 / 山东品牌仓</b> 只看 <b>15:00 前审核</b>的单、
|
||||
<b>河南洛宁仓</b> 只看 <b>13:00 前审核</b>的单(核验时刻仍是 18:00)。
|
||||
<b>轮次与「已等时长」都按审核时间算</b>(= 聚水潭「审核时间」列,最后一次审核);
|
||||
明细并列「审核时间 / 发货时间」,方便直接和聚水潭对账。
|
||||
<b>已发 / 未发以聚水潭发货时间为准</b>:有发货时间就是已发,不再用订单表的发货字段反推
|
||||
—— 该字段经常不回写,会把早就发货的单误判成超时。
|
||||
<b>已打单但还没揽收的</b>(聚水潭已生成运单号、发货时间还空着)会标成「已打单 · 未揽收」,
|
||||
鼠标悬停能看到运单号 —— 这类单还留在列表里,等聚水潭回传发货时间才消失。
|
||||
<b>自提 / 中铁 / 壹米滴答 三个快递不考核</b>(客户自提或专线大件,仓库控制不了运输),
|
||||
已按物流公司自动剔除。
|
||||
<b>「已支付待审核」和「异常」的单不计考核</b>(仓库还没接到或订单本身有问题),单独归入「不计考核」并给出明细;
|
||||
<b>作废单(已取消,含发货前售后)不考核也不展示</b> —— 客户付款后取消的单仓库本就不该发。
|
||||
</Text>
|
||||
</header>
|
||||
|
||||
<Card className="wst-status" bordered={false}>
|
||||
<div className="wst-status__row">
|
||||
<div className="wst-status__round">
|
||||
{hasRound ? (
|
||||
<>
|
||||
<span className="wst-status__badge">{round.label}</span>
|
||||
<span className="wst-status__label">
|
||||
核验今天 {cutoffText} 前审核的订单
|
||||
</span>
|
||||
<span className="wst-status__window">
|
||||
含历史未发 · 到点未发即超时
|
||||
{overrideText ? `(${overrideText} 为仓级例外)` : ''}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="wst-status__badge wst-status__badge--idle">未到核验时间</span>
|
||||
<span className="wst-status__label">
|
||||
{nextRoundText} 开始首轮核验
|
||||
(核验今天 {nextWindowEndText} 前审核的订单)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Space size={8}>
|
||||
{data?.generatedAt ? (
|
||||
<Text type="secondary" className="wst-status__time">
|
||||
数据更新于 {String(data.generatedAt).slice(11, 19)}
|
||||
{data?.cached ? `(缓存 ${data.cacheAgeSec || 0}s,点刷新重查)` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load(true)} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="wst-alert"
|
||||
message={error}
|
||||
action={<Button size="small" onClick={load}>重试</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{freshness && (freshness.stale || freshness.coversDeadline === false) ? (
|
||||
<Alert
|
||||
// 快照没覆盖到核验时刻 → 数字必然偏大,用红色;只是滞后用黄色
|
||||
type={freshness.coversDeadline === false ? 'error' : 'warning'}
|
||||
showIcon
|
||||
className="wst-alert"
|
||||
message={freshness.coversDeadline === false ? '数据未覆盖本轮核验时刻,超时单量偏大' : '发货数据可能滞后'}
|
||||
description={freshness.hint}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="wst-stats">
|
||||
<Card bordered={false} className="wst-stat">
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="到 13:00 / 18:00 核验时刻仍未发货的订单(按审核时间分轮),不设满 24 小时门槛">
|
||||
<span>超时未发货</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
value={notStarted ? 0 : (summary.overdueUnshipped ?? 0)}
|
||||
suffix="单"
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
valueStyle={{ color: (summary.overdueUnshipped || 0) > 0 ? '#cf1322' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
<Card
|
||||
bordered={false}
|
||||
className="wst-stat wst-stat--held"
|
||||
onClick={() => setHeldOpen(true)}
|
||||
>
|
||||
<Statistic
|
||||
title={(
|
||||
<Tooltip title="已支付待审核(还没审单,仓库没接到)和订单异常的单。这类单不进仓库考核,点开看明细。">
|
||||
<span>不计考核 <span className="wst-stat__more">明细</span></span>
|
||||
</Tooltip>
|
||||
)}
|
||||
value={notStarted ? 0 : (summary.heldCount ?? 0)}
|
||||
suffix="单"
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
valueStyle={{ color: (summary.heldCount || 0) > 0 ? '#8c8c8c' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
<Card bordered={false} className="wst-stat">
|
||||
<Statistic
|
||||
title="涉及仓库"
|
||||
value={notStarted ? 0 : (summary.overdueCenterCount ?? 0)}
|
||||
suffix="个"
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
/>
|
||||
</Card>
|
||||
<Card bordered={false} className="wst-stat">
|
||||
<Statistic
|
||||
title="最久未发货"
|
||||
value={notStarted ? 0 : (summary.oldestDays ?? 0)}
|
||||
suffix="天"
|
||||
formatter={(v) => (notStarted ? '—' : number(v))}
|
||||
valueStyle={{ color: (summary.oldestDays || 0) >= 7 ? '#d46b08' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="wst-body">
|
||||
<Card
|
||||
bordered={false}
|
||||
className="wst-centers"
|
||||
title="仓储中心"
|
||||
extra={<Text type="secondary" className="wst-centers__hint">点仓库看明细</Text>}
|
||||
>
|
||||
<Spin spinning={loading && !data} size="small">
|
||||
<ul className="wst-centers__list">
|
||||
{centers.map((c) => {
|
||||
const isActive = activeCenter === c.key;
|
||||
const disabled = c.placeholder || notStarted;
|
||||
return (
|
||||
<li key={c.key}>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={c.placeholder
|
||||
? '系统仓库表中暂无同名仓,待上游建仓后自动接入'
|
||||
: notStarted
|
||||
? `今日 ${nextRoundText} 首轮核验开始后才展示超时数据`
|
||||
: `${c.name}:本轮核验 ${String(c.windowEnd || '').slice(11, 16) || windowEndText} 前审核的单,`
|
||||
+ `超时未发货 ${c.overdueUnshipped} 单`
|
||||
+ `${c.oldestDays > 0 ? `(最久已等 ${c.oldestDays} 天)` : ''}`
|
||||
+ `${c.heldCount > 0 ? `\n另有不计考核(待审核/异常)${c.heldCount} 单` : ''}`
|
||||
+ (c.warehouseCount > 1 ? `\n含子仓 ${c.warehouseCount} 个` : '')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'wst-center',
|
||||
isActive ? 'is-active' : '',
|
||||
!c.overdueUnshipped ? 'is-empty' : '',
|
||||
disabled ? 'is-disabled' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setActiveCenter(isActive ? '' : c.key);
|
||||
}}
|
||||
>
|
||||
<span className="wst-center__name">{c.name}</span>
|
||||
{c.placeholder ? (
|
||||
<span className="wst-center__badge wst-center__badge--none">未接入</span>
|
||||
) : (
|
||||
<span className={`wst-center__badge${c.overdueUnshipped > 0 ? ' is-warn' : ''}`}>
|
||||
{notStarted ? '—' : (c.overdueUnshipped > 0 ? c.overdueUnshipped : '—')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
className="wst-detail"
|
||||
title={(
|
||||
<span>
|
||||
超时未发货订单
|
||||
<span className="wst-detail__scope">
|
||||
{activeCenterName || '全部仓库'} · {detailOrders.length} 单
|
||||
{data?.truncated && detailOrders.length >= (data?.detailLimit || 0)
|
||||
? `(已截断,仅显示前 ${data.detailLimit} 条)`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
extra={activeCenter ? (
|
||||
<button type="button" className="wst-detail__clear" onClick={() => setActiveCenter('')}>查看全部</button>
|
||||
) : null}
|
||||
>
|
||||
<Spin spinning={loading && !data}>
|
||||
{notStarted ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={`未到核验时间,今日 ${nextRoundText} 开始首轮核验`}
|
||||
/>
|
||||
) : detailOrders.length ? (
|
||||
<Table
|
||||
rowKey={(r) => `${r.source}-${r.oId}`}
|
||||
size="small"
|
||||
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }}
|
||||
dataSource={detailOrders}
|
||||
columns={DETAIL_COLUMNS}
|
||||
scroll={{ x: 1642 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本轮没有超时未发货的订单" />
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={`不计考核(待审核 / 异常)· ${heldOrders.length} 单`}
|
||||
open={heldOpen}
|
||||
onCancel={() => setHeldOpen(false)}
|
||||
footer={null}
|
||||
width={1080}
|
||||
destroyOnClose
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="wst-alert"
|
||||
message="「已支付待审核」= 还没审单,仓库根本没接到;「订单异常」= 聚水潭标记异常(缺货/地址/风控等)。两类都不该算仓库超时,故单列于此。"
|
||||
/>
|
||||
{heldOrders.length ? (
|
||||
<Table
|
||||
rowKey={(r) => `${r.oId}`}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false, showTotal: (t) => `共 ${t} 单` }}
|
||||
dataSource={heldOrders}
|
||||
columns={HELD_COLUMNS}
|
||||
scroll={{ x: 1280 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本轮没有待审核 / 异常的单" />
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Text type="secondary" className="wst-foot">
|
||||
说明:<b>今天 7:00 前审核</b>的订单 13:00 核验;<b>截止 16:00 前审核</b>的单 18:00 核验
|
||||
(含 7 点前审核、13:00 还没发出的,也含今天之前审核至今未发的历史单)。
|
||||
<b>到点仍未发货即超时</b>(不设「满 24 小时」门槛);
|
||||
轮次与「已等时长」都按审核时间(聚水潭显示值)算。
|
||||
发货 = 聚水潭回传的发货时间。
|
||||
<b>作废单不考核</b>(被合并 / 拆单父单 / 已取消,或订单表已有发货时间)
|
||||
{summary.voidCount > 0 ? ` —— 本轮已自动剔除 ${summary.voidCount} 单` : ''};
|
||||
<b>自提 / 中铁 / 壹米滴答不考核</b>
|
||||
{summary.carrierSkipped > 0 ? ` —— 本轮已剔除 ${summary.carrierSkipped} 单` : ''};
|
||||
已支付待审核、订单异常的单归入「不计考核」,避免冤枉仓库。
|
||||
</Text>
|
||||
</SycmPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default WarehouseShipTimeliness;
|
||||
@ -1,255 +0,0 @@
|
||||
.wst-page {
|
||||
padding: 16px 20px 24px;
|
||||
}
|
||||
|
||||
.wst-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wst-header .ant-typography {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wst-note {
|
||||
display: block;
|
||||
max-width: 1180px;
|
||||
margin-top: 8px;
|
||||
padding: 10px 14px;
|
||||
/* 页面顶部是深色渐变,直接放浅色小字会看不清,给口径说明一块浅底 */
|
||||
background: var(--sycm-bg-container);
|
||||
border: 1px solid var(--sycm-border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ============ 轮次状态条 ============ */
|
||||
.wst-status {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wst-status__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wst-status__round {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wst-status__badge {
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #0958d9;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.wst-status__badge--idle {
|
||||
background: rgba(136, 135, 128, 0.14);
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.wst-status__label {
|
||||
font-size: 13px;
|
||||
color: var(--sycm-text-primary);
|
||||
}
|
||||
|
||||
.wst-status__window,
|
||||
.wst-status__time {
|
||||
font-size: 12px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.wst-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ============ 统计卡 ============ */
|
||||
/* 5 张卡(超时未出库 / 业务挂起 / 涉及仓库 / 最久未出库 / 合计金额)
|
||||
用 grid 而不是 Row/Col:24 栅格除不尽 5,Row/Col 会把第 5 张挤到下一行。 */
|
||||
.wst-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wst-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.wst-stat {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 「业务挂起」卡可点开明细 */
|
||||
.wst-stat--held {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.wst-stat--held:hover {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.wst-stat__more {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
/* ============ 主从两栏 ============ */
|
||||
.wst-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.wst-centers {
|
||||
flex: none;
|
||||
width: 264px;
|
||||
}
|
||||
|
||||
.wst-centers__hint {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wst-centers__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wst-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
color: var(--sycm-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.wst-center:hover {
|
||||
background: var(--sycm-bg-subtle);
|
||||
}
|
||||
|
||||
.wst-center.is-active {
|
||||
border-color: rgba(22, 119, 255, 0.45);
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
.wst-center.is-empty .wst-center__name {
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.wst-center.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wst-center__name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wst-center__badge {
|
||||
flex: none;
|
||||
min-width: 24px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9px;
|
||||
background: rgba(136, 135, 128, 0.12);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wst-center__badge.is-warn {
|
||||
background: rgba(255, 77, 79, 0.16);
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.wst-center__badge--none {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wst-detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wst-detail__scope {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
.wst-detail__clear {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wst-detail__overdue {
|
||||
color: #cf1322;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 创建时间没补到、回退成付款时间时的提示色(带 * 号,tooltip 有说明) */
|
||||
.wst-detail__fallback {
|
||||
color: var(--sycm-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wst-foot {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--sycm-text-tertiary);
|
||||
}
|
||||
|
||||
/* 窄屏:仓库列表改为两列网格,明细整行下移 */
|
||||
@media (max-width: 1200px) {
|
||||
.wst-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wst-centers {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wst-centers__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px 10px;
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,6 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent
|
||||
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
||||
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
|
||||
const WarehouseShipTimeliness = lazy(() => import('../pages/WarehouseShipTimeliness'));
|
||||
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
|
||||
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
|
||||
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
|
||||
@ -116,10 +115,6 @@ const router = createBrowserRouter([
|
||||
path: 'planning/operation/mkh-bom-data',
|
||||
element: renderLazyComponent(MkhBomData),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/warehouse-ship-timeliness',
|
||||
element: renderLazyComponent(WarehouseShipTimeliness),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/daily-inventory',
|
||||
element: renderLazyComponent(DailyInventory),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user