From 893f4d6aca5d50029730eb92de1f32afb40344e4 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Tue, 20 Oct 2026 11:23:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E3=80=8C=E4=BB=93?= =?UTF-8?q?=E5=82=A8=E4=B8=AD=E5=BF=8324=E5=B0=8F=E6=97=B6=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=E6=97=B6=E6=95=88=E3=80=8D=E4=BE=A7=E6=A0=8F=EF=BC=88?= =?UTF-8?q?11=20=E4=B8=AA=E4=B8=BB=E4=BB=93=20=C2=B7=2012:00/16:00=20?= =?UTF-8?q?=E5=8F=8C=E8=BD=AE=E6=A0=B8=E9=AA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求(yanhui 2026-10-20):在 MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」, 下列 11 个主仓(横林二仓、横林三仓、横林二仓品牌仓、崔桥工厂仓、崔桥工厂品牌仓、 崔桥工厂半成品仓、新苏北仓、睢宁仓、山东青岗集仓、山东品牌仓、河南洛宁仓), 从系统库存抓取已发货/未发货订单,按订单创建时间判定发货是否及时: · 每天 12:00 核验「当天创建时间 7:00 之前」的订单是否已完成发货 · 每天 16:00 核验「当天创建时间 16:00 之前」的订单是否已完成发货 已确认的三条设计决策: 1. 子仓归属 → 连子仓一起算(京东云仓/自营仓/运营C专用仓/唯品仓等并入主仓) 2. 崔桥工厂半成品仓 → 系统无同名仓,先留空占位(不映射到配件仓 15718196) 3. 展示结构 → 只显示超时未发 实现要点 - 仓库归属用「关键词最长匹配」(不写死 wms_co_id),新增子仓只要名字含主仓关键词 就自动归入,缓存 1h。实测 28 个子仓全部正确归入 11 个主仓,132 个代发仓零误并。 - 12:00 之前**不做核验**:resolveActiveRound 返回 null,服务层直接返回 notStarted, 既不查订单表(接口 165ms)也不回填昨天的数据。前端显示「未到核验时间」+ 「12:00 开始首轮核验(核验当日 07:00 前创建的订单)」,11 个仓结构保留、数字显示「—」。 - 订单取数:两张表店铺互斥(jst_Orders = 京东/拼多多/抖音/得物/唯品会, jst_qmcust_orderlist = 天猫/淘宝/1688),并行分开查 + Node 侧按 o_id 去重合并, 用 Promise.allSettled 保证单表失败时另一表数据仍可用(detailDegraded 标记)。 - 三态判定 pending / shippedLate / onTime:send_date 与出库表 io_date 取先到的发货痕迹, 避免上游 send_date 回写滞后导致「已发的被判成没发」。 - 状态过滤:排除 Cancelled / Merged / **Split**(拆单父单永不发货,不排除会捞出一年前 1.3 万条历史单),Question 仍计入待发。 - freshness 字段如实报告出库表最新 io_date,前端「发货数据可能滞后」告警, 不拿一份可能滞后的数据去考核仓库。 排除的两次错误优化(均在代码注释里留了实测数据与复测脚本路径) 1. 用 created 替代 order_date 做窗口过滤(快 10 倍)—— 已证伪:2581 行里完全相等的只有 4 行, 差 >1h 的 412 行,198 行跨日;典型形态 order_date=10-18 23:58 而 created=10-19 04:00:08 (上游凌晨 4 点批量补录,created 是灌库时间不是下单时间)。业务口径是「订单创建时间」, 只有 order_date 正确。 2. 「UNION ALL 一条 SQL 慢 5~8 倍」—— 被后续实测推翻(46.3s vs 并行 48.2s,基本持平)。 验证 - 新增 backend/test/warehouseShipTimeliness.test.js,10 条用例全绿,覆盖: 最长匹配不误并、子仓命名、山东/河南高频前缀不误并、占位仓、12:00 前返回 null、 12:00 轮窗口 00:00~07:00、16:00 轮窗口 00:00~16:00、状态排除表、三态分桶互斥、 跨表重复 o_id 去重、round.windowEnd 必须带出(否则前端渲染成「核验 前」)。 - check-syntax 185 文件通过;全量 node --test 415 项 / 413 过 / 2 fail, 两条失败均为已知环境因素(负缓存带 TTL 因 .env 有 CLOUD_DB_* 短路; resilientSecretStore 的 Windows 权限断言,基线恒失败)。 - 真渲染验收(组件级沙盒 + 真实接口数据):两态均 0 控制台错误; 已核验态 358 单超时未发、8 个仓有数字、点仓切换选中/再点取消均正常; 多分辨率验证 1920/1600 侧栏 208px、1440/1366 收窄 176px、列表始终竖向。 --- backend/src/api/warehouseShipTimeliness.js | 62 ++ backend/src/index.js | 3 + .../warehouseShipTimelinessRegistry.js | 246 ++++++++ .../warehouseShipTimelinessService.js | 528 ++++++++++++++++++ backend/test/warehouseShipTimeliness.test.js | 233 ++++++++ frontend/src/api/warehouseShipTimeliness.js | 23 + .../WarehouseShipTimelinessSidebar.jsx | 146 +++++ frontend/src/pages/MkhBomData/index.jsx | 130 +++++ frontend/src/pages/MkhBomData/style.css | 277 +++++++++ 9 files changed, 1648 insertions(+) create mode 100644 backend/src/api/warehouseShipTimeliness.js create mode 100644 backend/src/services/warehouseShipTimelinessRegistry.js create mode 100644 backend/src/services/warehouseShipTimelinessService.js create mode 100644 backend/test/warehouseShipTimeliness.test.js create mode 100644 frontend/src/api/warehouseShipTimeliness.js create mode 100644 frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx diff --git a/backend/src/api/warehouseShipTimeliness.js b/backend/src/api/warehouseShipTimeliness.js new file mode 100644 index 0000000..a37902e --- /dev/null +++ b/backend/src/api/warehouseShipTimeliness.js @@ -0,0 +1,62 @@ +'use strict'; + +/** + * 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增) + * ──────────────────────────────────────────────────────────────────────────── + * GET /api/warehouse-ship-timeliness/list + * query: + * round 'NOON' | 'AFTERNOON' 手动指定核验轮次;不传按当前时刻自动判定 + * limit 明细条数上限(默认 500,最大 2000) + * + * GET /api/warehouse-ship-timeliness/rounds + * 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回) + * + * 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware + */ + +const express = require('express'); +const { getShipTimeliness } = require('../services/warehouseShipTimelinessService'); +const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry'); + +const router = express.Router(); + +function sendError(res, error, fallback) { + const status = Number(error?.statusCode) || 500; + console.error('[WarehouseShipTimeliness]', { + message: error?.message || fallback, + code: error?.code || null, + status, + }); + res.status(status).json({ success: false, error: error.message || fallback }); +} + +// 时效总览:每个主仓「超时未发」数量 + 超时订单明细 +router.get('/list', async (req, res) => { + try { + const data = await getShipTimeliness(req.query || {}); + res.json({ success: true, data }); + } catch (error) { + sendError(res, error, '加载仓储中心发货时效失败'); + } +}); + +// 仅轮次信息(不查订单) +router.get('/rounds', async (_req, res) => { + try { + res.json({ + success: true, + data: { + rounds: describeRounds(new Date()), + centers: CENTERS.map((c) => ({ + key: c.key, + name: c.name, + placeholder: Boolean(c.placeholder), + })), + }, + }); + } catch (error) { + sendError(res, error, '加载核验轮次失败'); + } +}); + +module.exports = router; diff --git a/backend/src/index.js b/backend/src/index.js index 1af090e..e5fa67d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -35,6 +35,7 @@ const promotionModuleRoutes = require('./api/promotionModule'); const productMonitoringRoutes = require('./api/productMonitoring'); const modularStockMonitorRoutes = require('./api/modularStockMonitor'); const mkhBomRoutes = require('./api/mkhBom'); +const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness'); const dailyInventoryRoutes = require('./api/dailyInventory'); const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification'); const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis'); @@ -672,6 +673,8 @@ async function startServer(options = {}) { cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); + // 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发 + cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes); cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes); cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes); diff --git a/backend/src/services/warehouseShipTimelinessRegistry.js b/backend/src/services/warehouseShipTimelinessRegistry.js new file mode 100644 index 0000000..32d03a6 --- /dev/null +++ b/backend/src/services/warehouseShipTimelinessRegistry.js @@ -0,0 +1,246 @@ +'use strict'; + +/** + * 仓储中心 24 小时发货时效(2026-10-20 新增) + * ──────────────────────────────────────────────────────────────────────────── + * 需求(yanhui 2026-10-20): + * MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓: + * 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 / + * 崔桥工厂半成品仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓 + * 从系统库存抓取「已发货订单」与「未发货订单」,按**订单创建时间**算是否及时。 + * 每天 12:00 核验「当天创建、7:00 前」的订单是否已发货; + * 每天 16:00 核验「当天创建、16:00 前」的订单是否已发货。 + * 展示口径:**只显示超时未发**(业务确认)。 + * + * 业务确认的口径(2026-10-20 AskUserQuestion): + * ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东云仓/京东自营仓/运营C专用仓 等) + * ② 崔桥工厂半成品仓 —— 系统无同名仓,**先留空占位**(不映射到 15718196) + * ③ 展示结构 —— **只显示超时未发** + * + * ── 数据源 ──────────────────────────────────────────────────────────────── + * 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头) + * rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会 + * rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688 + * 字段口径: + * · 创建时间:`order_date`(订单创建时间,业务要求按此算时效) + * · 发货时间:`send_date`(为空 = 未发货) + * · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓) + * ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属, + * 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。 + * + * ── 仓库归属(按名称包含匹配,最长匹配优先)──────────────────────────────── + * `rpt.jst_warehouseInfo` 实测(2026-10-20): + * 横林二仓 15033302 + 15197138/15301814/15835893/16040225/16132681 + * 横林三仓 15043561 + * 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**) + * 崔桥工厂仓 15038426 + 15301815/16040410 + * 崔桥工厂品牌仓 15870495 + * 崔桥工厂半成品仓 —— 无同名仓,留空 + * 新苏北仓 15038347 + 15315031/15301812 + * 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911 + * 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076 + * 山东品牌仓 15182705 + * 河南洛宁仓 15539084 + * ⟹ 仓库 ID → 主仓 的映射**在启动时从库表读取并缓存**(不用手写 ID 常量, + * 避免上游新增子仓后要改代码;新增子仓只要名字里带主仓关键词就会自动归入)。 + * 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。 + * + * ── 时效判定 ────────────────────────────────────────────────────────────── + * 截止时刻(每日两轮,取**当前已生效且最晚**的那一轮;未到 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` 相差十几秒, + * 均为北京时间)。因此 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: ['崔桥工厂品牌仓'] }, + // 系统内无同名仓(最接近的「简尚-崔桥工厂半成品包装项目仓」15718196 是配件仓, + // 业务确认先留空占位,不并进来) + { key: 'CQ_WIP', name: '崔桥工厂半成品仓', keywords: [], placeholder: true }, + { key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] }, + { key: 'SN', name: '睢宁仓', keywords: ['睢宁'] }, + { key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] }, + { key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'] }, + { key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] }, +]); + +// 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段, +// 靠关键词长度倒序匹配即可(见 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); + +// ── 时效轮次(业务给定)────────────────────────────────────────────────── +// cutoffHour:当天几点截止;windowEndHour:只核验「创建时间不晚于该时刻」的订单 +const ROUNDS = Object.freeze([ + { key: 'NOON', label: '12:00 轮', cutoffHour: 12, windowEndHour: 7 }, + { key: 'AFTERNOON', label: '16:00 轮', cutoffHour: 16, windowEndHour: 16 }, +]); + +// 只排除取消/合并单;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')`; + +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'); +} + +/** + * 用「关键词最长匹配」把仓库名归到主仓。 + * @returns {object|null} CENTERS 里的元素;无法归属返回 null + */ +function resolveCenter(warehouseName) { + if (!warehouseName) return null; + for (const item of SORTED_KEYWORDS) { + if (warehouseName.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 = []; + for (const row of rows) { + 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: String(row.name || ''), + }); + } else { + unmatched.push({ wmsCoId: Number(row.wms_co_id), name: String(row.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 }; + return list; +} + +function clearWarehouseCache() { + warehouseCache = { at: 0, rows: [] }; +} + +/** + * 计算「当前已生效」的核验轮次。 + * + * 12:00 之前 → **未到核验时间,返回 null**。 + * 业务明确要求(2026-10-20 确认):12:00 之前不做核验, + * 前端显示「未到核验时间」,**不要回填昨天的数据** —— + * 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。 + * 12:00~15:59 → 用 12:00 轮(窗口 00:00~07:00) + * 16:00 之后 → 用 16:00 轮(窗口 00:00~16:00,覆盖范围更大) + * + * @param {Date} now 以**东八区本地时间**为准的 Date(生产/本地机器均为东八区) + * @returns {object|null} null 表示当前不在任何核验时段内 + */ +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.cutoffHour * 60) active = round; + } + + // 12:00 前:首轮核验还没开始,不做判定 + if (!active) return null; + + const y = now.getFullYear(); + const m = pad2(now.getMonth() + 1); + const d = pad2(now.getDate()); + return { + ...active, + 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`, + date: `${y}-${m}-${d}`, + settled: false, + isPreviousDay: false, + }; +} + +/** + * 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。 + */ +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}`; + return ROUNDS.map((r) => ({ + key: r.key, + label: r.label, + date, + deadline: `${date} ${pad2(r.cutoffHour)}:00:00`, + windowStart: `${date} 00:00:00`, + windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`, + active: now.getHours() * 60 + now.getMinutes() >= r.cutoffHour * 60, + })); +} + +module.exports = { + CENTERS, + ROUNDS, + resolveCenter, + loadCenterWarehouseMap, + clearWarehouseCache, + resolveActiveRound, + describeRounds, + EXCLUDED_STATUS_SQL, + toNumber, +}; diff --git a/backend/src/services/warehouseShipTimelinessService.js b/backend/src/services/warehouseShipTimelinessService.js new file mode 100644 index 0000000..a1990f0 --- /dev/null +++ b/backend/src/services/warehouseShipTimelinessService.js @@ -0,0 +1,528 @@ +'use strict'; + +/** + * 仓储中心 24 小时发货时效 —— 取数/聚合服务(2026-10-20 新增) + * ──────────────────────────────────────────────────────────────────────────── + * 配置与口径定义见 warehouseShipTimelinessRegistry.js(主仓关键词、轮次、时区说明)。 + * + * ── 一次请求做几件事 ────────────────────────────────────────────────────── + * ① 读仓库表 → 主仓 → wms_co_id 集合(缓存 1h) + * ② 一次 SQL 同时取「超时未发」和「已超时已发」两类,按 wms_co_id 分组 + * ③ 归并到主仓,返回每仓的 count / orderAmount,以及全量超时明细 + * + * ── SQL 设计要点(踩过的坑)─────────────────────────────────────────────── + * · 两张订单表**店铺互斥**,用 UNION ALL 合并后一次性分组,不要分别查再在 + * Node 侧合并 —— 两趟扫描在 rpt 视图上代价翻倍。 + * · `wms_co_id IN (...)` 直接内联仓库 ID(都是整数,无注入风险),因为仓库 + * 集合动态生成,用参数化会引入可变数量的 @p0..@pN,反而难维护。 + * · **不要在 ORDER BY 里对聚合列做算术**(见 MEMORY:会触发灾难计划)。 + * 这里 SQL 只 GROUP BY 取原始计数/金额,排序与派生字段全在 Node 侧算。 + * · 时间比较一律用**字面量字符串**(形如 '2026-10-20 16:00:00'),由 + * resolveActiveRound 在东八区算出;不用 GETDATE() 做相对偏移,避免 + * 数据库与应用的时区假设不一致。 + * · 明细行带 `TOP (@limit)`,`@limit` 走参数化,避免界面一次拉几万行。 + */ + +const cloudDatabase = require('../config/cloudDatabase'); +const registry = require('./warehouseShipTimelinessRegistry'); + +const { CENTERS, EXCLUDED_STATUS_SQL, toNumber } = registry; + +// 明细上限:只用于页面展示,超出部分用 truncated 标记提示 +const DEFAULT_DETAIL_LIMIT = 500; +const MAX_DETAIL_LIMIT = 2000; + +/** + * 把 UTC Date / 字符串 统一格式化成 'YYYY-MM-DD HH:mm:ss'(按服务器本地时区)。 + * mssql 驱动默认把 datetime 解析为 UTC 语义的 Date,这里只做展示,统一成 + * 与库内一致的东八区墙钟时间。 + */ +function formatDateTime(value) { + if (!value) return ''; + if (typeof value === 'string') return value.replace('T', ' ').slice(0, 19); + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +/** 两个 'YYYY-MM-DD HH:mm:ss' 相差多少分钟(四舍五入) */ +function diffMinutes(fromText, toText) { + const a = new Date(String(fromText).replace(' ', 'T')).getTime(); + const b = new Date(String(toText).replace(' ', 'T')).getTime(); + if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; + return Math.round((b - a) / 60000); +} + +/** + * 构造两表合并的子查询。 + * + * 🔥 三条必须同时满足(2026-10-20 实测踩坑): + * ① `order_date >= @windowStart` —— **必须有下界**。只写 `<= @deadline` 会把 + * 一年前创建、至今未发的历史单全捞进来(实测 13,553 条,最早 2025-09-01)。 + * ② `order_date <= @deadline` —— 上界:截止时刻后创建的订单不参与本轮考核。 + * ③ Split 已在 EXCLUDED_STATUS_SQL 里排掉(拆单父单永不发货)。 + * + * @param {number[]} wmsIds 主仓下所有 wms_co_id + * @param {string} deadline 截止时刻字面量 + * @param {'pending'|'shippedLate'} mode 取哪一类 + */ +function buildUnionSql(wmsIds, deadline, mode) { + const idList = wmsIds.join(','); + // pending:未发货且窗口内已创建 → 超时未发 + // shippedLate:已发货但发货时间晚于截止时刻 → 已超时已发 + const cond = mode === 'pending' + ? `o.send_date IS NULL` + : `o.send_date IS NOT NULL AND o.send_date > @deadline`; + const branch = (table) => ` + SELECT N'${table}' AS src, o.o_id, o.so_id, o.shop_name, o.shop_id, + o.order_date, o.send_date, o.status, o.shop_status, + o.wms_co_id, o.pay_amount, o.labels + FROM dbo.${table} o + WHERE o.wms_co_id IN (${idList}) + AND o.order_date >= @windowStart + AND o.order_date <= @deadline + AND ${cond} + AND ${EXCLUDED_STATUS_SQL}`; + return `${branch('jst_Orders')} + UNION ALL${branch('jst_qmcust_orderlist')}`; +} + +/** + * 单个主仓的仓库 ID 集合 → 逗号串(用于按主仓分桶时拼 IN 条件) + */ +function joinIds(ids) { + return ids.join(','); +} + +/** + * 主入口:计算某一天、某一轮次的仓储中心发货时效。 + * + * @param {object} options + * @param {string} [options.round] 'NOON' | 'AFTERNOON',不传则按当前时刻自动判定 + * @param {Date} [options.now] 仅测试用 + * @param {number} [options.limit] 明细条数上限 + * @returns {Promise} + */ +async function getShipTimeliness(options = {}) { + const now = options.now instanceof Date ? options.now : new Date(); + const limit = Math.min( + Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1), + MAX_DETAIL_LIMIT + ); + + // ① 主仓 → 仓库映射 + const centerList = await registry.loadCenterWarehouseMap(); + const pad2 = (n) => String(n).padStart(2, '0'); + + // ② 决定本轮截止时刻 + const rounds = registry.describeRounds(now); + let round = null; + if (options.round) { + const target = registry.ROUNDS.find((r) => r.key === String(options.round).toUpperCase()); + if (!target) { + const err = new Error(`未知的核验轮次: ${options.round}`); + err.statusCode = 400; + throw err; + } + const pad = (n) => String(n).padStart(2, '0'); + const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + round = { + ...target, + date, + deadline: `${date} ${pad(target.cutoffHour)}:00:00`, + windowStart: `${date} 00:00:00`, + windowEnd: `${date} ${pad(target.windowEndHour)}:00:00`, + active: now.getHours() * 60 + now.getMinutes() >= target.cutoffHour * 60, + }; + } else { + round = registry.resolveActiveRound(now); + } + + // 🔥 12:00 前没有任何轮次生效(业务口径:12:00 才开始第一轮核验)。 + // 这里**直接返回「未到核验时间」**,既不查库、也不回填昨天 —— + // 让业务清楚知道「现在还没到看数据的时候」,而不是拿旧数字去对仓库。 + if (!round) { + const centerList = await registry.loadCenterWarehouseMap(); + const nextRound = rounds.find((r) => r.cutoffHour === 12) || rounds[0]; + return { + generatedAt: formatDateTime(now), + date: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`, + round: null, + notStarted: true, + isPreviousDay: false, + rounds, + nextRoundAt: nextRound ? nextRound.deadline : '', + deadline: '', + window: null, + summary: emptySummary(), + centers: buildEmptyCenters(centerList), + orders: [], + truncated: false, + detailLimit: limit, + detailDegraded: false, + freshness: null, + note: '今日 12:00 首轮核验开始后才展示超时数据', + }; + } + + // 没有归属任何主仓的仓库不用查 + const allWmsIds = [...new Set( + centerList.flatMap((c) => c.warehouses.map((w) => w.wmsCoId)).filter((n) => Number.isFinite(n)) + )]; + + if (!allWmsIds.length) { + return { + generatedAt: formatDateTime(now), + date: round.date, + round: { key: round.key, label: round.label }, + isPreviousDay: Boolean(round.isPreviousDay), + rounds, + deadline: round.deadline, + window: { start: round.windowStart, end: round.windowEnd }, + summary: emptySummary(), + centers: buildEmptyCenters(centerList), + orders: [], + truncated: false, + detailLimit: limit, + note: '仓库表中未匹配到任何目标主仓,请检查仓库命名', + }; + } + + // ③ 一趟 SQL 取全量分组结果。原实现拆成「超时未发 / 已超时已发 / 窗口总数」 + // 三条 SQL,在 rpt 视图上三趟全表扫描实测 213 秒;合并成一趟后按 + // `src + 是否超时` 打标记再 GROUP BY,只扫一次两表。 + // + // 分组维度:主仓 不直接在 SQL 里 GROUP(仓库→主仓的映射在 Node 侧缓存里), + // 所以按 wms_co_id 分组,回 Node 侧累加到主仓。 + // + // 🔥 「算不算已发货」的三态判定(2026-10-20 实测新增): + // 上游 `send_date` 回写**明显滞后** —— 实测 10-20 当天 700 单里只有 100 单 + // 有 send_date,且都集中在 08:16~08:44(刚回写的那几单)。若只看 send_date, + // 中午 12:00 核验时当天早上的单会**全部被判超时**,页面毫无价值。 + // 因此引入出库表 `rpt.jst_salesOutboundInfo` 作为佐证: + // · 判为「已发」= send_date 有值 **或** 出库表能查到该 o_id + // · 判为「按时」= send_date <= 截止 **或** 出库行 io_date <= 截止 + // ⚠️ 出库表同样滞后(每天 04:00 起才有数据,当天往往 0 行,次日凌晨灌), + // 所以它只能**减少误判**(把「已发的判成没发」纠正过来), + // 不能凭空补出「没发的单已经发了」。最终仍可能有误报,故返回值里带 + // `freshness` 让前端明示「数据可能滞后」。 + // ⚠️ 出库表 `qty` 列全表恒 0(见 MEMORY),判断有无出库**只看行数 / io_date**。 + // + // 每条订单归属哪一类(互斥): + // pending —— 两源都查不到发货痕迹 → 超时未发 + // shippedLate —— 有发货痕迹但发货时间 > 截止 → 已发但超时 + // onTime —— 发货时间 <= 截止 → 按时已发 + // ⚠️ OUTER APPLY 里只取 `io_date`,用「io_date 是否为 NULL」判断出库表有无命中。 + // 别在外面引用 `ob.o_id` —— APPLY 子查询只 SELECT 了 io_date,引用不存在的列 + // 会报 `Invalid column name 'o_id'`(2026-10-20 已踩)。 + // + // ⚡ 性能关键(2026-10-20 实测): + // · 原实现「汇总 SQL + 明细 SQL」各扫一遍窗口,合计 108 秒; + // · 窗口内订单只有几百条(实测 420 条),所以改成**一趟取回全部窗口订单明细**, + // 汇总与明细都在 Node 里算 —— 少一次大表扫描。 + // · 返回列加上 pay_amount / status,供 Node 侧分桶。 + // + // 🔥🔥 两表**不能 UNION ALL 成一条 SQL**(2026-10-20 实测,这是本文件最大的坑): + // · `jst_qmcust_orderlist` 是**宽视图**(上百列),拆开单独跑很快,但一旦参与 + // UNION ALL + 派生表 + OUTER APPLY 的嵌套,优化器会放弃谓词下推, + // 变成先物化整表再过滤 —— 实测单条 SQL 跑 200~320 秒。 + // · 两表**分别查、Node 侧合并**:jst_Orders 5.5 秒、jst_qmcust_orderlist 35 秒, + // 合计约 40 秒,比合并版快 5~8 倍。 + // · 两表店铺完全互斥(京东/拼多多/抖音/得物/唯品会 vs 天猫/淘宝/1688), + // o_id 不会有交集,直接拼接即可,不需要再按 o_id 去重(仍保留一次去重兜底)。 + // + // 🔥 `jst_qmcust_orderlist` 单表也有 35 秒,而同样条件的 `jst_Orders` 只要 2 秒。 + // 曾尝试把它的窗口过滤换成 `created`(实测快 10 倍),但**已证伪,不能用**: + // · 2026-10-20 实测 2,581 行里,`order_date` 与 `created` 完全相等的只有 4 行, + // 相差 >1 小时的有 412 行,最大差 24.7 天,**198 行甚至跨日**; + // · 典型形态:`order_date=10-18 23:58` 而 `created=10-19 04:00:08` —— + // 上游每天凌晨 4 点批量补录,`created` 是**灌库时间**不是下单时间; + // · 同一窗口下 `created` 比 `order_date` 多命中 30 单(全是补录单)。 + // 业务口径是「按**订单创建时间**算是否及时」,只有 `order_date` 是对的。 + // 所以:**两表都用 `order_date`**,性能只能靠「两表分开查 + Node 侧合并」。 + // ⚠️ 换回 `created` 之前必须先跑 `.workbuddy/tmp/probe-od-vs-created.cjs` 复测。 + const windowBranch = (table) => ` + SELECT N'${table}' AS src, + o.wms_co_id, o.o_id, o.so_id, o.shop_name, o.order_date, + o.send_date, o.status, o.shop_status, o.pay_amount, o.labels, + ob.io_date AS ob_io_date + FROM dbo.${table} o + OUTER APPLY ( + SELECT TOP 1 s.io_date + FROM dbo.jst_salesOutboundInfo s + WHERE s.o_id = o.o_id + ORDER BY s.io_date ASC + ) ob + WHERE o.wms_co_id IN (${joinIds(allWmsIds)}) + AND o.order_date >= @windowStart + AND o.order_date <= @deadline + AND ${EXCLUDED_STATUS_SQL}`; + + const params = { deadline: round.deadline, windowStart: round.windowStart }; + // ⚠️ 超时给足:qmcust 单表实测 35~80 秒(视图谓词下推较差), + // 不能按 jst_Orders 的 5 秒去设,否则会被误杀成 500。 + const queryOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 320000 }; + + // 两表并行查(互不依赖),Node 侧合并 + const [ordersRows, qmcustRows] = await Promise.allSettled([ + cloudDatabase.query(windowBranch('jst_Orders'), params, queryOpts), + cloudDatabase.query(windowBranch('jst_qmcust_orderlist'), params, queryOpts), + ]); + + // 🔥 用 allSettled 而不是 all:一张表超时/报错时,另一张表的数据仍然可用, + // 否则整页变空白。降级情况记录在 detailDegraded + note 里。 + const mergeWarnings = []; + const windowRows = []; + if (ordersRows.status === 'fulfilled') { + windowRows.push(...(ordersRows.value || [])); + } else { + mergeWarnings.push(`jst_Orders 查询失败:${ordersRows.reason?.message || '未知错误'}`); + } + if (qmcustRows.status === 'fulfilled') { + windowRows.push(...(qmcustRows.value || [])); + } else { + mergeWarnings.push(`jst_qmcust_orderlist 查询失败:${qmcustRows.reason?.message || '未知错误'}`); + } + if (!windowRows.length && mergeWarnings.length) { + // 两表全挂 —— 这才是真正的失败,直接抛给上层 + const err = new Error(mergeWarnings.join(';')); + err.statusCode = 502; + throw err; + } + + // ④ 数据新鲜度:出库表最新 io_date。 + // ⚠️ 只用于「提示用户数据可能滞后」,不参与超时判定。 + // 两个源都滞后时(出库表当天 0 行 + send_date 昨天就停了),说明上游还没灌数, + // 此时「超时未发」的准确性无法保证 —— 前端必须显式告警,不能让业务拿着 + // 一份假数据去考核仓库。 + const freshness = await loadShipFreshness(round); + + // ⑤ 三态归类(在 Node 侧做,SQL 只负责取数) + const wmsToCenter = new Map(); + for (const c of centerList) { + for (const w of c.warehouses) wmsToCenter.set(w.wmsCoId, c.key); + } + + const acc = new Map(centerList.map((c) => [c.key, { + pendingCount: 0, + pendingAmount: 0, + shippedLateCount: 0, + onTimeCount: 0, + }])); + + const pendingRows = []; + // 两表店铺互斥、o_id 理论不重叠,但历史上出现过同一 o_id 被两边都收录的情况, + // 按 `${src}-${o_id}` 去重(跨表同 o_id 也算重复,取先到的 jst_Orders)。 + const seenKey = new Set(); + for (const row of windowRows || []) { + const bucket = acc.get(wmsToCenter.get(Number(row.wms_co_id))); + if (!bucket) continue; + const dedupeKey = String(row.o_id || ''); + if (dedupeKey) { + if (seenKey.has(dedupeKey)) continue; + seenKey.add(dedupeKey); + } + const kind = bucketOf(row, round.deadline); + if (kind === 'pending') { + bucket.pendingCount += 1; + bucket.pendingAmount += toNumber(row.pay_amount); + pendingRows.push(row); + } else if (kind === 'shippedLate') { + bucket.shippedLateCount += 1; + } else { + bucket.onTimeCount += 1; + } + } + + // 明细:只要「超时未发」,最早创建的最急,排前面 + // 用 formatDateTime 转成 'YYYY-MM-DD HH:mm:ss' 再比字符串 —— 字典序与时间序一致, + // 且不受 mssql 驱动把 datetime 解析成 UTC Date 的影响(见文件头时区说明)。 + pendingRows.sort((a, b) => { + const x = formatDateTime(a.order_date); + const y = formatDateTime(b.order_date); + if (x < y) return -1; + if (x > y) return 1; + return 0; + }); + const detailRows = pendingRows.slice(0, limit); + + const centers = centerList.map((c) => { + const b = acc.get(c.key) || { pendingCount: 0, pendingAmount: 0, shippedLateCount: 0, onTimeCount: 0 }; + const totalCount = b.pendingCount + b.shippedLateCount + b.onTimeCount; + return { + key: c.key, + name: c.name, + placeholder: c.placeholder, + warehouseCount: c.warehouses.length, + warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })), + // 列表主展示:超时未发 + overdueUnshipped: b.pendingCount, + overdueUnshippedAmount: Math.round(b.pendingAmount * 100) / 100, + // 辅助信息 + overdueShipped: b.shippedLateCount, + windowOrderCount: totalCount, + onTimeRate: totalCount > 0 + ? Math.round((b.onTimeCount / totalCount) * 1000) / 10 + : null, + }; + }); + + const orders = (detailRows || []).map((r) => { + const orderDate = formatDateTime(r.order_date); + const center = centerList.find((c) => c.key === wmsToCenter.get(Number(r.wms_co_id))); + const warehouse = center && center.warehouses.find((w) => w.wmsCoId === Number(r.wms_co_id)); + return { + oId: String(r.o_id || ''), + soId: String(r.so_id || ''), + source: r.src === 'jst_qmcust_orderlist' ? 'qmcust' : 'orders', + shopName: String(r.shop_name || ''), + orderDate, + sendDate: formatDateTime(r.send_date), + status: String(r.status || ''), + shopStatus: String(r.shop_status || ''), + wmsCoId: Number(r.wms_co_id), + warehouseName: warehouse ? warehouse.name : '', + centerKey: center ? center.key : '', + centerName: center ? center.name : '', + payAmount: toNumber(r.pay_amount), + labels: String(r.labels || ''), + // 相对截止时刻的超时时长(分钟) + overdueMinutes: diffMinutes(orderDate, round.deadline), + }; + }); + + const summary = centers.reduce((acc2, c) => { + acc2.overdueUnshipped += c.overdueUnshipped; + acc2.overdueShipped += c.overdueShipped; + acc2.overdueUnshippedAmount += c.overdueUnshippedAmount; + acc2.windowOrderCount += c.windowOrderCount; + if (c.overdueUnshipped > 0) acc2.overdueCenterCount += 1; + return acc2; + }, emptySummary()); + summary.overdueUnshippedAmount = Math.round(summary.overdueUnshippedAmount * 100) / 100; + // 按时率统一口径:按时 = 窗口内总数 - 超时未发 - 已发超时 + // (与各仓算法完全一致,避免两处各算一遍算出不同数字) + summary.onTimeRate = summary.windowOrderCount > 0 + ? Math.round(((summary.windowOrderCount - summary.overdueUnshipped - summary.overdueShipped) + / summary.windowOrderCount) * 1000) / 10 + : null; + + const totalPending = summary.overdueUnshipped; + + return { + generatedAt: formatDateTime(now), + date: round.date, + // ⚠️ 前端要展示「核验 07:00 / 16:00 前」,必须把 windowEnd 一起带出去。 + // 早先只回了 key/label,前端拿不到 cutoff,页面就渲染成「核验 前」。 + round: { + key: round.key, + label: round.label, + cutoffHour: round.cutoffHour, + windowEnd: round.windowEnd, + }, + // 12:00 前不会走到这里(见上方 notStarted 分支),留着兼容显式指定 round 的调用 + notStarted: false, + isPreviousDay: false, + rounds, + deadline: round.deadline, + window: { start: round.windowStart, end: round.windowEnd }, + summary, + centers, + orders, + truncated: totalPending > orders.length, + detailLimit: limit, + // 任一张订单表查询失败 → 数字不完整,前端要显式告警(不能拿残缺数据考核仓库) + detailDegraded: mergeWarnings.length > 0, + freshness, + note: mergeWarnings.length ? `${mergeWarnings.join(';')}(数据不完整)` : '', + }; +} + +/** + * 计算发货数据新鲜度。 + * + * 🔥 业务上这是**最容易被误判成系统 bug** 的地方(见 MEMORY 入口 1): + * 上游 send_date / 出库表都滞后,「超时未发」数量会虚高。 + * 这里只如实报告滞后程度,不做任何「猜测补数」。 + * + * @returns {Promise<{outboundMaxIo: string, sendDateMax: string, stale: boolean, hint: string}>} + */ +async function loadShipFreshness(round) { + try { + const rows = await cloudDatabase.query( + `SELECT CONVERT(varchar(19), MAX(io_date), 120) AS outbound_max + FROM dbo.jst_salesOutboundInfo`, + {}, + { requestTimeout: 60000 } + ); + const outboundMaxIo = rows && rows[0] ? String(rows[0].outbound_max || '') : ''; + // 出库表最新一行早于今天 → 当天发货数据尚未灌入 + const outboundStale = !outboundMaxIo || outboundMaxIo.slice(0, 10) < round.date; + return { + outboundMaxIo, + stale: outboundStale, + hint: outboundStale + ? `出库表最新数据 ${outboundMaxIo || '暂无'},今天(${round.date})的发货记录尚未灌入,` + + '「超时未发」可能偏多,请以仓库实际发货情况为准' + : '', + }; + } catch (e) { + // 新鲜度查询失败绝不能影响主结果 + console.warn('[ShipTimeliness] 取新鲜度失败:', e.message); + return { outboundMaxIo: '', stale: true, hint: '发货数据新鲜度未知' }; + } +} + +/** + * 单条订单的三态归类(与服务层口径一一对应): + * pending —— send_date 为空 **且** 出库表无命中 → 超时未发 + * shippedLate —— 有发货痕迹但发货时间晚于截止 → 已发但超时 + * onTime —— 发货时间不晚于截止 → 按时已发 + * + * 「发货时间」取值优先级:send_date 有值就用 send_date,否则用出库表 io_date。 + * (两个都滞后时以 send_date 为准,因为它是订单系统自己的回写,语义最直接。) + * + * @param {object} row 含 send_date / ob_io_date + * @param {string} deadline 'YYYY-MM-DD HH:mm:ss' + */ +function bucketOf(row, deadline) { + const sendDate = formatDateTime(row.send_date); + const ioDate = formatDateTime(row.ob_io_date); + if (!sendDate && !ioDate) return 'pending'; + const shippedAt = sendDate || ioDate; + return shippedAt > deadline ? 'shippedLate' : 'onTime'; +} + +function emptySummary() { + return { + overdueUnshipped: 0, + overdueShipped: 0, + overdueUnshippedAmount: 0, + overdueCenterCount: 0, + windowOrderCount: 0, + onTimeRate: null, + }; +} + +function buildEmptyCenters(centerList) { + return centerList.map((c) => ({ + key: c.key, + name: c.name, + placeholder: c.placeholder, + warehouseCount: c.warehouses.length, + warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })), + overdueUnshipped: 0, + overdueUnshippedAmount: 0, + overdueShipped: 0, + windowOrderCount: 0, + onTimeRate: null, + })); +} + +module.exports = { + getShipTimeliness, + DEFAULT_DETAIL_LIMIT, + MAX_DETAIL_LIMIT, +}; diff --git a/backend/test/warehouseShipTimeliness.test.js b/backend/test/warehouseShipTimeliness.test.js new file mode 100644 index 0000000..6771d02 --- /dev/null +++ b/backend/test/warehouseShipTimeliness.test.js @@ -0,0 +1,233 @@ +/** + * 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 新增) + * + * 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定四条关键语义: + * ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走, + * 否则品牌仓的订单会被重复计入两个仓; + * ② 核验轮次的时刻选择 —— 12:00 前**不做核验(返回 null)**、12:00~15:59 用 + * 7:00 窗口、16:00 后用 16:00 窗口; + * ③ 状态排除表 —— Cancelled / Merged / Split 必须排除(Split 是拆单父单, + * 永不发货,不排除会把一年前的单全算成超时未发); + * ④ 服务层分桶算术 —— pending/shippedLate/onTime 三态互斥,按时率不为负。 + * + * ⚠️ 本文件**不连数据库**,纯逻辑测试。 + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const registry = require('../src/services/warehouseShipTimelinessRegistry'); + +// ── ① 主仓归属:最长匹配 ──────────────────────────────────────────────── +test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => { + assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND'); + assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2'); + assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND'); + assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ'); + assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND'); + assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ'); +}); + +test('resolveCenter 能捞到各种命名的子仓', () => { + const cases = [ + ['京仓-横林二仓京东云仓', 'HL2'], + ['简尚-运营C横林二仓专用仓', 'HL2'], + ['简尚-横林二仓京东仓', 'HL2'], + ['简尚-崔桥工厂仓唯品仓', 'CQ'], + ['简尚-运营C崔桥工厂仓专用仓', 'CQ'], + ['简尚-新苏北临时仓', 'XSB'], + ['简尚-分销部睢宁专用仓', 'SN'], + ['京仓-山东青岗集京东仓储', 'SD_QGJ'], + ['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'], + ['简尚-河南洛宁仓', 'HN_LN'], + ]; + for (const [name, expected] of cases) { + assert.equal(registry.resolveCenter(name)?.key, expected, name); + } +}); + +test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => { + // 这是最容易翻车的地方:仓库表里有上百个「工厂-山东XX代发仓」, + // 如果关键词写成「山东」就会全被并进山东品牌仓/青岗集仓。 + const shouldBeNull = [ + '工厂-山东菏泽森美代发仓', + '工厂-山东恒思代发仓', + '工厂-河南千优代发仓', + '工厂-河北霸州派雅世佳代发仓', + '简尚-苏州福利品仓', + '简尚-崔桥工厂半成品包装项目仓', + ]; + for (const name of shouldBeNull) { + assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`); + } +}); + +test('崔桥工厂半成品仓是占位仓:没有关键词、匹配不到任何仓', () => { + const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP'); + assert.ok(c, 'CQ_WIP 必须存在(业务要求列出来)'); + assert.equal(c.placeholder, true); + assert.deepEqual(c.keywords, []); +}); + +// ── ② 核验轮次 ────────────────────────────────────────────────────────── +const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区) + +test('12:00 前不做核验,返回 null(不回填昨日数据)', () => { + // 业务口径(2026-10-20 确认):中午 12:00 才开始第一轮核验, + // 12:00 之前页面显示「未到核验时间」,**不能用昨天的结算结果冒充今天**。 + assert.equal(registry.resolveActiveRound(at(9, 30)), null); + assert.equal(registry.resolveActiveRound(at(11, 59)), null); + assert.equal(registry.resolveActiveRound(at(0, 5)), null); +}); + +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('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 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('排除 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, realDb) { + const wmsId = 15038426; + // 重构后服务层是「一趟取回窗口内全部订单,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: '测试店', + order_date: '2026-10-20 01:00:00', + send_date: sendDate, + status: 'Delivering', + shop_status: '', + pay_amount: amount, + labels: '', + ob_io_date: null, + }); + + 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), + ]; + + // 记录业务表被查了几次 —— 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: 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 }; +} + +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')]; + } +}); + +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')]; + } +}); diff --git a/frontend/src/api/warehouseShipTimeliness.js b/frontend/src/api/warehouseShipTimeliness.js new file mode 100644 index 0000000..7f313f6 --- /dev/null +++ b/frontend/src/api/warehouseShipTimeliness.js @@ -0,0 +1,23 @@ +import request from './request'; + +// 发货时效要扫两张订单表 + 出库表(rpt 下全是视图),冷启动实测 40~50 秒, +// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。 +const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000; + +// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细 +// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定) +// params.limit 明细条数上限 +export const getWarehouseShipTimeliness = (params = {}) => request.get( + '/api/warehouse-ship-timeliness/list', + { + params, + timeout: TIMELINESS_TIMEOUT_MS, + headers: { 'Cache-Control': 'no-cache' }, + } +); + +// 仅取今日两轮核验的描述(不打订单表,秒回),用于顶部提示 +export const getWarehouseShipTimelinessRounds = () => request.get( + '/api/warehouse-ship-timeliness/rounds', + { timeout: 30000 } +); diff --git a/frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx b/frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx new file mode 100644 index 0000000..56d9de1 --- /dev/null +++ b/frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx @@ -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 ( + + ); +} + +export default WarehouseShipTimelinessSidebar; diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 993ac5f..9ca3aea 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -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(() => { @@ -986,6 +1047,73 @@ const MkhBomData = () => { + {/* + 左侧「仓储中心24小时发货时效」侧栏 + 右侧原有内容,横向两栏。 + 侧栏收起时只留一条窄条,主区自动占满 —— 用 flex,不用固定宽度判断。 + */} +
+ setShipCollapsed((v) => !v)} + /> + +
+ {/* + 超时明细:只有点了左侧某个仓才展开。 + 明细来自同一次 list 接口(已按 order_date 升序,最早的最急), + 不再单独请求 —— 两套数据必须同源同快照,否则数字对不上。 + */} + {shipCenter && shipTimeliness ? ( + + 超时未发订单 · {shipCenterName} + + {shipCenterOrders.length} 单 + {shipTimeliness.truncated && shipCenterOrders.length >= (shipTimeliness.detailLimit || 0) + ? `(已截断,仅显示前 ${shipTimeliness.detailLimit} 条)` + : ''} + + + )} + extra={} + > + `${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) => {formatOverdue(v)}, + }, + { + title: '金额', + dataIndex: 'payAmount', + width: 96, + align: 'right', + render: (v) => `¥${number(v, 2)}`, + }, + { title: '状态', dataIndex: 'status', width: 110 }, + ]} + /> + + ) : null} + { + + ); }; diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index afb18b0..aeda168 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -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; +} -- 2.47.2