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