yanhui cb7d06c20c feat(wst): 数据源停更熔断——订单表停更时醒目标注,别把「没同步」当「没发货」
## 业务问题

10-28 18:00 轮页面核验出 546 单超时未发,业务核对「基本都发了,怎么会核验出未发」。

## 根因(不是判定写错,是数据源停更)

`jst_Orders` 当时**从 14:40 就停止同步了**(停了 4 小时),停更之后发出的单
`send_date` 一直是空,因此全被判成「未发货」:

| 来源表 | 超时单数 | 该表状态 |
|---|---|---|
| `jst_Orders` | 540 | 最后更新 14:40(停 4 小时) |
| `jst_qmcust_orderlist` | 6 | 17:30 正常 |

两个订单表**各自独立同步**,同一天完全可能一个正常一个停更。原来只拿候选行的
`MAX(updated_at)` 判快照滞后,会被「碰巧新」的行掩盖 —— 必须直接问源表。

## 改动

后端 `warehouseShipTimelinessService.js`:

- 新增 `loadSourceFreshness()`:分别直接查 `jst_Orders.MAX(updateTime)` 与
  `jst_qmcust_orderlist.MAX(local_updated)`(限轮次窗口起 30 天,避免全表扫描),
  两源并行;按 `CANGKU_WST_SOURCE_STALE_MINUTES`(默认 60 分钟)判停更
  - 查询失败或取不到更新时间 → **不判停更**(宁可漏报,不谎报为停更)
- 明细行新增 `src` / `srcLabel` / `sourceStale`;`summary` 新增 `sourceStaleUnshipped`
- `freshness.stale` 改为「候选行滞后 **OR** 任一源停更」;`hint` 说清停更源、
  停更时刻、以及各源各贡献多少单(不能只写「这张表」)
- 导出数据带 `dataSources`,Excel 脱离页面也有上下文
- 响应新增 `dataSources` 数组(每源 label / lastUpdate / lagMinutes / stale)

前端 `WarehouseShipTimeliness/index.jsx`:

- 红条 Alert:「N 单所在订单表已停止同步,这些单很可能已经发货」(error 级),
  描述里逐源列出「最后同步 14:40:05(已 3.9 小时)」
- 超时统计卡下方小字:「其中 N 单数据未同步」(悬停有说明)
- 明细「内部单号」旁「未同步」红标签 + tooltip,一眼看出哪几行不可信
- 导出新增「数据源 / 数据源是否停更」两列(`是(该单可能已发)`)

## 验证

- 后端 `npm run check`:**494/496**,2 个失败是既有环境问题(负缓存 TTL 计时、
  Windows 文件权限 0o600),改动前同样失败
- `warehouseShipTimeliness.test.js` **23/23** 通过(含新增「停更表超时单必须打标
  并计入 sourceStaleUnshipped」用例)
- 本地真渲染(Playwright + 手签 token,localhost:5173):
  - 真数据:13:00 前 `notStarted` 正常展示「—」,无 pageerror
  - mock 停更数据:红条 / 逐源行 / 统计小字 / 3 行「未同步」标签全部渲染,无 pageerror
- 真实接口 10-28 13:00 轮:`dataSources` 正确识别 `jst_Orders` 停更(滞后 96 分钟)
  而 qmcust 正常;`sourceStaleUnshipped=1146` 与明细逐行打标同源同口径

## 风险与回退

- 只新增字段与展示,**不改动分轮、起算点、候选池口径** —— 超时单量本身不变
- 阈值可用 `CANGKU_WST_SOURCE_STALE_MINUTES` 调;不配默认 60 分钟
- 低峰期(凌晨)可能出现「1 小时无新单」被判停更,但页面只在 13:00 / 18:00
  核验时刻展示超时数据,这两个时点 1 小时不同步即属异常
- 回退:revert 本提交即可,无数据迁移、无新表、无新依赖
2026-10-29 08:25:41 +08:00

362 lines
6.7 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

.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;
}
/* 超时卡下面的「其中 N 单数据未同步」—— 订单表停更时的警示小字(2026-10-28) */
.wst-stat__warn {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: #cf1322;
cursor: help;
}
/* 停更告警里逐个源列出「最后同步时间 / 滞后多久」 */
.wst-alert__sources {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.wst-alert__source {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
/* 明细里的「内部单号 + 未同步标记」 */
.wst-detail__oid {
display: inline-flex;
align-items: center;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.wst-detail__stale {
margin-inline-end: 0;
font-size: 11px;
line-height: 16px;
padding-inline: 5px;
cursor: help;
}
/* ============ 主从两栏 ============ */
.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;
}
}
/* ── 钉钉通知(两轮核验完成后各推一次)───────────────────────────── */
.wst-notify__row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wst-notify__label {
flex: 0 0 88px;
font-size: 13px;
color: var(--sycm-text-secondary);
}
.wst-notify__last {
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
}
.wst-notify__error {
margin-top: 4px;
}
.wst-notify__help {
font-size: 12px;
line-height: 1.8;
color: var(--sycm-text-secondary);
}
.wst-notify__help code {
padding: 0 4px;
border-radius: 3px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.04));
}
.wst-notify__preview {
padding: 10px 12px;
border: 1px solid var(--sycm-border, rgba(0, 0, 0, 0.08));
border-radius: 8px;
background: var(--sycm-fill-quaternary, rgba(0, 0, 0, 0.02));
font-size: 12px;
line-height: 1.8;
}
.wst-notify__preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wst-notify__preview pre {
margin: 8px 0 0;
padding: 8px 10px;
border-radius: 6px;
background: var(--sycm-bg-container, #fff);
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
line-height: 1.7;
/* 30 条明细大概 40 行,别把整个弹窗撑爆;内部滚动看全 */
max-height: 280px;
overflow: auto;
}