furniture-3d-system-Wu/furniture-3d-system/docs/架构设计/物流到货签收时长看板-订单明细快照.md
apple eca96b3067 fix(logistics-arrival): 快照覆盖判定改用实际数据覆盖,修当天少算/导出缺单
看板/导出的快照覆盖判定此前只比对批次"声明窗口"(windowTo = 跑批当天),
但每天 03:00/05:30 跑批时当天还没签收完,数据只到跑批那一刻,于是把
"还没签收完的当天"误判成已覆盖:10-08 生成的批次只会算到 10-07(约 3.8 万),
当天签收的单(用户清单里那 5,820 条)整批缺失。

- 快照批次新增 dataThrough(该批真实最大签收时间),覆盖上限改为
  min(windowTo - 1, dataThrough 当天):跑批当天永远不完整,只信任到前一天;
  数据源本身滞后时再由 dataThrough 收窄。
- 生成任务逐片记录 dataThrough 并写回批次;旧批次缺值时按 windowTo - 1 兜底。
- 定时器默认 05:30 → 03:00;文档同步更新覆盖规则。
- migration: LogisticsArrivalSigningDurationSnapshotRun.dataThrough。

任务名:物流到货签收时长-deepseek
2026-10-09 17:16:12 +08:00

29 KiB
Raw Blame History

物流到货签收时长看板 · 订单明细每日快照

为什么要做

看板的全部数据来自远端只读报表视图 rpt.jst_order_express_details(外加 jst_qmcust_orderlist_item 条码明细和 SupplyChainProductArchive 产品档案富化)。 该视图是远端对象,一次扫描通常以分钟计,页面每次刷新、翻页、下钻、导出都要重新扫一遍,看板因此很难"高效运转"。

做法:每天把看板口径的订单明细物化到本地 Postgres,看板优先读本地快照,只有快照覆盖不到时才回落实时数据源。

数据与表

条码/供应商/采购的匹配来源(两套互补的订单表)

报表库把订单明细拆成了两套互不重叠的表,只看一套会漏掉近一半订单的条码:

表 覆盖平台 规模(实测)
rpt.jst_qmcust_orderlist / rpt.jst_qmcust_orderlist_item 淘宝天猫、阿里巴巴(1688)、淘工厂、天猫供销 408 万 / 576 万行
rpt.jst_Orders / rpt.jst_OrderItem 京东商城、京东厂家直送、拼多多、头条放心购(抖音)、得物、唯品会、线下、微信视频号、小红书 124 万 / 175 万行

看板取条码时会同时查两张明细表并用 INNER_ORDER_ID(即 o_id)合并(listItemRows,两表按订单号各发一批查询后合并结果)。修复前只查 jst_qmcust_orderlist_item,导致京东/拼多多/抖音/得物/唯品会的订单条码全空;修复后新生成的快照条码匹配率为 100%(此前整体 52.3%)。

注:rpt.jst_salesOutboundInfo(销售出库单头)可以读取,但只有 o_id / so_id / l_id / shop_name / io_id / qty 等出库单头字段,没有 SKU 列,不能单独提供条码。

纯新增两张表(迁移 prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot):

表 作用
LogisticsArrivalSigningDurationSnapshotRun 批次元数据:snapshotDate、windowFrom、windowTo、status(RUNNING/COMPLETED/FAILED)、rowCount、message
LogisticsArrivalSigningDurationSnapshot 明细行:与看板 09 订单明细一一对应,含条码/供应商/采购/productItems 等快照时点已固化的富化结果

要点:

  • 只有 status = COMPLETED 的批次对读路径可见,写入中途或失败的批次不会被读到。
  • 行唯一键 (snapshotDate, rowKey),rowKey 与下钻键一致(内部单号 + 线上订单号 + 物流单号 + 签收时间),重复执行用 skipDuplicates 幂等。
  • (snapshotDate, signTime) 有索引,按签收日期区间取数走索引。

生成任务

# 默认:快照日期=当天(Asia/Shanghai),窗口=最近 62 天(覆盖本月与上月),保留最近 2 批
npm run sync:logistics-arrival-signing-duration-snapshot -- \
  --apply --confirm=sync-logistics-arrival-signing-duration-snapshot \
  --expected-database-identity="<本地库身份>" \
  --expected-source-database-identity="<远端报表身份>"

可调参数:--snapshot-date=YYYY-MM-DD、--window-days、--chunk-days(默认 7,按天分批抓取以控制内存)、--batch-size(默认 2000)、--retention、--force(当天批次已完成时强制重建)。

编排逻辑在 src/server/services/logistics-arrival-signing-duration-snapshot.service.ts:

  1. 当天批次已 COMPLETED 且未 --force → 直接跳过;
  2. 标记 RUNNING 并清空当天旧行;
  3. 按 chunk-days 分片读远端并写入本地,同时记下真正抓到的最新签收时间 dataThrough;
  4. 全部成功才标记 COMPLETED(连同 dataThrough),随后按 retention 清理更早批次;任一分片失败则标记 FAILED 并保留错误信息,读路径会自动继续用上一批快照;
  5. 收尾关闭远端连接池(一次性任务必须关闭,否则进程挂在事件循环上)。

生产定时(systemd timer,默认北京时间 03:00):

sudo bash scripts/install-logistics-arrival-signing-duration-snapshot-timer.sh

首次安装会生成 $BASE/shared/logistics-arrival-signing-duration-snapshot.env 模板,需要填两个身份校验值(缺省为空会拒绝写入)。可用环境变量覆盖:LOGISTICS_SNAPSHOT_CALENDAR、LOGISTICS_SNAPSHOT_WINDOW_DAYS、LOGISTICS_SNAPSHOT_RETENTION。

读路径规则

src/server/repositories/logistics-arrival-signing-duration.repository.ts 的每个读方法都先判断快照是否可用:

  • 取最近一批 COMPLETED 快照;请求区间完全落在快照的实际覆盖范围内才读快照(否则回落实时视图,避免返回不完整结果)。覆盖上限 = min(windowTo - 1, dataThrough 当天):
    • windowTo 是跑批当天,当天还没签收完,所以只能信任到它前一天;
    • 再用批次记录的 dataThrough(该批真实最大签收时间)兜底,避免数据源滞后时把不完整的一天当成完整。
    • 例:一批 10-08 生成、数据只到 10-08 05:30 的快照,对 10-01~10-08 不再命中,看板/导出自动改用实时源,不会少算 10-08。
  • 命中快照时返回值带 source: 'snapshot' 与 snapshotDate,页面头部显示"快照数据 · 日期";未命中为 source: 'live',显示原来的"数据更新 · 时间";
  • 快照表还没迁移或还没有任何批次时,findLatestCompletedRun() 返回 null(并吞掉异常),整条链路自动等价于改造前行为。

因此在以下情况仍会走实时数据源(较慢,但结果正确):请求区间超出窗口、任务当天尚未跑完或失败、本地还没生成过快照。

指标口径(整区间精确 vs 样本)

命中快照时,整块看板都是整区间精确值,与 2,000 条样本彻底解耦:

看板块 精确口径实现
经营总览(6 个 KPI) aggregateDashboard() 一次扫描聚合
履约环节拆解(5 环节平均/中位/P90) 同上,percentile_cont 直接给出分位数
发货→签收时效分布 同上,4 个区间计数
付款→签收 / 付款→发货 多维经营分析(01–08) aggregatePayToSignAnalysis() / aggregatePayToSendAnalysis(),按签收日期 + 采购/供应商/条码(jsonb_array_elements 展开 productItems)+ 物流公司/发货仓/平台/店铺分组
发货仓 / 物流公司 超时排名 aggregateThresholdRankings()
09 订单明细下钻 listDrilldownRows():按维度条件下钻,列出全量匹配订单并给出真实总数与页数

口径一致性由三点保证:筛选条件统一由 snapshotFilterConditions() 派生(Prisma 与原始 SQL 共用);比率/取整统一走 lib 的 buildLogisticsArrivalSigningDurationOverview、buildPayToSignMetrics、buildPayToSendMetrics;排序统一走 comparePayToSignGroups / comparePayToSendGroups / compareLogisticsArrivalSigningDurationThresholdRows。每个聚合块都用"把整区间明细喂给样本路径同样函数"的方式做过逐字段比对(MATCH)。

看板顺序与联动链路

履约环节拆解:目标值与看板宽度

经营总览:平均到货时长卡片与异常下钻

  • 签收订单后面新增 付款→签收平均到货时长 卡片:主值是该环节平均时长,附行给出 本月目标(上月平均 × 98%)与 目标达成率,与履约环节拆解同一套口径。
  • 缺少关键时间 / 时间异常 两张卡片可点击:点击后用 { kind: 'anomaly', value: 'missing' | 'invalid' } 条件下钻,09 订单明细列出对应订单;再点一次取消。判定口径与总览一致——缺少关键时间 = 四个时间任一为空;时间异常 = 四个时间齐全但至少一个环节时长算不出来(时间顺序异常)。
  • 自动滚动只保留给经营总览的异常卡片(卡片上写着"点击看明细"):点它们会把 09 订单明细滚到视口顶部(detailBoardRef + scrollIntoView,按"下钻签名"去重,不会在翻页时反复滚动)。经营对象维度(平台/店铺/发货仓/物流/采购/供应商/商品/签收日期)的点选不再自动滚动——那些看板是连着看的,一滚到底就没法接着看其他维度、还得手动往上拉;下钻筛选本身照常生效,09 明细就在原位置更新。
  • 为保证"卡片数字 = 点击后的明细条数",命中快照时经营总览始终按整区间精确统计(aggregateDashboard(..., includeStages=false),只算总览不算分位数);超出精确上限的大区间里,只有履约环节拆解/时效分布/经营分析/超时排名退回样本,页面脚注会写明。

经营总览视觉层次与看板工具条

  • 经营总览从"7 张卡一张网格"改为两组网格:5 张履约指标卡一排(--sc-kpi-cols,标准 5 / 紧凑 3 / 展开 2),2 张数据质量卡单独成排(--sc-kpi-alert-cols,标准 2 / 展开 1)。7 张卡在常见宽度下不再是 5+2、第二排右侧大片留白。

  • 卡片排版统一:标签 12px 用 --sc-muted,数值 24px 且 font-variant-numeric: tabular-nums,用 margin-top: auto 贴底、副标题保留 2 行最小高度(min-height: 2.9em)。同一行内标签换行不影响数值对齐,卡片等高、数字列对齐。

  • 绿色强调卡(付款→签收平均到货时长、付款→签收履约率)改为"浅绿底 + 左侧 2px 竖条"(inset 2px 0 0),不再整卡大面积着色;点击态的竖条与边框跟着状态色走。

  • 缺关键时间 / 时间异常改为横向"告警条":左侧图标 + 名称/说明,数值贴右,用低饱和暖色表达"异常"语义;点击下钻、is-active 选中态、aria-pressed 与 aria-label(含条数)都保留。

  • 修复看板工具条(导出 / 尺寸切换)样式不生效的根因:BoardTools 是本文件内的独立组件,styled-jsx 按组件注入作用域类,主组件里的 .sc-logistics-icon-button / .sc-logistics-board-tools / .sc-logistics-board-size-label 规则永远匹配不到它的 DOM,四个看板右上角一直是"裸图标"。现在样式放进 BoardTools 自己的 <style jsx>(深色排名表头用 :global(.sc-logistics-ranking-board-header) 适配),并删除主样式块里的死规则。

  • 每个环节多出一列 目标:本月目标 = 上月同环节平均时长 × 98%(LOGISTICS_ARRIVAL_SIGNING_DURATION_TARGET_IMPROVEMENT_RATIO)。基准月取所选区间结束日期所在月的上一个月(跨年正确),基准值由 aggregateStageAverages() 用同一套筛选条件跑上个月区间得到;当前平均不高于目标时目标值标绿表示达标,鼠标悬停可看到"目标 = 上月平均 × 98%"的算式。

  • 目标后面还有 目标达成率:该环节时长不超过目标的订单占比。精确口径由 aggregateStageTargetHits() 按目标值统计命中订单数,样本口径由 buildLogisticsArrivalSigningDurationStageAchievement() 在样本上计算;无目标或无可计算订单时显示 -,鼠标悬停显示"命中 / 可计算"明细。环节行的平均/中位/P90/目标/达成率都只显示数值,单位(小时)只在表头语义里体现。

  • 环节行按 128px / 弹性条 / 74 / 74 / 86 / 92 收紧列宽,列表支持横向滚动,避免 P90、目标被截断。

  • 履约环节拆解与发货→签收时效分布之间加了可拖动的分隔条(role="separator",支持左右方向键微调),比例默认 62% 并记在本地,可随时左右拖动调整两个看板的宽度。

经营对象问题排名各看板按"上级 → 下级"顺序排列,并逐级联动:

01 签收日期维度 → 02 平台维度 → 03 店铺维度 → 04 发货仓维度 → 05 物流维度
→ 06 采购维度 → 07 供应商维度 → 08 商品维度 → 09 订单明细

点击任意一级对象,都会保留更上级条件、丢弃更下级条件,并把这条链路作为筛选条件重新取数:

  • 整区间口径:链路以 cascade(JSON 数组,LogisticsArrivalSigningDurationDrilldownFilter[])随 dashboard 请求下发,服务端在 aggregatePayToSignAnalysis / aggregatePayToSendAnalysis / aggregateThresholdRankings 的 WHERE 上追加 cascadeSqlWhere,因此下级看板与 09 明细都只展示对应数据;
  • 样本口径(实时源或超出精确上限):前端用 logisticsArrivalSigningDurationRowMatchesCascade 收窄样本后重算;
  • 店铺维度分组键是"平台 + 店铺",因此平台级联动能精确到该平台下的店铺;
  • 联动刷新是原位刷新(只有页面筛选变化才显示整块加载态),排名看板标题会显示当前链路与"联动加载中…"提示;
  • 经营总览、履约环节拆解、时效分布属于页级口径,不随联动链路变化。

经营对象的目标与目标达成率

02–08 经营对象表(平台 / 店铺 / 发货仓 / 物流 / 采购 / 供应商 / 商品)在原有指标后面追加 目标 与 目标达成率 两列;01 签收日期维度的「指标 × 日期」矩阵与总计不追加(按日算"上月同日目标"没有业务含义)。

  • 目标 = 该对象上月同环节平均时长 × 98%,达成率 = 100% − (平均 − 目标) / 目标 × 100%,与履约环节拆解同一套口径,只是精确到每个经营对象——所以每个对象的目标值不同(例如 08 月各采购平均不同,9 月目标 52.7 / 56.5 / 60.5 …),达成的判断是"相对自己上个月是否改善 2%"。
  • 数值口径与展示一致:平均、目标都按 1 位小数取值后再算达成率,便于和表里看到的数字对账;没有上月基线的对象(新采购、新店铺、当月新条码)显示 -。
  • 取数:aggregateObjectBaselines()(快照仓库)在上月区间一次扫描算出 7 个维度 × 付款→签收 / 付款→发货 两个环节的平均时长,随 dashboard 载荷以 objectBaselines 下发;前端用 withObjectAnalysisTargets() 按分组键(JSON.stringify(keyParts),与样本路径同一套键)合并,样本口径与整区间口径共用。
  • 这条聚合约 2–4 秒,因此按「快照日期 + 基准月 + 关键词/排除词」在服务端缓存 10 分钟(这些平均值只在每日快照重建时变化),并与看板其余查询并行执行;查询失败只让这两列显示 -,不会把整块看板拖回实时源。
  • 「经营对象问题排名」导出的 CSV 同步带上这两列。

看板分页

页面顶部的「经营管理 / 物流时效 + 看板标题 + 物流签收明细口径 + 明细条数/更新时间」页头已去掉(用户口径:无需展示,标签页标题已经说明是哪个看板);快照/缓存状态在数据源异常时的黄色提示条里仍会给出。

批量关键词查询

筛选区的「关键词」支持批量输入(与排除关键词同一套拆分规则):

  • 按中文逗号 ,、英文逗号 ,、换行拆分,自动去空、去重,最多 100 个、单个最长 120 字符(原始输入上限 4000 字符);
  • 多个关键词之间是"或":命中任一字段即算匹配。整区间(快照)口径覆盖:内部单号、线上订单号、物流单号、物流公司、店铺、平台、发货仓、收货地址,以及快照时点固化的条码、商品名称、供应商、采购(KEYWORD_FIELDS)——所以能直接按采购/供应商/条码检索;
  • 走实时源时(区间超出快照窗口)只覆盖前 8 个报表字段,条码/采购/供应商这三类需要本地快照支持,这是两套口径的已知差异;
  • 解析规则由 splitSearchKeywords() 统一提供,服务端缓存键按关键词集合(排序后)计算,输入顺序不同不会重复缓存。
  • 输入框与其它筛选控件等高(34px 单行高度,多行时框内滚动),"已识别 N 个关键词"显示在标签行右侧并在空间不足时省略,不再把这一格撑成两行高。

筛选区的三个按钮各管一件事,点下去立即按新条件查询(互不重复):

按钮 签收日期 关键词 排除关键词 结果
重置 清空(输入框留空) 清空 清空 没有任何筛选条件;空日期由服务端兜底为本月 1 号~昨日(dateRangeCleared 让输入框保持空白,不被兜底值回填)
恢复默认 回到本月 1 号~昨日(显式填回输入框) 保留 恢复系统默认配置(全部勾选) 默认口径
清空排除 回到本月 1 号~昨日(显式填回输入框) 保留 置空 默认区间 + 不排除任何关键词

三者过去都只是"改输入、等用户点查询"(重置只清日期与关键词、另两个只改排除词),结果看起来一样容易混;现在各自的语义、界面状态与结果都区分开了,按钮 hover 有对应说明,筛选栏底部也有一行图例。空日期提交时服务端仍按默认区间查询(跨天且当日快照未生成时可能落实时源,较慢但结果正确)。

默认签收日期区间是「本月 1 号 ~ 昨日」(由 normalizeLogisticsArrivalSigningDurationFilters() 生成):当日签收数据仍在滚动,且每日快照只覆盖到昨日,用"到昨日"作默认值可以让首屏直接命中快照(整区间精确 + 秒级),不再因为区间含当天而回落到实时源。每月 1 号时会自动退化为"昨日当天"(本月 1 号还没到昨日)。

筛选控件(签收日期、关键词输入框,以及排除关键词选择器的摘要)都是受控并与服务端下发的筛选条件同步:从菜单重新进入(无参数 URL)、重置后查询、浏览器前进/后退时,控件会刷成当前真正生效的条件。这一点很重要——否则会出现"数据已经是默认区间,输入框还停在上次条件"的错觉。

排除关键词选择器

筛选区的「排除关键词」是按字段分组的勾选式选择器(ExcludeKeywordPicker.tsx)。关键词只能配置在它要匹配的那个字段上,不跨字段混用——这是这一版最重要的口径修正。

字段与匹配规则

字段(配置分组) 数据列 匹配方式
发货仓 warehouseName 该字段值包含关键词(不区分大小写),命中即排除
物流公司 logisticCompanyName 同上
订单标签 orderLabels(JSON 数组文本) 同上
买家留言 buyerMessage 同上
卖家备注 remark 同上

配置与筛选都以「字段 → 关键词」存储(LogisticsArrivalSigningDurationExcludeKeywords)。SQL 侧对每个关键词只生成一条 NOT (该字段 ILIKE '%词%');旧版"命中 5 个字段里任意一个就排除"的口径已废弃——那正是「物流」会连带命中订单标签里的 商家指定物流、物流探查 和买家留言里"物流单号"的根因。

选择器交互

  • 按字段分组:发货仓 / 物流公司 / 订单标签 / 买家留言 / 卖家备注,每组标题显示"共 N 项 · 已勾选 M 项",右侧索引条按字段跳转(字段没有候选词时不显示该组);
  • 默认全选:候选词 = 该字段的默认排除词 ∪ 当前已排除词(旧链接里带的自定义词不会丢),进入看板时全部处于"排除"状态;取消勾选 = 本次不排除该项,确定后立即以当前表单条件重新查询(requestSubmit() 在受控值落到 DOM 后再触发);一个都不勾时以 excludeKeywordsMode=clear 提交,避免回落到默认排除词;
  • 选择器内含:搜索、全部 / 已选项(n) 页签、全选 / 反选 / 清空(作用于当前可见列表,全选带半选态)、紧凑模式(四列更密);
  • 新增候选词仅系统管理员可操作:底部先选字段、再输入关键词(回车或点「添加」),新词并入该字段的默认排除词并立即落库(PUT …/settings,服务端 assertCanManage 同样校验管理员身份),提示"已保存到「物流公司」默认排除关键词:…";非管理员只看到"需要新增请联系系统管理员";
  • 页面原先独立的「默认排除关键词」设置卡片已删除,维护入口统一收敛到选择器;清空排除(不排除任何词)与 恢复默认(回到全部默认排除词)两个按钮保留,只改输入、点查询生效。

编码与兼容

  • 筛选栏摘要按字段给数量(如"已排除 14 项:发货仓 3 · 物流公司 5 · 订单标签 2 · 买家留言 2 · 卖家备注 2"),URL 用 excludeKeywords=字段:词,词;字段:词 的紧凑写法;
  • 老链接的平铺写法按当前配置归位:excludeKeywords=词1,词2 会被逐个对应到配置里它所属的字段(大小写不敏感,bbff 会归一到配置里的 BBFF),配置里已经没有的词说明它没有字段、直接忽略——不会再把一个词摊到 5 个字段上(那会让选择器每组都列出全部关键词)。只有当配置为空、任何词都对应不上时才退回旧口径,避免老链接突然变成"不排除任何词"。
  • 历史配置(数组)在 normalizeExcludeKeywords() 里仍按旧口径挂到全部 5 个字段(那是当年真实的匹配语义);但配置一旦以新结构保存,后续解析都会走"按字段归位"。

当前初设配置(2026-09-30 落库):发货仓=自动发货仓 / 常州简尚家居有限公司 / 京东云仓;物流公司=京喜 / 物流 / 安能 / 自提 / 中铁;订单标签=配件 / 1688一件代发;买家留言=BBFF / SSDD;卖家备注=BBFF / SSDD。2026-09-01~09-02 实测:不排除 26,296 行 → 按字段排除后 17,506 行(旧口径同样 14 个词是 16,552 行,多出的 954 行正是"标签里带物流、物流公司里没有"的那些)。

经营对象问题排名里的每个看板都有独立翻页,不再用"显示更多"或无限滚动,也不会因为对象太多被截断:

  • 01 签收日期维度:按日期列翻页(默认 10 列/页,可切 10/20/50/100);
  • 02–08 采购/供应商/条码/物流公司/发货仓/平台/店铺:按对象行翻页(默认 20 行/页);
  • 发货仓 / 物流公司 超时排名:按对象行翻页(默认 20 行/页)。
  • 09 订单明细:翻页控件就在看板内部(不再挂在页面底部);未下钻时按 URL 的 page/pageSize 走服务端分页(每页 25/50/100/200,命中快照时显示真实总数"共 N 条筛选结果"),下钻时用本地分页翻全量匹配明细。

分页条统一由 BoardPagination.tsx 渲染(含"共 N 条 · 第 x / y 页"、上/下一页与每页条数),行集合变化(例如切换采购后重新筛选供应商)时自动回到第 1 页。

每页条数切换的坑(已修):下拉框原先在 onChange 里同时调了 onPageSizeChange(新值) 和 onPageChange(1)。服务端分页(09 订单明细未下钻时)这两次都会触发 router.push,而后一次带着旧的 pageSize 构造 URL 并覆盖前一次,表现为"订单明细每页条数选了没反应、又跳回 50"。现在 BoardPagination 只通知"每页条数变了",回到第 1 页由调用方负责:服务端分页用 href(1, { ...filters, pageSize }),本地分页(各经营对象表、发货仓/物流公司排名、下钻后的 09 明细)在 setState 里带 page: 1。

店铺维度表在"店铺"列前额外展示"平台"列:点击平台即把该表筛成该平台的店铺(标题变成"店铺维度 · 天猫",再次点击取消),点击店铺名仍走 09 明细下钻。店铺分组的业务键是"平台 + 店铺",避免同名店铺跨平台互相覆盖;平台列在样本路径(JS 归组)与整区间路径(SQL GROUPING SETS ((platform, shop)))都做了同样的处理,并逐字段比对过一致性。

规模上限与样本回落

SQL 聚合成本与区间行数近似线性(约 5 万行/秒)。当**区间原始行数超过 LOGISTICS_ARRIVAL_SIGNING_DURATION_EXACT_SCOPE_MAX_ROWS(11 万条,约 11 天)**时,getDashboard 会退回区间内均匀抽取的 2,000 条样本(listDashboardSampleRows,5 层并行、按签收时间分层,不再是"取最近一段"),并在底部注释写明原因与"缩小签收日期范围即可得到精确值"。

返回值上的 dataScope(exact / sample)与 dataScopeReason(live-source / range-too-large)驱动卡片副标题、底部注释与 CSV 导出的口径说明。

实测(663k 行快照,含 13 个默认排除关键词):

区间 原始行数 口径 首屏耗时
1 天 约 1.3 万 整区间精确 0.55s
2 天 约 2.6 万 整区间精确 1.7s
5 天 约 6.5 万 整区间精确 3.1s
本月 约 30 万 样本(超上限) 3.8–5.0s

服务端 60s 内重复访问直接命中缓存。要把大区间也做成亚秒精确,方向是在快照写入时就把 4 个时间戳、5 个环节时长落成列,并把多维指标的计数改为位掩码 GROUP BY(一次扫描产出全部桶计数),现有文档结论不变、只替换实现。

查询性能优化(2026-10)

上线后"每次查询生成太慢"的根因有三个,都在快照读路径上:

  1. 整表顺序扫描:聚合查询原先 SELECT 全列(rowKey、productItems、十几个文本列), Postgres 只能对 900MB 的堆表做 Seq Scan(28 天区间实测 read=106409 页、6.3 秒)。 现在按用途收窄列集合——FILTERED_TIMELINE_SELECT(只取 4 个时间戳)用于整区间总览/环节目标, FILTERED_DIMENSION_SELECT(时间戳 + 4 个维度列)用于签收日期/平台/店铺/发货仓/物流/超时排名—— 并新增覆盖索引 LogisticsArrivalSigningDurationSnapshot_cover_idx ((snapshotDate, signTime) INCLUDE (payTime, sendTime, fetchTime, warehouseName, platformName, shopName, logisticCompanyName)), 扫描变成 Index Only Scan(同样的 28 天区间 26ms、Heap Fetches: 0)。
  2. 有序集聚合把整行拖进排序:percentile_cont(0.5/0.9) WITHIN GROUP 原先和十几个计数写在同一条语句里, 输入行 14 列宽,PG 18 会把它整行送进排序(两个分位数单独就占 4.3 秒,环节分位数叠在一起甚至到分钟级)。 现在分位数按列拆成独立语句、只投影一个浮点列,并与计数语句并行下发(Promise.all)。
  3. CTE 表达式被重复内联:measured/durations 的 CASE + EXTRACT 会在每一处引用被重新计算, 同一条聚合里重复十几次。改为 AS MATERIALIZED 后只算一次(计数语句从 2.3s 降到 ~0.5s)。

另外把 环节目标(上月平均 × 98%) 也接入 10 分钟内存缓存(此前每次刷新都重跑上月聚合)。

优化前后(本机 Postgres 18、快照 648,689 行、冷启动、无缓存):

场景 优化前 优化后
仓库层 getDashboard(28 天,样本口径) 7.3s 2.3s
仓库层 getDashboard(10 天) 4.4s 0.4s
仓库层 getDashboard(2 天,精确口径) 1.7s 0.3s
浏览器首屏出数(冷启动,含 5.6MB 载荷 + 渲染) ~7–10s 2.7s

正确性用 2 天区间做过逐项对账:SQL 聚合与"取全量明细在 JS 里现算"在订单数、平均/中位/P90、 异常计数、达成率、时效分布、5 个环节的 可计算单量/平均/中位/P90 上完全一致。

时间戳在 SQL 里用 NULLIF(BTRIM(col),'')::timestamp 直接转型:快照写入的就是远端 CONVERT(varchar(19), …, 120) 结果,格式固定;早期版本用正则逐行校验,整区间聚合约慢一个数量级。

运维排查

-- 批次状态
SELECT "snapshotDate", status, "windowFrom", "windowTo", "rowCount", message, "finishedAt"
FROM "LogisticsArrivalSigningDurationSnapshotRun" ORDER BY "snapshotDate" DESC;

-- 当天行数
SELECT count(*) FROM "LogisticsArrivalSigningDurationSnapshot" WHERE "snapshotDate" = CURRENT_DATE;
  • 页面长时间显示"数据更新"而不是"快照数据" → 先看批次是不是 FAILED,或窗口是否覆盖了页面的签收日期区间。
  • 批次 RUNNING 卡住(进程被杀)→ 该批次不会发布;直接重跑或加 --force 即可(重跑会先清空当天行)。
  • 需要重建历史窗口 → --window-days= 调大 + --force。

部署步骤(生产)

  1. 部署新版本代码;
  2. 执行迁移 prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot(需按生产迁移流程授权、先备份);
  3. 跑一次手工同步生成第一批快照(--apply),确认批次为 COMPLETED、行数与窗口符合预期;
  4. 安装 timer 并核对 systemctl list-timers;
  5. 打开看板确认头部显示"快照数据 · 日期"。

注意:Prisma Client 重新生成过,运行中的 dev/prod 进程需要重启才能拿到新模型。