diff --git a/docs/mileage-query-cache-2026-10-27.md b/docs/mileage-query-cache-2026-10-27.md new file mode 100644 index 0000000..3b5623d --- /dev/null +++ b/docs/mileage-query-cache-2026-10-27.md @@ -0,0 +1,56 @@ +# 里程监控结果复用(2026-10-27) + +本文为第二阶段历史记录。当前主列表已改用上游区间汇总;最新行为、权限趋势处理及实测见 [区间汇总接入与验证](mileage-summary-integration-2026-10-27.md)。下文的明细缓存和首次耗时描述不代表当前实现。 + +## 行为 + +同一服务进程内,按规范化起止日期和有序协议优先级缓存 OneOS 区间明细、结束日数据及对比日数据。有效期为成功获取后 5 分钟,最多保留 4 组,新增超限时淘汰最早写入项;相同在途查询合并。只在成功完成后写入缓存,失败可重试。 + +分页、排序、筛选、全屏和导出复用这组上游数据。车辆归属及考核关系每次从数据库读取,然后重新合并并按当前用户权限过滤。用户身份不需要进入原始里程缓存键;授权结果绝不跨用户缓存。车辆改派经理、变更部门后,下一次请求读取最新归属。 + +区间日趋势始终根据已授权且符合筛选条件的车辆重新计算,修正此前权限过滤后仍可能使用全量日趋势合计的问题。 + +点击「查询」可命中缓存;点击普通视图或全屏的刷新按钮发送 `refresh=1`,跳过已完成缓存,包括历史单日缓存。已有相同在途请求仍可共享。刷新失败时不回退旧的区间缓存。日期和搜索仍需确认后请求。 + +## 接口可观察性 + +`GET /api/mileage/monitoring?startDate=...&endDate=...`: + +- 响应头 `X-Mileage-Cache`:`miss` / `hit` / `shared` / `refresh`。 +- 响应字段 `queriedAt`:这组 OneOS 数据成功获取的时间。缓存命中时保持不变;前端使用它展示获取时间,避免将缓存读取误显示为重新采集。 +- `refresh=1`:显式刷新。普通分页、排序和导出不附带该参数。 + +旧的 `date=` 单日兼容分支和不带日期的后台缓存分支保持原有机制;当前监控前端使用起止日期参数,走本次新增的复用路径。 + +## 限制与取舍 + +这是 5 分钟的进程内数据复用,不是持久化会话快照。过期、淘汰、进程重启或请求落到另一实例时会重新取数;跨过这些边界时数据可能变化,不能保证整场会话固定在同一快照。前端可随时主动刷新。没有新增 Redis、数据库表或后台清理任务。 + +缓存仅减少 OneOS 的重复拉取。数据库读取、权限过滤、汇总和排序仍按请求执行,保证归属及时变化;首次长区间仍受上游串行分页限制。导出接口耗时不包含浏览器生成 XLSX 文件的时间。 + +## 本地真实接口实测 + +条件:2026-10-01 至 2026-10-26,仪表来源,765 辆 BI 车辆;同一进程连续请求,包含 HTTP 传输和 JSON 读取。 + +| 操作 | 耗时 | 缓存状态 | +| --- | ---: | --- | +| 首次查询 50 条 | 10,723 ms | miss | +| 第 2 页 | 92 ms | hit | +| 改为升序 | 48 ms | hit | +| 导出接口返回全部 765 条 | 64 ms | hit | +| 主动刷新 | 9,869 ms | refresh | +| 刷新后再次导出 | 96 ms | hit | + +前 4 次响应使用相同的 `queriedAt`;主动刷新更新该时间,再次导出复用新的结果。以上为单次样本,不是 P95。命中路径仍包含最新车辆信息 SQL 和重新聚合;首次/主动刷新仍受上游耗时影响。 + +## 验证 + +TypeScript 检查、30 项里程相关测试及生产构建通过;构建仍有已有的大资源包提示。 + +- 查询缓存单测覆盖命中、5 分钟过期、容量淘汰、来源顺序隔离、在途合并、刷新和失败重试。 +- 路由集成测试以模拟 OneOS/SQL 验证:首页拉取 3 次上游数据,后续分页、排序、导出、筛选及权限变更均不新增上游请求;刷新再新增 3 次。归属变更后权限立即按新的数据库结果生效。 +- 验证部门/个人范围的列表、筛选车牌、日趋势及空权限结果;缓存不会被某一用户的筛选污染。 +- 浏览器模拟测试覆盖桌面 1440px 和手机 390px,各 15 项交互检查通过,包括刷新传参、刷新后恢复复用、未确认条件不请求。 +- 实测脚本与结果保存在工作区上级 `outputs/mileage-query-cache-20261027/`。 + +上游首次查询建议见 [OneOS 性能优化反馈](oneos-mileage-performance-feedback-2026-10-27.md)。 diff --git a/docs/mileage-query-performance-2026-10-27.md b/docs/mileage-query-performance-2026-10-27.md new file mode 100644 index 0000000..4b5ed69 --- /dev/null +++ b/docs/mileage-query-performance-2026-10-27.md @@ -0,0 +1,56 @@ +# 里程监控查询交互与性能分析(2026-10-27) + +当前已完成第三阶段 [区间汇总接入与验证](mileage-summary-integration-2026-10-27.md),首次查询不再拉取全量逐日明细。 + +本文保留第一阶段诊断与修改记录。后续已实现 OneOS 查询复用,见 [第二阶段实现与验证](mileage-query-cache-2026-10-27.md);可转交上游的独立建议见 [OneOS 性能优化反馈](oneos-mileage-performance-feedback-2026-10-27.md)。 + +## 本次修改 + +实时监控页将编辑中的筛选条件与已提交条件分开。日期输入、快捷日期、搜索选择、筛选标签清理、重置与排序修改只更新草稿,点击「查询」才提交。首次进入仍加载默认日期;主动刷新与确认后的滚动分页继续保留。移除页面每分钟自动刷新,避免编辑过程中产生后台查询。 + +高级筛选和全屏均提供查询按钮。草稿未提交时禁用刷新和导出,并暂停加载更多,避免使用未确认条件。日期不完整或起止倒置时不可提交。接口失败显示重试提示;数据统计使用返回结果的日期范围。保留原有权限过滤、数据来源规则及全屏参数差异。 + +范围:本次修改实时监控页;统计报表、每日汇报和单车详情的日期交互未改动。后端数据缓存、上游分页及实时性策略未修改。 + +## 测量方式 + +在本地开发环境中,使用已有配置调用 `queryRangeMileage`,仅执行数据读取。临时探针包裹 `fetch`(统计至 JSON 读取完毕)及数据库 `execute`,不记录凭据和车辆明细。来源为仪表数据,测量日期均为已结束的上海自然日。 + +这些是单次样本,不代表 P95 或高并发表现;总耗时为服务端查询函数耗时,包含网络、解析、数据库及合并计算,不包含浏览器渲染。同一进程重复查询可复用已有单日缓存。 + +| 区间 | 总耗时 | 区间上游记录数 | 区间分页请求数 | +| --- | ---: | ---: | ---: | +| 10 月 26 日 | 1,644 ms | 1,024 | 1 | +| 10 月 20–26 日,首次 | 3,100 ms | 7,168 | 2 | +| 10 月 20–26 日,重复 | 2,994 ms | 7,168 | 2 | +| 10 月 1–26 日,首次 | 11,123 ms | 26,624 | 6 | +| 10 月 1–26 日,重复 | 9,510 ms | 26,624 | 6 | + +单日样本同时另发结束日和前一日的两个单日请求,各约 1.6 秒。26 天首次查询的 6 个游标分页分别约 2,283 / 2,114 / 1,775 / 1,762 / 1,682 / 1,466 ms,串行耗时合计约 11.08 秒,几乎占满总耗时。 + +车辆关联 SQL 返回 765 行,耗时 58–168 ms;考核目标关联 SQL 返回 512 行,耗时 25–125 ms。它们与上游查询并行执行。本次样本不支持把主要瓶颈归因于数据库或浏览器渲染。 + +## 原因与代码依据 + +1. **每次改条件就发请求。** `MonitoringView.tsx` 原本直接以全部输入状态构建 `monitoringQuery`,依赖它的 `loadFirstPage` 改变后由 Effect 自动调用;另有 60 秒轮询。本次已经改为显式提交。 +2. **页面分页发生在全量查询之后。** `src/server/routes/mileage/monitoring.ts` 先调用 `queryRangeMileage`,然后执行权限过滤、业务筛选、排序和 `slice`。请求第 2 页或只选一辆车仍先执行全量区间查询。 +3. **区间查询没有结果缓存。** `src/server/routes/mileage/oneos-api.ts` 中的 `fetchOneOsMileageDates` 只有 `rangeInflight`,完成后删除;单日查询的历史 6 小时缓存不覆盖此路径。重复查询同一个月仍发 6 次分页请求,与实测吻合。 +4. **游标必须串行。** `requestRange` 每页 5,000 条,拿到 `nextCursor` 才能发下一页。不能直接并行发后续页。前端固定传 `startDate/endDate`,即使只选一天也会走区间接口,再额外取结束日单日数据。 +5. **重试可能进一步拉长等待。** 本地配置每次上游调用超时 20 秒,最多尝试 3 次,中间等待 1 秒、2 秒。某一页连续超时即可约 63 秒;这是代码推导,实测样本没有出现超时。接口没有整个区间请求的总时限。 +6. **开发环境首次请求有两次。** `src/main.tsx` 启用 React StrictMode,浏览器测试观察到首次挂载两次请求;用户点击查询只产生一次请求。上游相同在途请求可合并,但数据库工作仍重复。这与生产环境行为需区分,不能为消除开发重复而移除 StrictMode。 + +## 后续优化顺序 + +1. **优先让分页、排序、导出复用一次已完成查询的结果。** 采用明确的查询快照或按日期范围与数据来源缓存原始结果,再按当前用户权限过滤。需要定义刷新失效方式及允许的数据延迟;不能缓存某个用户过滤后的结果再跨用户共享。这样能去掉重复查询的主要等待,但不会缩短首次上游查询。 +2. **对单日范围复用单日数据。** 避免区间与结束日两个接口重复查询同一天,验证缺失值、累计里程、来源优先级和前一日统计口径后再替换。 +3. **减少首次查询传输量。** 上游若提供区间聚合结果,可直接获取每车汇总及每日总量;明确车牌查询可考虑传入车牌限制,但必须保留现有权限检查和筛选候选项语义。若继续只提供明细游标分页,长区间首次查询仍受串行页数限制。 + +未通过缩短超时、隐藏等待动画或并发重试来声称已解决单次查询耗时。 + +## 验证 + +- TypeScript 检查通过。 +- 监控查询模型、日期范围及里程显示相关 12 项测试通过。 +- Vite 生产构建通过,保留已有的大资源包提示。 +- 浏览器真实接口检查:修改两个日期没有监控请求,点击查询后发送一条包含两个最终日期的请求。 +- 浏览器自动化使用模拟数据验证 1440px 和 390px 两种宽度,各 11 项交互检查通过,均无页面运行时错误;运行脚本与结果位于工作区上级 `outputs/mileage-query-20261027/`。模拟测试用于验证请求触发条件,不用于性能结论。 diff --git a/docs/mileage-summary-integration-2026-10-27.md b/docs/mileage-summary-integration-2026-10-27.md new file mode 100644 index 0000000..e06d8be --- /dev/null +++ b/docs/mileage-summary-integration-2026-10-27.md @@ -0,0 +1,59 @@ +# 区间里程汇总接入与验证(2026-10-27) + +本地里程监控页已接入 `POST /api/v1/vehicles/mileage/summary/query`。依据用户提供的《BI 区间里程汇总接口对接说明》及 [OpenAPI 契约](https://open.d.lnoneos.com/open-api/openapi.yaml);沿用现有服务地址和 appKey,不新增凭据配置。 + +## 查询与展示 + +- 首次进入加载默认日期;修改日期、来源及业务筛选后点击「查询」才请求。保留手动刷新,取消定时轮询。 +- 主列表每辆车使用一条区间汇总,不再先拉全部逐日明细。分页、排序、全屏及区间汇总导出复用服务器结果。 +- 默认仪表来源仍为有序的 `GB32960, MQTT`;不因文档示例包含 `JT808` 而扩大来源。保留现有来源选择、366 天 BI 区间限制和上海自然日口径。 +- 区间里程取 `periodMileageKm`,只合计有效日;真实零显示 0,无有效数据显示横杠。异常车辆保留其余有效日期的合计,并展示有效、缺失、异常天数及原因提示。 +- 结束日累计里程取 `endDateTotalMileageKm`,独立于区间日里程质量。无读数时不回填历史累计。采集时间、源更新时间与查询时间保持区分。 +- `CARRIED_FORWARD` 明细计为缺失,不冒充有效零。日趋势的 `null` 保留缺口。 +- 单车详情按当前区间和来源按需取明细。导出菜单分为「导出区间汇总」和「导出逐日明细」;前者复用汇总,后者明确拉取所选车辆逐日数据,仍可能较慢。 + +## 权限与缓存 + +每次 BI 请求重新读取车辆归属和考核关系,再执行现有部门、个人等权限及业务筛选、客户名称脱敏。上游原始结果可复用,用户授权结果不跨用户缓存。单车详情也校验当前 BI 车辆权限。 + +上游全量授权车辆与 BI 当前可见车辆并不相同,因此不能直接把全量趋势展示给用户。当筛选范围缩小时,使用已授权且可见的车牌集合请求范围内趋势;空集合直接返回无数据趋势,不向上游传空数组(空数组在契约中代表全部)。分页、排序、全屏和导出使用 `includeDailyTrend=0`,无需重复获取趋势。 + +缓存键包含起止日期、有序来源及规范化车牌范围;同一在途请求合并,成功结果最多缓存 5 分钟且不超过上游快照到期时间,最多 4 项。显式刷新绕过已完成缓存;失败不回退成空数据或成功零值。401、403、429、504 等上游错误转为明确提示,保留错误码、traceId 和适用的 Retry-After。 + +缓存为进程内机制,过期、淘汰、重启或跨实例都会重新请求。BI 归属变化立即按最新数据库结果过滤;上游授权变化需要下次上游请求才能获知。列表与按权限重查的趋势可能来自两个生成时点,当前日或历史补报期间不保证原子一致;上游首次汇总本身也不是数据库原子快照。 + +## 真实数据对账 + +条件:2026-10-01 至 2026-10-26,仪表来源。仅执行读取,不修改上游或业务数据库。 + +- 旧明细接口:6 页、26,624 条记录;新汇总:1,024 辆、26 天趋势。 +- 逐车对比有效里程合计、有效/缺失/异常天数:差异 0。 +- 旧明细中 6,910 条 `NORMAL/CARRIED_FORWARD` 按契约计为缺失,真实正常记录 11,150 条,无数据 8,559 条,异常 5 条。 +- BI 列表 765 辆,其中 761 辆匹配上游汇总,4 辆不在返回集合内,保持无可用里程。匹配车辆的区间里程、质量天数、结束日累计里程:差异 0。 +- 按 BI 车辆集合重新计算的 26 天趋势与返回趋势逐日对比:差异 0。 + +## 性能实测 + +同一本地开发环境、同一日期和来源;单次样本,非 P95。HTTP 指标包含响应及 JSON 读取,不包含浏览器绘图或 XLSX 生成。 + +| 路径 | 耗时 | +| --- | ---: | +| 旧上游逐日明细,完整 6 页 | 10,250 ms | +| 新上游区间汇总,单次请求 | 1,869 ms | +| 新 BI 首次完整查询,缓存 miss | 3,862 ms | +| 第 2 页,缓存 hit | 68 ms | +| 升序查询,缓存 hit | 35 ms | +| 区间汇总导出数据,765 辆,缓存 hit | 39 ms | +| 主动刷新 | 3,572 ms | +| 刷新后再次导出数据,缓存 hit | 67 ms | + +首次 BI 请求仍包含全量汇总、原有对比日读数、数据库查询,以及 BI 范围的趋势汇总,因此不能把上游单次 1.9 秒宣称为完整页面耗时。此前旧 BI 冷查询实测 10,723 ms,本次 3,862 ms,单样本约缩短 64%。 + +## 验证记录 + +- 38 项里程相关测试通过:覆盖契约解析、快照分页、真实零与缺失、部分异常有效合计、缓存与过期、部门/个人/空权限、归属变化、错误响应和按需明细。 +- `npm run lint` 和 `npm run build` 通过;构建仍提示已有的大资源包。 +- 真实浏览器 1440px / 390px:日期编辑不请求、确认后查询、765 辆和 26 天趋势、质量天数、手机查询按钮可见,均通过,无页面脚本异常。 +- 实际下载并解析区间汇总 XLSX:1 张表、765 辆、质量列齐全;单车逐日 XLSX:2 张表、26 个日期列。单车详情请求日期与来源与列表一致。 + +本机验证脚本、脱离 Git 的原始结果及截图位于工作区父目录 `outputs/mileage-summary-20261027/`:`timings.json`、`reconciliation.json`、`browser-results.json`。原始响应含业务数据,不应直接发布到公共仓库或转发。 diff --git a/docs/oneos-mileage-performance-feedback-2026-10-27.md b/docs/oneos-mileage-performance-feedback-2026-10-27.md new file mode 100644 index 0000000..2f9dcf1 --- /dev/null +++ b/docs/oneos-mileage-performance-feedback-2026-10-27.md @@ -0,0 +1,103 @@ +# OneOS 里程接口性能优化反馈 + +后续进展:上游已提供区间汇总接口,本地 BI 已接入并完成对账,见 [接入与验证记录](mileage-summary-integration-2026-10-27.md)。下文保留最初反馈背景。 + +日期:2026-10-27。本文供 BI 与 OneOS 团队对接,建议项尚未代表上游已实现或已承诺。 + +## 当前问题与业务影响 + +BI 查询一段日期的车辆里程时,先获取全部车辆逐日明细,完成后才能汇总、排序及展示首页。长区间查询的主要等待发生在 OneOS 游标分页:首次进入或主动刷新一个月的数据仍需约 10 秒。 + +相关现有接口: + +- `POST /api/v1/vehicles/mileage/range/query`:日期范围、可选车牌、来源优先级、游标分页。 +- `POST /api/v1/vehicles/mileage/query`:单日里程及累计里程。 + +## 可复现样本 + +请求条件如下,不包含任何访问凭据: + +```json +{ + "startDate": "2026-10-01", + "endDate": "2026-10-26", + "pageSize": 5000, + "protocolPriority": ["GB32960", "MQTT"] +} +``` + +后续请求携带上一页返回的 `nextCursor` 作为 `cursor`;BI 检查返回 `snapshotId` 的一致性。 + +在当前应用授权范围内,共返回 26,624 条逐日记录,需 6 次串行分页请求。2026-10-27 的一组测量结果如下(客户端计时至 JSON 读取完毕): + +| 页 | 返回条数 | 耗时 | +| --- | ---: | ---: | +| 1 | 5,000 | 2,283 ms | +| 2 | 5,000 | 2,114 ms | +| 3 | 5,000 | 1,775 ms | +| 4 | 5,000 | 1,762 ms | +| 5 | 5,000 | 1,682 ms | +| 6 | 1,624 | 1,466 ms | + +区间分页合计约 11.08 秒,BI 查询函数整体约 11.12 秒。同一进程重复查询约 9.51 秒,仍需请求 6 页。另一组 7 天查询约 3.10 秒,重复约 2.99 秒。 + +车辆归属 SQL 约 58–168 ms,与上游调用并行;合并后 BI 范围为 765 辆车,上游返回范围大于 BI 的有效车辆集合。 + +以上是单次本地实测,包含网络与 JSON 处理,不是服务端执行时间或 P95。尚无 OneOS 内部 trace,不能据此断言某条 SQL 缺索引或每个游标页都重复计算。 + +## BI 已做的优化 + +- 日期、筛选和搜索修改后,点击查询才发请求。 +- 相同日期范围与来源优先级的 OneOS 结果在本地服务进程中复用 5 分钟,最多保留 4 组。 +- 分页、排序、筛选和导出复用 OneOS 数据;主动刷新绕过该缓存,并重新获取相关单日数据。 +- 车辆归属与考核关系仍每次读取,权限检查、筛选和汇总仍按当前用户执行,不缓存用户权限结果。 + +这解决重复查询,不能消除首次长区间查询的 6 次串行等待。上游优化的目标是首次查询、主动刷新及跨应用复用,而不是让 BI 延长缓存来掩盖延迟。 + +## 建议优先级 + +### P0:诊断每页耗时,并复用已生成快照 + +请提供一次上述查询的 `traceId` 与各页阶段耗时,至少区分:数据库读取、里程计算/协议选择、快照生成或读取、序列化、排队等待。 + +请确认: + +1. 后续游标页是否只读取已有快照,还是重新扫描、计算整个区间?若存在重复计算,优先改为计算一次后分页读取。 +2. 相同应用授权范围、日期范围、协议优先级和车牌集合,能否在短期内复用已完成快照? +3. 当前 `snapshotId` 的有效期及数据更新时间是什么?能否通过新增参数重用指定快照,而不是重新创建? + +快照复用必须包含应用权限范围和有序协议优先级,车牌集合需规范化;不能仅按起止日期跨应用共享授权后的结果。需要返回有效期/数据时间,明确实时日更新、历史补数与主动刷新的失效规则。 + +### P1:提供按车辆的区间聚合,减少传输与调用次数 + +建议新增“区间汇总”能力(接口名称由 OneOS 确定,非现有协议):每辆车返回一条汇总,而非默认返回每辆车每天一条完整记录。BI 列表所需字段包括: + +| 字段能力 | 用途及口径 | +| --- | --- | +| 车牌、VIN | 与 BI 车辆信息关联 | +| 区间有效日里程合计 | 不能用末日累计里程减首日累计里程替代 | +| 有效天数、缺失天数、异常状态 | 区分真实零里程和整段无数据 | +| 结束日累计里程 | 当前 BI 为它额外调用单日接口 | +| 区间开始日前一天的日里程 | 当前对比口径,若可一并提供可再减少一次调用 | +| 实际来源/混合来源、数据时间 | 保持来源优先级与数据质量可解释性 | +| 快照标识、计算/生成时间 | 后续查询与数据时间展示 | + +这能将主列表传输量从约“车辆数 × 天数”降至“车辆数”。日报趋势/单车逐日明细按需读取,或通过字段投影仅返回必要的逐日值。 + +BI 的每日趋势必须遵循当前筛选与权限范围:不能直接采用全应用车辆的日合计。建议日趋势聚合支持同一快照和明确车牌集合,或在汇总结果中可选返回每车的精简逐日值,再由 BI 汇总。 + +### P2:减少单页耗时并提供稳定的调用预算 + +- 基于 trace 和实际执行计划,优化日期范围、车牌集合、来源优先级条件的读取路径;目前数据不足以指定具体索引。 +- 核实响应压缩、连接复用及明细字段投影是否生效。调大 `pageSize` 可作为对照实验,但应同时评估响应大小、内存与单页超时;不能只靠加大页数掩盖重复计算。 +- 目前 BI 每页超时 20 秒、最多尝试 3 次,退避 1 秒与 2 秒。单页连续超时可导致约 63 秒等待,这是配置推导,非本次实测异常。建议约定整个查询的期限、可重试错误码和可恢复快照,避免失效后从第一页重做。 + +## 建议验收方式 + +双方使用相同应用权限、同一车牌集合及协议优先级,对 1 / 7 / 31 天、冷查询与快照复用分别测量。记录首屏耗时、完整结果耗时、请求数、响应字节数和上游阶段耗时;有足够重复样本后再报告 P50/P95。 + +可讨论的目标:约 1,000 辆 × 31 天汇总首次完整返回在 3 秒内,快照复用在 300 ms 内。以上是待双方评估确认的目标,不是当前测量结果或已承诺 SLA。 + +结果正确性需与现有接口逐车核对:来源优先级、缺失值与零值、异常里程、累计里程、上海自然日起止边界、跨月区间及权限范围。缓存命中不能改变这些口径。 + +请上游反馈:阶段 trace、快照当前实现与复用能力、聚合接口可行性,以及可交付的第一阶段范围与排期。 diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index aee70fd..9ec2574 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -48,7 +48,6 @@ export default function MonitoringView() { const [isFullscreen, setIsFullscreen] = useState(false); const [fullscreenVehicles, setFullscreenVehicles] = useState([]); const [fullscreenStats, setFullscreenStats] = useState({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); - const [fullscreenRefresh, setFullscreenRefresh] = useState(0); const [fullscreenLoading, setFullscreenLoading] = useState(false); // New filters from image @@ -62,7 +61,6 @@ export default function MonitoringView() { const [filterRegion, setFilterRegion] = useState('All'); const [filterBrands, setFilterBrands] = useState([]); const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); - const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [exporting, setExporting] = useState(false); const [detailVehicle, setDetailVehicle] = useState(null); const [rangeStart, setRangeStart] = useState(defaultMileageDate); @@ -72,7 +70,7 @@ export default function MonitoringView() { const [vehicles, setVehicles] = useState([]); const [stats, setStats] = useState({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); const [filterOptions, setFilterOptions] = useState({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] }); - const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]); + const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number | null }[]>([]); const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() })); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -85,7 +83,7 @@ export default function MonitoringView() { const [relativeNow, setRelativeNow] = useState(() => Date.now()); const PAGE_SIZE = 50; - const monitoringQuery = useMemo(() => ({ + const draftQuery = useMemo(() => ({ sortBy, sortOrder, searchTerm, @@ -98,35 +96,24 @@ export default function MonitoringView() { targetNames: filterTargetNames, region: filterRegion, plates: filterPlates, - mileageRange: appliedMileageRange, + mileageRange: filterMileageRange, rangeStart, rangeEnd, brands: filterBrands, sourcePriority, - }), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]); + }), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, filterMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]); - // Keep this model and dependency list separate: fullscreen historically omits - // project, entity and mileage-range, so adding those states would change both - // its request parameters and refresh triggers. - const fullscreenQuery = useMemo(() => ({ - sortBy, - sortOrder, - searchTerm, - department: filterDept, - customer: filterCustomer, - project: 'All', - entity: 'All', - rentStatus: filterRentStatus, - platePrefix: filterPlatePrefix, - targetNames: filterTargetNames, - region: filterRegion, - plates: filterPlates, - mileageRange: { min: '', max: '' }, - rangeStart, - rangeEnd, - brands: filterBrands, - sourcePriority, - }), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, filterBrands, sourcePriority]); + const [monitoringQuery, setMonitoringQuery] = useState(draftQuery); + const [queryError, setQueryError] = useState(''); + const hasPendingChanges = JSON.stringify(draftQuery) !== JSON.stringify(monitoringQuery); + const queryDisabled = pageLoading || manualRefreshing || fullscreenLoading || !rangeStart || !rangeEnd || rangeStart > rangeEnd; + + const handleQuery = () => { + if (queryDisabled) return; + setQueryError(''); + setMonitoringQuery({ ...draftQuery }); + setIsFilterOpen(false); + }; useEffect(() => { const timer = setInterval(() => setRelativeNow(Date.now()), 5000); @@ -145,45 +132,48 @@ export default function MonitoringView() { const isHighMileageAlert = useCallback((v: MonitoringVehicle) => { const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name)) - || filterTargetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name)); + || monitoringQuery.targetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name)); return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM; - }, [filterTargetNames]); + }, [monitoringQuery.targetNames]); const queryRequestRef = useRef(0); // 加载首页数据 - const loadFirstPage = useCallback((showPageLoading = true) => { + const loadFirstPage = useCallback((showPageLoading = true, refresh = false) => { const requestId = ++queryRequestRef.current; + setQueryError(''); if (showPageLoading) setPageLoading(true); - return fetchMonitoring(buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE)).then(d => { + return fetchMonitoring({ ...buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE), refresh }).then(d => { if (requestId !== queryRequestRef.current) return; setVehicles(d.vehicles); setStats(d.stats); setFilterOptions(d.filters); setRangeDailyTotals(d.rangeDailyTotals || []); - setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd }); + setEffectiveRange(d.dateRange || { start: monitoringQuery.rangeStart, end: monitoringQuery.rangeEnd }); setTotal(d.total); setPage(1); setHasMore(d.page < d.totalPages); - setLastRefreshedAt(new Date()); - }).catch(() => {}).finally(() => { + setLastRefreshedAt(d.queriedAt ? new Date(d.queriedAt) : new Date()); + }).catch(error => { + if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '里程查询失败,请点击查询重试'); + }).finally(() => { if (requestId === queryRequestRef.current) setPageLoading(false); }); - }, [monitoringQuery, rangeStart, rangeEnd]); + }, [monitoringQuery]); const handleManualRefresh = useCallback(async () => { - if (manualRefreshing || pageLoading) return; + if (manualRefreshing || pageLoading || hasPendingChanges) return; setManualRefreshing(true); try { - await loadFirstPage(false); + await loadFirstPage(false, true); } finally { setManualRefreshing(false); } - }, [loadFirstPage, manualRefreshing, pageLoading]); + }, [loadFirstPage, manualRefreshing, pageLoading, hasPendingChanges]); // 加载更多 const loadMore = useCallback(() => { - if (pageLoading || loadingMore || !hasMore) return; + if (pageLoading || manualRefreshing || loadingMore || !hasMore || hasPendingChanges || queryError) return; const requestId = queryRequestRef.current; const nextPage = page + 1; setLoadingMore(true); @@ -192,15 +182,17 @@ export default function MonitoringView() { setVehicles(prev => [...prev, ...d.vehicles]); setPage(nextPage); setHasMore(nextPage < d.totalPages); - }).catch(() => {}).finally(() => setLoadingMore(false)); - }, [monitoringQuery, page, pageLoading, loadingMore, hasMore]); + }).catch(error => { + if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '加载更多失败,请重试'); + }).finally(() => setLoadingMore(false)); + }, [monitoringQuery, page, pageLoading, manualRefreshing, loadingMore, hasMore, hasPendingChanges, queryError]); - // 筛选/排序变化时重新加载 + // 首次进入和确认查询后加载;编辑筛选条件不触发请求。 useEffect(() => { loadFirstPage(); }, [loadFirstPage]); - // 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌 + // 保留区域级联清理;清理后的草稿也必须确认后才能请求。 useEffect(() => { if (filterPlates.length === 0) return; const valid = new Set(filterOptions.plates); @@ -209,24 +201,18 @@ export default function MonitoringView() { }, [filterOptions.plates, filterPlates]); // 下载当前筛选结果为 xlsx - const handleDownload = useCallback(async () => { - if (exporting) return; + const handleDownload = useCallback(async (dailyDetails = false) => { + if (exporting || hasPendingChanges || pageLoading || queryError) return; setExporting(true); try { - const d = await fetchMonitoring(buildExportMonitoringQuery(monitoringQuery)); - exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy }); + const d = await fetchMonitoring({ ...buildExportMonitoringQuery(monitoringQuery), dailyDetails }); + exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || monitoringQuery.rangeStart, endDate: d.dateRange?.end || monitoringQuery.rangeEnd, sortBy: monitoringQuery.sortBy }); } catch (err) { - console.error('export failed', err); + setQueryError(err instanceof Error ? err.message : '导出失败,请重试'); } finally { setExporting(false); } - }, [exporting, monitoringQuery, rangeStart, rangeEnd, sortBy]); - - // 每分钟自动刷新 - useEffect(() => { - const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000); - return () => clearInterval(timer); - }, [loadFirstPage]); + }, [exporting, monitoringQuery, hasPendingChanges, pageLoading, queryError]); // 触底检测:用 IntersectionObserver 监听哨兵元素 const loadMoreRef = useRef(loadMore); @@ -272,16 +258,20 @@ export default function MonitoringView() { setIsFullscreen(!isFullscreen); }; - // 全屏时加载全部数据(无分页),筛选变化时重新加载 - useEffect(() => { - if (!isFullscreen) return; + // 全屏使用已确认条件,编辑筛选后仍需点击查询。 + const loadFullscreen = useCallback((refresh = false) => { + setQueryError(''); setFullscreenLoading(true); - fetchMonitoring(buildFullscreenMonitoringQuery(fullscreenQuery)).then(d => { + return fetchMonitoring({ ...buildFullscreenMonitoringQuery(monitoringQuery), refresh }).then(d => { setFullscreenVehicles(d.vehicles); setFullscreenStats(d.stats); setFilterOptions(d.filters); - }).catch(() => {}).finally(() => setFullscreenLoading(false)); - }, [isFullscreen, fullscreenQuery, fullscreenRefresh]); + }).catch(error => setQueryError(error instanceof Error ? error.message : '全屏查询失败')).finally(() => setFullscreenLoading(false)); + }, [monitoringQuery]); + + useEffect(() => { + if (isFullscreen) void loadFullscreen(); + }, [isFullscreen, loadFullscreen]); // 全屏时禁止背景滚动 useEffect(() => { @@ -322,9 +312,13 @@ export default function MonitoringView() { forceLandscape={forceLandscape} fullscreenStats={fullscreenStats} sortBy={sortBy} - sourcePriority={sourcePriority} - onRefresh={() => { setFullscreenRefresh(n => n + 1); }} + sourcePriority={monitoringQuery.sourcePriority} + onQuery={handleQuery} + queryDisabled={queryDisabled} + hasPendingChanges={hasPendingChanges} + onRefresh={() => { void loadFullscreen(true); }} fullscreenLoading={fullscreenLoading} + error={queryError} onToggleFullscreen={toggleFullscreen} filterTargetNames={filterTargetNames} onFilterTargetNamesChange={setFilterTargetNames} @@ -351,8 +345,13 @@ export default function MonitoringView() { { void handleDownload(); }} + onDownloadDetails={() => { void handleDownload(true); }} exporting={exporting} + onQuery={handleQuery} + queryDisabled={queryDisabled} + hasPendingChanges={hasPendingChanges} + resultsDisabled={pageLoading || hasPendingChanges || !!queryError} lastRefreshedAt={lastRefreshedAt} onManualRefresh={handleManualRefresh} manualRefreshing={manualRefreshing} @@ -415,8 +414,8 @@ export default function MonitoringView() { onFilterPlatesChange={setFilterPlates} onFilterTargetNamesChange={setFilterTargetNames} onFilterRegionChange={setFilterRegion} - onAppliedMileageRangeChange={setAppliedMileageRange} - onFilterOpenChange={setIsFilterOpen} + onQuery={handleQuery} + queryDisabled={queryDisabled} /> )} @@ -445,8 +444,7 @@ export default function MonitoringView() { searchTerm={searchTerm} onSearchTermChange={setSearchTerm} onFilterMileageRangeChange={setFilterMileageRange} - appliedMileageRange={appliedMileageRange} - onAppliedMileageRangeChange={setAppliedMileageRange} + mileageRange={filterMileageRange} filterPlatePrefix={filterPlatePrefix} onFilterPlatePrefixChange={setFilterPlatePrefix} rangeStart={rangeStart} @@ -455,10 +453,12 @@ export default function MonitoringView() { onRangeEndChange={setRangeEnd} /> + {queryError &&
{queryError}
} + {/* Sticky header: KPI + 清单标题 */} setDetailVehicle(null)} - sourcePriority={sourcePriority} + sourcePriority={monitoringQuery.sourcePriority} /> {/* 回到顶部按钮 */} diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx index 8326285..96b4f2f 100644 --- a/src/modules/mileage/VehicleDetailModal.tsx +++ b/src/modules/mileage/VehicleDetailModal.tsx @@ -13,11 +13,13 @@ interface Props { vehicle: MonitoringVehicle | null; onClose: () => void; sourcePriority: MileageSourceGroup[]; + selectedRange: { start: string; end: string }; } -type RangeKey = 'last15' | 'month' | 'quarter'; +type RangeKey = 'selected' | 'last15' | 'month' | 'quarter'; const RANGE_TABS: { key: RangeKey; label: string }[] = [ + { key: 'selected', label: '当前区间' }, { key: 'last15', label: '近 15 天' }, { key: 'month', label: '本月' }, { key: 'quarter', label: '本季度' }, @@ -48,10 +50,6 @@ function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: stri return { start: fmtYmd(start), end, rangeLabel: '本季度' }; } -function isToday(date: string): boolean { - return date === fmtYmd(new Date()); -} - function formatLabel(date: string, key: RangeKey): string { // YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD) void key; @@ -64,30 +62,32 @@ function daySourceLabel(day: VehicleRecentDay): string { return day.isDataSynced ? '来源待接口' : '无数据'; } -export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) { +export default function VehicleDetailModal({ vehicle, onClose, sourcePriority, selectedRange }: Props) { const [days, setDays] = useState([]); const [loading, setLoading] = useState(false); - const [range, setRange] = useState('last15'); + const [error, setError] = useState(''); + const [range, setRange] = useState('selected'); const dragControls = useDragControls(); // 切换车辆时重置区间为默认 useEffect(() => { - if (vehicle) setRange('last15'); + if (vehicle) setRange('selected'); }, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps // 拉取数据(车辆或区间变化) useEffect(() => { if (!vehicle) return; - const { start, end } = rangeFor(range); + const { start, end } = range === 'selected' ? selectedRange : rangeFor(range); setLoading(true); + setError(''); setDays([]); let cancelled = false; fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority }) .then(d => { if (!cancelled) setDays(d.days); }) - .catch(() => { if (!cancelled) setDays([]); }) + .catch(() => { if (!cancelled) setError('单车里程加载失败,请重新打开详情重试'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps + }, [vehicle?.plate, range, sourcePriority, selectedRange.start, selectedRange.end]); // eslint-disable-line react-hooks/exhaustive-deps // 锁滚动 useEffect(() => { @@ -96,8 +96,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: return () => { document.body.style.overflow = ''; }; }, [vehicle]); - // 排除"今日"列(数据未到位时易引起误读) - const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]); + // 与主列表区间一致;缺失日保留缺口,真实零保留为零。 + const historyDays = useMemo(() => days, [days]); const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]); const chartDays = useMemo(() => historyDays.map(day => ({ ...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null, @@ -106,11 +106,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: // 骨架天数:根据区间预估 const skeletonCount = useMemo(() => { if (range === 'last15') return 15; - const { start, end } = rangeFor(range); + const { start, end } = range === 'selected' ? selectedRange : rangeFor(range); const s = new Date(start); const e = new Date(end); return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000)); - }, [range]); + }, [range, selectedRange.start, selectedRange.end]); return ( @@ -171,11 +171,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: - + {error &&
{error}
} {/* 时间范围切换 */}
diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts index 5b70579..6d82fb3 100644 --- a/src/modules/mileage/api.ts +++ b/src/modules/mileage/api.ts @@ -7,7 +7,7 @@ import type { TargetVehicle, TrendPoint, } from './types'; -import { fetchJson } from '../../auth/api-client'; +import { fetchJson, fetchAuthenticated } from '../../auth/api-client'; import type { DailyMileageReport, MileageReportHistoryResponse, @@ -26,6 +26,9 @@ export type { const BASE = '/api/mileage'; export async function fetchMonitoring(params?: { + refresh?: boolean; + includeDailyTrend?: boolean; + dailyDetails?: boolean; sortBy?: 'today' | 'total' | 'statisticTime'; sortOrder?: 'asc' | 'desc'; limit?: number; @@ -50,6 +53,9 @@ export async function fetchMonitoring(params?: { sourcePriority?: MileageSourceGroup[]; }): Promise { const query = new URLSearchParams(); + if (params?.refresh) query.set('refresh', '1'); + if (params?.includeDailyTrend === false) query.set('includeDailyTrend', '0'); + if (params?.dailyDetails) query.set('dailyDetails', '1'); if (params?.sortBy) query.set('sortBy', params.sortBy); if (params?.sortOrder) query.set('sortOrder', params.sortOrder); if (params?.limit) query.set('limit', String(params.limit)); @@ -81,7 +87,10 @@ export async function fetchMonitoring(params?: { if (params?.endDate) query.set('endDate', params.endDate); if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(',')); const qs = query.toString(); - return fetchJson(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); + const response = await fetchAuthenticated(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || '里程查询失败,请重试'); + return data as MonitoringData; } export async function fetchTargets(): Promise { diff --git a/src/modules/mileage/mileage-display.test.ts b/src/modules/mileage/mileage-display.test.ts index 13f6adf..f0b6c25 100644 --- a/src/modules/mileage/mileage-display.test.ts +++ b/src/modules/mileage/mileage-display.test.ts @@ -21,3 +21,10 @@ test('列表全区间缺失时显示横杠,即使存在累计里程', () => { assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0); assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417); }); + +test('summary partial anomalies retain valid mileage; missing stays distinct from zero', () => { + const v = { periodMileageKm: 0, dailyKm: 0, mileageAnomaly: 'BASELINE_MISSING', isDataSynced: true } as MonitoringVehicle; + assert.equal(monitoringMileageValue(v),0); + assert.equal(monitoringMileageValue({...v,periodMileageKm:20}),20); + assert.equal(monitoringMileageValue({...v,periodMileageKm:null}),'—'); +}); diff --git a/src/modules/mileage/mileage-display.ts b/src/modules/mileage/mileage-display.ts index aa16014..0abfbe7 100644 --- a/src/modules/mileage/mileage-display.ts +++ b/src/modules/mileage/mileage-display.ts @@ -2,6 +2,7 @@ import type { MonitoringVehicle } from './types'; import type { VehicleRecentDay } from './api'; export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string { + if (vehicle.periodMileageKm !== undefined) return vehicle.periodMileageKm ?? '—'; if (vehicle.mileageAnomaly) return '数据异常'; if (!vehicle.isDataSynced) return '—'; return Math.max(0, vehicle.dailyKm || 0); @@ -22,3 +23,8 @@ export function recentMileageStats(days: VehicleRecentDay[]) { totalDays: days.length, }; } + +export function mileageQualityLabel(vehicle: MonitoringVehicle): string | null { + if (!vehicle.qualityStatus) return null; + return `${vehicle.validDays ?? 0}天有效 · ${vehicle.missingDays ?? 0}天缺失 · ${vehicle.anomalyDays ?? 0}天异常`; +} diff --git a/src/modules/mileage/monitoring/components/ActiveFilterTags.tsx b/src/modules/mileage/monitoring/components/ActiveFilterTags.tsx index 1cec12d..dbec249 100644 --- a/src/modules/mileage/monitoring/components/ActiveFilterTags.tsx +++ b/src/modules/mileage/monitoring/components/ActiveFilterTags.tsx @@ -27,8 +27,7 @@ export default function ActiveFilterTags({ searchTerm, onSearchTermChange, onFilterMileageRangeChange, - appliedMileageRange, - onAppliedMileageRangeChange, + mileageRange, filterPlatePrefix, onFilterPlatePrefixChange, rangeStart, @@ -58,8 +57,7 @@ export default function ActiveFilterTags({ searchTerm: string; onSearchTermChange: (value: string) => void; onFilterMileageRangeChange: Dispatch>; - appliedMileageRange: MileageRange; - onAppliedMileageRangeChange: Dispatch>; + mileageRange: MileageRange; filterPlatePrefix: string; onFilterPlatePrefixChange: (value: string) => void; rangeStart: string; @@ -84,8 +82,8 @@ export default function ActiveFilterTags({ if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') }); if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') }); if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => onSearchTermChange('') }); - if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, min: '' })); } }); - if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, max: '' })); } }); + if (mileageRange.min) tags.push({ label: `里程≥${mileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); } }); + if (mileageRange.max) tags.push({ label: `里程≤${mileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); } }); if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') }); if (rangeStart || rangeEnd) tags.push({ label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`, @@ -99,7 +97,7 @@ export default function ActiveFilterTags({ const clearAll = () => { onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All'); onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All'); - onFilterMileageRangeChange({ min: '', max: '' }); onAppliedMileageRangeChange({ min: '', max: '' }); + onFilterMileageRangeChange({ min: '', max: '' }); onFilterBrandsChange([]); const today = defaultMileageDate(); onRangeStartChange(today); onRangeEndChange(today); diff --git a/src/modules/mileage/monitoring/components/FilterPanel.tsx b/src/modules/mileage/monitoring/components/FilterPanel.tsx index e99c519..faf840e 100644 --- a/src/modules/mileage/monitoring/components/FilterPanel.tsx +++ b/src/modules/mileage/monitoring/components/FilterPanel.tsx @@ -35,8 +35,8 @@ export default function FilterPanel({ onFilterPlatesChange, onFilterTargetNamesChange, onFilterRegionChange, - onAppliedMileageRangeChange, - onFilterOpenChange, + onQuery, + queryDisabled, }: { rangeStart: string; rangeEnd: string; @@ -65,8 +65,8 @@ export default function FilterPanel({ onFilterPlatesChange: (value: string[]) => void; onFilterTargetNamesChange: (value: string[]) => void; onFilterRegionChange: (value: string) => void; - onAppliedMileageRangeChange: (value: MileageRange) => void; - onFilterOpenChange: (value: boolean) => void; + onQuery: () => void; + queryDisabled: boolean; }) { return ( onRangeStartChange(e.target.value)} @@ -89,11 +90,13 @@ export default function FilterPanel({ 至 onRangeEndChange(e.target.value)} />
+ {rangeStart && rangeEnd && rangeStart > rangeEnd &&

开始日期不能晚于结束日期

}
{([ ['today', '今天'], @@ -240,13 +243,13 @@ export default function FilterPanel({ onFilterDeptChange('All'); onFilterPlatesChange([]); onFilterCustomerChange('All'); + onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All'); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All'); onFilterMileageRangeChange({ min: '', max: '' }); - onAppliedMileageRangeChange({ min: '', max: '' }); onFilterBrandsChange([]); const today = defaultMileageDate(); onRangeStartChange(today); @@ -257,13 +260,11 @@ export default function FilterPanel({ 重置所有
diff --git a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx index 71c9e85..99b0454 100644 --- a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx +++ b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx @@ -1,4 +1,4 @@ -import { monitoringMileageValue } from '../../mileage-display'; +import { monitoringMileageValue, mileageQualityLabel } from '../../mileage-display'; import type { Dispatch, SetStateAction } from 'react'; import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react'; import { motion } from 'motion/react'; @@ -15,8 +15,12 @@ export default function FullscreenMonitoring({ fullscreenStats, sortBy, sourcePriority, + onQuery, + queryDisabled, + hasPendingChanges, onRefresh, fullscreenLoading, + error, onToggleFullscreen, filterTargetNames, onFilterTargetNamesChange, @@ -42,8 +46,12 @@ export default function FullscreenMonitoring({ fullscreenStats: MonitoringStats; sortBy: SortBy; sourcePriority: MileageSourceGroup[]; + onQuery: () => void; + queryDisabled: boolean; + hasPendingChanges: boolean; onRefresh: () => void; fullscreenLoading: boolean; + error: string; onToggleFullscreen: () => void; filterTargetNames: string[]; onFilterTargetNamesChange: Dispatch>; @@ -93,13 +101,13 @@ export default function FullscreenMonitoring({

全屏监控

- 区间 {Math.round(fullscreenStats.totalToday).toLocaleString()} km + 区间 {fullscreenStats.validVehicleCount === 0 ? '—' : Math.round(fullscreenStats.totalToday).toLocaleString()} km | - 累计 {Math.round(fullscreenStats.totalAll).toLocaleString()} km + 累计 {fullscreenStats.totalMileageVehicleCount === 0 ? '—' : Math.round(fullscreenStats.totalAll).toLocaleString()} km | 车辆 {fullscreenStats.vehicleCount} 台 | - 均 {(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)} km + 均 {(sortBy === 'total' ? fullscreenStats.totalMileageVehicleCount : fullscreenStats.validVehicleCount) === 0 ? '—' : (fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)} km | 来源 @@ -109,7 +117,11 @@ export default function FullscreenMonitoring({
+ + {hasPendingChanges && 待查询}
+ {error &&
{error}
} {/* Batch selector + legend */}
@@ -298,7 +311,7 @@ export default function FullscreenMonitoring({
{v.plate}
{sourceDisplay.label} diff --git a/src/modules/mileage/monitoring/components/MonitoringSummary.tsx b/src/modules/mileage/monitoring/components/MonitoringSummary.tsx index bcf3bb6..026ab77 100644 --- a/src/modules/mileage/monitoring/components/MonitoringSummary.tsx +++ b/src/modules/mileage/monitoring/components/MonitoringSummary.tsx @@ -16,12 +16,12 @@ export default function MonitoringSummary({ isRangeMode: boolean; stats: MonitoringStats; rangeLabel: string; - rangeDailyTotals: { date: string; totalKm: number }[]; + rangeDailyTotals: { date: string; totalKm: number | null }[]; total: number; }) { - const averageDailyKm = rangeDailyTotals.length > 0 - ? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length - : 0; + const hasMileage = (sortBy === 'total' ? stats.totalMileageVehicleCount : stats.validVehicleCount) !== 0; + const validDays = rangeDailyTotals.filter(item => item.totalKm != null); + const averageDailyKm = validDays.length ? validDays.reduce((sum, item) => sum + item.totalKm!, 0) / validDays.length : null; return (
@@ -29,13 +29,13 @@ export default function MonitoringSummary({
{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}总里程
- {pageLoading ?
: <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} km} + {pageLoading ?
: <>{hasMileage ? Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString() : '—'} km}
{rangeLabel}
平均单车
-
{pageLoading ?
: (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}
+
{pageLoading ?
: hasMileage ? (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0) : '—'}
km/台
@@ -57,27 +57,27 @@ export default function MonitoringSummary({
{rangeDailyTotals.length} 天 · km
- {rangeDailyTotals.length === 0 ? ( -
暂无趋势
+ {rangeDailyTotals.length === 0 || validDays.length === 0 ? ( +
无有效里程数据
) : ( [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']} + formatter={(value) => value == null ? ['无有效数据', '当日里程'] : [`${Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']} labelFormatter={(label) => `日期 ${String(label)}`} contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }} cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }} /> - {averageDailyKm > 0 && } + {averageDailyKm != null && averageDailyKm > 0 && } )}
-
日均
-
{Math.round(averageDailyKm).toLocaleString()}
+
有效日均
+
{averageDailyKm == null ? '—' : Math.round(averageDailyKm).toLocaleString()}
km
@@ -92,11 +92,11 @@ export default function MonitoringSummary({
当日总计
-
{Math.round(stats.totalToday).toLocaleString()}
+
{stats.validVehicleCount === 0 ? '—' : Math.round(stats.totalToday).toLocaleString()}
日均单车
-
{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}
+
{stats.validVehicleCount === 0 ? '—' : stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}
)} diff --git a/src/modules/mileage/monitoring/components/MonitoringToolbar.tsx b/src/modules/mileage/monitoring/components/MonitoringToolbar.tsx index 39100be..f046c3b 100644 --- a/src/modules/mileage/monitoring/components/MonitoringToolbar.tsx +++ b/src/modules/mileage/monitoring/components/MonitoringToolbar.tsx @@ -11,8 +11,13 @@ type SortBy = 'today' | 'total' | 'statisticTime'; type SortOrder = 'asc' | 'desc'; export default function MonitoringToolbar({ + onQuery, + queryDisabled, + hasPendingChanges, + resultsDisabled, onToggleFullscreen, onDownload, + onDownloadDetails, exporting, lastRefreshedAt, onManualRefresh, @@ -44,8 +49,13 @@ export default function MonitoringToolbar({ onApplyRangePreset, rangeLabel, }: { + onQuery: () => void; + queryDisabled: boolean; + hasPendingChanges: boolean; + resultsDisabled: boolean; onToggleFullscreen: () => void; onDownload: () => void; + onDownloadDetails: () => void; exporting: boolean; lastRefreshedAt: Date | null; onManualRefresh: () => void; @@ -88,9 +98,13 @@ export default function MonitoringToolbar({ - +
+ {exporting ? : } +
+ + +
+
@@ -100,15 +114,15 @@ export default function MonitoringToolbar({
- + {lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
-
+
+
{([ ['today', '今天'], ['yesterday', '昨天'], @@ -166,7 +181,10 @@ export default function MonitoringToolbar({ ); })} - {rangeLabel} +
+ + {hasPendingChanges && 待查询} + {rangeLabel}
); diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx index 9477303..b983eef 100644 --- a/src/modules/mileage/monitoring/components/VehicleList.tsx +++ b/src/modules/mileage/monitoring/components/VehicleList.tsx @@ -1,4 +1,4 @@ -import { monitoringMileageValue } from '../../mileage-display'; +import { monitoringMileageValue, mileageQualityLabel } from '../../mileage-display'; import type { RefObject } from 'react'; import { Truck } from 'lucide-react'; import { motion } from 'motion/react'; @@ -87,7 +87,8 @@ export default function VehicleList({ {sourceDisplay.label} - {v.mileageAnomaly &&
{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}
} + {v.qualityStatus &&
{mileageQualityLabel(v)}
} + {!v.qualityStatus && v.mileageAnomaly &&
{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}
}
{statisticTime.label}
diff --git a/src/modules/mileage/monitoring/monitoring-model.test.ts b/src/modules/mileage/monitoring/monitoring-model.test.ts index 8fbe1e9..cd31773 100644 --- a/src/modules/mileage/monitoring/monitoring-model.test.ts +++ b/src/modules/mileage/monitoring/monitoring-model.test.ts @@ -135,10 +135,10 @@ test('four monitoring query builders preserve pagination and fullscreen differen assert.equal(initial.plate, '粤A00001,粤A00002'); const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3); - assert.deepEqual(nextPage, { ...initial, page: 3 }); + assert.deepEqual(nextPage, { ...initial, page: 3, includeDailyTrend: false }); const exported = buildExportMonitoringQuery(queryModel); - assert.deepEqual(exported, { ...initial, limit: 9999 }); + assert.deepEqual(exported, { ...initial, limit: 9999, includeDailyTrend: false }); const fullscreen = buildFullscreenMonitoringQuery(queryModel); assert.equal(fullscreen.limit, 9999); diff --git a/src/modules/mileage/monitoring/query-model.ts b/src/modules/mileage/monitoring/query-model.ts index c15c7bb..0c0db30 100644 --- a/src/modules/mileage/monitoring/query-model.ts +++ b/src/modules/mileage/monitoring/query-model.ts @@ -82,15 +82,15 @@ export function buildNextPageMonitoringQuery( pageSize: number, page: number, ): MonitoringRequestParams { - return buildPagedQuery(model, pageSize, page); + return { ...buildPagedQuery(model, pageSize, page), includeDailyTrend: false }; } export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams { - return buildPagedQuery(model, 9999, 1); + return { ...buildPagedQuery(model, 9999, 1), includeDailyTrend: false }; } export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams { // Characterization boundary: fullscreen historically omits these four filters. // Keep project, entity, mileageMin and mileageMax out until a separate bug fix is approved. - return buildSharedQuery(model, 9999, 1); + return { ...buildSharedQuery(model, 9999, 1), includeDailyTrend: false }; } diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index c17f39c..135af6c 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -6,6 +6,13 @@ export interface MonitoringVehicle { plate: string; vin: string; dailyKm: number; + periodMileageKm?: number | null; + validDays?: number; + missingDays?: number; + anomalyDays?: number; + qualityStatus?: 'NORMAL' | 'PARTIAL_DATA' | 'NO_DATA' | 'DATA_ANOMALY'; + qualityReasons?: string[]; + sourceProtocols?: ('GB32960' | 'MQTT' | 'JT808')[]; mileageAnomaly?: string | null; dailyMileage?: Record; totalKm: number | null; @@ -35,6 +42,8 @@ export interface MonitoringStats { totalAll: number; vehicleCount: number; yesterdayTotal: number; + validVehicleCount?: number; + totalMileageVehicleCount?: number; } export interface MonitoringFilters { @@ -54,11 +63,12 @@ export interface MonitoringData { vehicles: MonitoringVehicle[]; stats: MonitoringStats; filters: MonitoringFilters; - rangeDailyTotals?: { date: string; totalKm: number }[]; + rangeDailyTotals?: { date: string; totalKm: number | null }[]; dateRange?: { start: string; end: string }; total: number; page: number; totalPages: number; + queriedAt?: string; updatedAt: string; sourcePriority: MileageSourceGroup[]; } diff --git a/src/modules/mileage/xlsx-export.ts b/src/modules/mileage/xlsx-export.ts index 43fc2da..1d1ebda 100644 --- a/src/modules/mileage/xlsx-export.ts +++ b/src/modules/mileage/xlsx-export.ts @@ -11,7 +11,7 @@ interface ExportContext { const BASE_HEADERS = [ '状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态', - '运营区域', + '运营区域', '有效天数', '缺失天数', '异常天数', '质量说明', ] as const; function sourceLabel(v: MonitoringVehicle): string { @@ -22,6 +22,7 @@ function sourceLabel(v: MonitoringVehicle): string { } function statusLabel(v: MonitoringVehicle): string { + if (v.qualityStatus) return { NORMAL: '有效', PARTIAL_DATA: '部分缺失', NO_DATA: '无数据', DATA_ANOMALY: '部分异常' }[v.qualityStatus]; if (!v.isDataSynced) return '未对接'; return v.isOnline ? '在线' : '离线'; } @@ -30,7 +31,7 @@ function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | nu if (kind === 'today') { return monitoringMileageValue(v); } - return v.totalKm != null ? v.totalKm : '未对接'; + return v.totalKm != null ? v.totalKm : '—'; } export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void { @@ -62,7 +63,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont v.department || '', v.project || '', v.rentStatus || '', - v.region || '', + v.region || '', v.validDays ?? '', v.missingDays ?? '', v.anomalyDays ?? '', v.qualityReasons?.join(', ') || '', ]; const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—'); return [ @@ -86,6 +87,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont { wch: 16 }, // 项目 { wch: 10 }, // 租赁状态 { wch: 12 }, // 运营区域 + { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 36 }, ]; for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 }); wsCols.push({ wch: 14 }); // 区间合计 @@ -177,6 +179,6 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont : ctx.sortBy === 'statisticTime' ? '统计时间' : isRange ? '区间' : '今日'; - const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`; + const filename = `${dayKeys.length ? '里程逐日明细' : '里程区间汇总'}_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`; XLSX.writeFile(wb, filename); } diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index 0021f8f..b8a9d34 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -3,16 +3,16 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import pool from '../../db.js'; import { fetchVehicleInfoMap } from './vehicle-info.js'; -import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js'; -import type { OneOsProtocol } from './source-policy.js'; -import { buildRangeMileage, type RangeMileageResult } from './range-model.js'; +import { fetchOneOsDailyMileage } from './oneos-api.js'; +import { queryMonitoringRange } from './query-cache.js'; +import { DEFAULT_ONEOS_PROTOCOL_PRIORITY, type OneOsProtocol } from './source-policy.js'; +import { buildSummaryMileage } from './summary-model.js'; import { buildMonitoringFilters, buildPlateTargetNamesMap, buildTargetPlatesMap, dailyMileageMap, mergeMonitoringVehicles, - mileageDatesBetween, previousMileageDate, toMileageRows, type TargetRow, @@ -117,17 +117,22 @@ export async function queryDateMileage( export async function queryRangeMileage( startDate: string, endDate: string, - protocolPriority?: OneOsProtocol[], -): Promise { - const days = mileageDatesBetween(startDate, endDate); - const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([ - fetchOneOsMileageDates(days, undefined, protocolPriority), - fetchOneOsDailyMileage(endDate, undefined, protocolPriority), - fetchOneOsDailyMileage(previousMileageDate(startDate), undefined, protocolPriority), + protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY, + refresh = false, +) { + const [cached, infoMap, targetRows] = await Promise.all([ + queryMonitoringRange(startDate, endDate, protocolPriority, refresh), fetchVehicleInfoMap(), fetchTargetRows(), ]); - return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap); + const { summary, yesterdayRows } = cached.result; + return { + vehicles: buildSummaryMileage(summary, yesterdayRows, infoMap, targetRows, regionMap), + summary, + start: startDate, end: endDate, + queriedAt: cached.queriedAt, + cacheStatus: cached.cacheStatus, + }; } export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters { diff --git a/src/server/routes/mileage/monitoring-cache.test.ts b/src/server/routes/mileage/monitoring-cache.test.ts new file mode 100644 index 0000000..cb9f363 --- /dev/null +++ b/src/server/routes/mileage/monitoring-cache.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import pool from '../../db.js'; +import type { AuthUser } from '../../auth/types.js'; +import type { MonitoringResponse } from './types.js'; +import monitoring from './monitoring.js'; +import recent from './vehicle-recent.js'; + +const originalConfig = { url: process.env.ONEOS_MILEAGE_API_BASE_URL, key: process.env.ONEOS_MILEAGE_API_KEY }; +test.before(() => { process.env.ONEOS_MILEAGE_API_BASE_URL = 'https://oneos.test'; process.env.ONEOS_MILEAGE_API_KEY = 'test-only'; }); +test.after(() => { + if (originalConfig.url === undefined) delete process.env.ONEOS_MILEAGE_API_BASE_URL; else process.env.ONEOS_MILEAGE_API_BASE_URL = originalConfig.url; + if (originalConfig.key === undefined) delete process.env.ONEOS_MILEAGE_API_KEY; else process.env.ONEOS_MILEAGE_API_KEY = originalConfig.key; +}); + +test('summary integration preserves scope, valid partial mileage, trend nulls, reuse, details and refresh', async t => { + const requests: {url:string;body:any}[]=[]; + let reassigned=false; + let failure=0; + t.mock.method(globalThis,'fetch',async(url:string,options:RequestInit)=>{ + const body=JSON.parse(options.body as string); requests.push({url,body}); + if(url.endsWith('/summary/query')){ + if(failure)return Response.json({code:'UPSTREAM_ERROR',traceId:'trace'}, {status:failure,headers:{'Retry-After':'12'}}); + const selected=body.plateNumbers || ['TEST0','TEST1','TEST2']; + const values=[0,30,20]; + return Response.json({code:'SUCCESS',startDate:body.startDate,endDate:body.endDate,timezone:'Asia/Shanghai',vehicleCount:selected.length, + data:{vehicles:selected.map((plate:string)=>({plateNumber:plate,vin:'VIN',periodMileageKm:values[Number(plate.slice(-1))], + endDateTotalMileageKm:plate==='TEST0'?null:100,endDateDataTime:null,endDateSourceProtocol:null, + validDays:1,missingDays:plate==='TEST2'?0:1,anomalyDays:plate==='TEST2'?1:0, + status:plate==='TEST2'?'DATA_ANOMALY':'PARTIAL_DATA',qualityReasons:[plate==='TEST2'?'BASELINE_MISSING':'CARRIED_FORWARD'],sourceProtocols:['MQTT'],updatedAt:null})), + dailyTrend:[{date:body.startDate,mileageKm:selected.reduce((sum:number,p:string)=>sum+values[Number(p.slice(-1))],0),validVehicleCount:selected.length,missingVehicleCount:0,anomalyVehicleCount:0}, + {date:body.endDate,mileageKm:null,validVehicleCount:0,missingVehicleCount:selected.filter((p:string)=>p!=='TEST2').length,anomalyVehicleCount:selected.includes('TEST2')?1:0}]}, + snapshotId:'snapshot',generatedAt:'2026-10-27T00:00:00Z',expiresAt:'2099-01-01T00:00:00Z',nextCursor:null}); + } + if(url.endsWith('/range/query'))return Response.json({code:'SUCCESS',data:(body.plateNumbers||[]).flatMap((plate:string)=>[ + {plateNumber:plate,date:body.startDate,dailyMileageKm:20,totalMileageKm:100,status:'NORMAL',sourceProtocol:'MQTT'}, + {plateNumber:plate,date:body.endDate,dailyMileageKm:0,totalMileageKm:100,status:'NORMAL',dataQuality:'CARRIED_FORWARD',sourceProtocol:'MQTT'}, + ]),snapshotId:'detail',nextCursor:null}); + return Response.json({code:'SUCCESS',data:[]}); + }); + t.mock.method(pool,'execute',async(sql:string)=>{ + if(!sql.includes('FROM vehicle_info'))return [[],[]]; + return [[0,1,2,3].map(i=>({plate:`TEST${i}`,vin:'VIN',customer:'客户',department:i===1?'B':'A',manager:null, + manager_id:i===0&&reassigned?'new-owner':`${i}`,rent_status:'租赁',entity:null,project:null,inventory_location:null,brand_label:null})),[]]; + }); + let user:AuthUser={userId:'0',userName:'测试',loginName:'test',depCode:'',depName:'A',roles:[],permissionLevel:'full'}; + const app=new Hono<{Variables:{user:AuthUser}}>(); + app.use('*',async(c,next)=>{c.set('user',user);await next();}); + app.route('/monitoring',monitoring);app.route('/vehicle',recent); + const base='/monitoring?startDate=2026-10-10&endDate=2026-10-11&sourcePriority=instrument'; + const read=async(extra='')=>{const response=await app.request(base+extra);assert.equal(response.status,200);return await response.json() as MonitoringResponse;}; + const first=await read('&limit=1'); + assert.equal(first.vehicles[0].plate,'TEST1');assert.equal(first.stats.totalToday,50); + assert.equal(first.rangeDailyTotals?.[1].totalKm,null); + assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1); + assert.equal(requests.filter(r=>r.url.endsWith('/range/query')).length,0); + const second=await read('&page=2&limit=1&includeDailyTrend=0');assert.equal(second.vehicles[0].periodMileageKm,20); + const sorted=await read('&sortOrder=asc&includeDailyTrend=0');assert.equal(sorted.vehicles[0].periodMileageKm,0); + const exported=await read('&limit=9999&includeDailyTrend=0'); + assert.equal(exported.vehicles.length,4);assert.equal(exported.vehicles[3].periodMileageKm,null); + assert.equal(exported.queriedAt,first.queriedAt);assert.ok(!exported.vehicles[0].dailyMileage); + assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1); + user={...user,permissionLevel:'department'}; + const department=await read();assert.equal(department.stats.totalToday,20); + assert.equal(department.rangeDailyTotals?.[0].totalKm,20); + assert.deepEqual(requests.filter(r=>r.url.endsWith('/summary/query')).at(-1)!.body.plateNumbers,['TEST0','TEST2']); + user={...user,permissionLevel:'personal'}; + const personal=await read();assert.equal(personal.rangeDailyTotals?.[0].totalKm,0); + assert.equal(personal.rangeDailyTotals?.[1].totalKm,null); + const count=requests.length;reassigned=true; + const empty=await read();assert.equal(empty.total,0);assert.ok(empty.rangeDailyTotals?.every(d=>d.totalKm===null));assert.equal(requests.length,count); + const denied=await app.request('/vehicle/TEST0/recent?start=2026-10-10&end=2026-10-11');assert.equal(denied.status,403); + reassigned=false;user={...user,permissionLevel:'full'}; + const detail=await read('&plate=TEST2&dailyDetails=1&includeDailyTrend=0'); + assert.deepEqual(detail.vehicles[0].dailyMileage,{'2026-10-10':20,'2026-10-11':null}); + assert.deepEqual(requests.at(-1)!.body.plateNumbers,['TEST2']); + const before=requests.filter(r=>r.url.endsWith('/summary/query')).length; + await read('&refresh=1');assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,before+1); + failure=403;const fail=await app.request(base+'&refresh=1');assert.equal(fail.status,403);assert.equal((await fail.json()).code,'UPSTREAM_ERROR'); + failure=429;const limited=await app.request(base);assert.equal(limited.status,429);assert.equal(limited.headers.get('Retry-After'),'12'); +}); diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index 7165e7c..3f7fe88 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -1,4 +1,9 @@ import { Hono } from 'hono'; +import { queryMonitoringRange } from './query-cache.js'; +import { MileageSummaryError, type MileageSummary } from './summary-api.js'; +import { mileageDatesBetween } from './cache-model.js'; +import { fetchOneOsMileageDates } from './oneos-api.js'; +import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js'; import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import type { AuthUser } from '../../auth/types.js'; @@ -132,21 +137,29 @@ app.get('/', async (c) => { brands: c.req.queries('brand') || [], }; + let rangeSummary: MileageSummary | undefined; + let queriedAt: string | undefined; let allVehicles: CachedVehicle[]; let filters: MonitoringFilters; - let rangeDailyTotals: { date: string; totalKm: number }[] | undefined; + let rangeDailyTotals: { date: string; totalKm: number | null }[] | undefined; let dateRange: { start: string; end: string } | undefined; if (range) { try { - const result = await queryRangeMileage(range.start, range.end, protocolPriority); + const result = await queryRangeMileage(range.start, range.end, protocolPriority, c.req.query('refresh') === '1'); + queriedAt = result.queriedAt; + c.header('X-Mileage-Cache', result.cacheStatus); allVehicles = result.vehicles; - rangeDailyTotals = result.dailyTotals; + rangeSummary = result.summary; dateRange = { start: result.start, end: result.end }; filters = buildDateFilters(allVehicles); } catch (e: unknown) { + if (e instanceof MileageSummaryError) { + if (e.retryAfter) c.header('Retry-After', e.retryAfter); + return c.json({ error: e.message, code: e.code, traceId: e.traceId }, e.status === 401 ? 502 : e.status as 400 | 403 | 429 | 500 | 502 | 504); + } console.error('monitoring range query error:', e); - return c.json(EMPTY_RESPONSE, 500); + return c.json({ error: e instanceof Error ? e.message : '里程汇总查询失败' }, 502); } } else if (date) { try { @@ -177,17 +190,35 @@ app.get('/', async (c) => { } const filtered = applyFilters(allVehicles, filterParams); - if (rangeDailyTotals && filtered.length !== allVehicles.length) { - rangeDailyTotals = rangeDailyTotals.map(item => ({ - ...item, - totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)), - })); + // The provider trend covers its entire selected fleet, not the BI user's scope. + if (range && rangeSummary && c.req.query('includeDailyTrend') !== '0') { + const authorized = new Set(rangeSummary.vehicles.map(v => v.plateNumber)); + const plates = [...new Set(filtered.map(v => v.plate.trim().toUpperCase()).filter(p => authorized.has(p)))].sort(); + if (plates.length === 0) { + rangeDailyTotals = mileageDatesBetween(range.start, range.end).map(date => ({ date, totalKm: null })); + } else if (range.start === range.end) { + const valid = filtered.filter(v => v.periodMileageKm != null); + rangeDailyTotals = [{ date: range.start, totalKm: valid.length ? valid.reduce((sum,v) => sum + v.periodMileageKm!, 0) : null }]; + } else { + try { + const summary = plates.length === authorized.size ? rangeSummary + : (await queryMonitoringRange(range.start, range.end, protocolPriority, c.req.query('refresh') === '1', plates)).result.summary; + rangeDailyTotals = summary.dailyTrend.map(d => ({ date: d.date, totalKm: d.mileageKm })); + } catch (e) { + if (e instanceof MileageSummaryError) { + if (e.retryAfter) c.header('Retry-After', e.retryAfter); + return c.json({ error: e.message, code: e.code, traceId: e.traceId }, e.status === 401 ? 502 : e.status as 400 | 403 | 429 | 500 | 502 | 504); + } + return c.json({ error: '所选车辆趋势查询失败,请重试' }, 502); + } + } } const stats = { totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)), totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0), vehicleCount: filtered.length, + ...(rangeSummary ? { validVehicleCount: filtered.filter(v => v.periodMileageKm != null).length, totalMileageVehicleCount: filtered.filter(v => v.totalKm != null).length } : {}), yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0), }; @@ -215,7 +246,19 @@ app.get('/', async (c) => { }); const offset = (page - 1) * limit; - const paged = sorted.slice(offset, offset + limit); + let paged = sorted.slice(offset, offset + limit); + if (range && rangeSummary && c.req.query('dailyDetails') === '1') { + const authorized = new Set(rangeSummary.vehicles.map(v => v.plateNumber)); + const plates = paged.map(v => v.plate.trim().toUpperCase()).filter(p => authorized.has(p)); + try { + const dates = mileageDatesBetween(range.start, range.end); + const rows = plates.length ? await fetchOneOsMileageDates(dates, plates, protocolPriority) : new Map(); + const byDate = new Map(dates.map(date => [date, new Map((rows.get(date) || []).map(row => [row.plateNumber, validDailyMileage(row)]))])); + paged = paged.map(v => ({ ...v, dailyMileage: Object.fromEntries(dates.map(date => [date, byDate.get(date)?.get(v.plate.trim().toUpperCase()) ?? null])) })); + } catch { + return c.json({ error: '逐日明细导出失败,请缩小范围或重试' }, 502); + } + } const total = filtered.length; return c.json({ @@ -229,6 +272,7 @@ app.get('/', async (c) => { totalPages: Math.ceil(total / limit), updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(), sourcePriority, + queriedAt, }); }); diff --git a/src/server/routes/mileage/oneos-api.ts b/src/server/routes/mileage/oneos-api.ts index 682cdc2..c7e86f8 100644 --- a/src/server/routes/mileage/oneos-api.ts +++ b/src/server/routes/mileage/oneos-api.ts @@ -224,6 +224,7 @@ export async function fetchOneOsDailyMileage( date: string, plateNumbers?: string[], protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY, + refresh = false, ): Promise { assertMileageDate(date); @@ -231,7 +232,7 @@ export async function fetchOneOsDailyMileage( const cacheKey = `${date}:${policyKey}`; const hit = cache.get(cacheKey); let rows: OneOsDailyMileage[]; - if (hit && hit.expiresAt > Date.now()) { + if (!refresh && hit && hit.expiresAt > Date.now()) { rows = hit.rows; } else { let pending = inflight.get(cacheKey); diff --git a/src/server/routes/mileage/oneos-model.test.ts b/src/server/routes/mileage/oneos-model.test.ts index 11574cb..b77d8f5 100644 --- a/src/server/routes/mileage/oneos-model.test.ts +++ b/src/server/routes/mileage/oneos-model.test.ts @@ -98,3 +98,13 @@ test('异常日里程不伪造为有效值,保留仪表累计和异常原因', assert.equal(row.totalMileageKm, 66852.4); assert.equal(row.dataQuality, 'ODOMETER_SOURCE_CHANGED'); }); + +test('NORMAL carried-forward detail is missing while actual zero remains valid', async () => { + const { validDailyMileage } = await import('./oneos-model.js'); + const rows=normalizeOneOsRows([ + {plateNumber:'MISSING',status:'NORMAL',dailyMileageKm:0,dataQuality:'CARRIED_FORWARD'}, + {plateNumber:'ZERO',status:'NORMAL',dailyMileageKm:0}, + ],'2026-10-01'); + assert.equal(validDailyMileage(rows[0]),null); + assert.equal(validDailyMileage(rows[1]),0); +}); diff --git a/src/server/routes/mileage/oneos-model.ts b/src/server/routes/mileage/oneos-model.ts index e92e3ed..9f17344 100644 --- a/src/server/routes/mileage/oneos-model.ts +++ b/src/server/routes/mileage/oneos-model.ts @@ -17,7 +17,7 @@ export interface OneOsDailyMileage { /** Missing or unusable daily mileage must not contribute to interval statistics. */ export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null { const km = row?.dailyMileageKm; - return row?.status === 'NORMAL' && km != null && Number.isFinite(km) && km >= 0 ? km : null; + return row?.status === 'NORMAL' && row.dataQuality !== 'CARRIED_FORWARD' && km != null && Number.isFinite(km) && km >= 0 ? km : null; } const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -69,7 +69,7 @@ export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOs dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status), totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status), status, - ...(status === 'DATA_ANOMALY' ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}), + ...((row.dataQuality || status === 'DATA_ANOMALY') ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}), dataTime, calculatedAt, updatedAt, diff --git a/src/server/routes/mileage/query-cache.test.ts b/src/server/routes/mileage/query-cache.test.ts new file mode 100644 index 0000000..b11a1e2 --- /dev/null +++ b/src/server/routes/mileage/query-cache.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMonitoringQueryCache, type RangeMileageSource } from './query-cache.js'; +import type { OneOsProtocol } from './source-policy.js'; + +const protocols: OneOsProtocol[] = ['GB32960', 'MQTT']; +const result = (): RangeMileageSource => ({ summary: { vehicles: [], dailyTrend: [], snapshotId: 'test', generatedAt: '2026-01-01T00:00:00Z', expiresAt: '2099-01-01T00:00:00Z' }, yesterdayRows: [] }); + +test('completed range results are reused for five minutes; refresh bypasses daily caches too', async () => { + let time = 1_000; + const calls: boolean[] = []; + const query = createMonitoringQueryCache(async (start, end, _protocols, refresh) => { + calls.push(!!refresh); + return result(); + }, () => time); + const first = await query('2026-10-01', '2026-10-26', protocols); + assert.equal(first.cacheStatus, 'miss'); + time += 299_999; + const hit = await query('2026-10-01', '2026-10-26', protocols); + assert.equal(hit.cacheStatus, 'hit'); + assert.equal(hit.result, first.result); + assert.equal(hit.queriedAt, first.queriedAt); + time += 1; + assert.equal((await query('2026-10-01', '2026-10-26', protocols)).cacheStatus, 'miss'); + assert.equal((await query('2026-10-01', '2026-10-26', protocols, true)).cacheStatus, 'refresh'); + assert.deepEqual(calls, [false, false, true]); +}); + +test('date range and ordered protocol priority isolate cached results; entries are bounded', async () => { + let calls = 0; + const query = createMonitoringQueryCache(async (start, end) => { calls++; return result(); }); + await query('2026-10-01', '2026-10-26', protocols); + await query('2026-10-01', '2026-10-26', ['MQTT', 'GB32960']); + await query('2026-10-01', '2026-10-26', ['JT808']); + await query('2026-10-02', '2026-10-26', protocols); + assert.equal(calls, 4); + assert.equal((await query('2026-10-01', '2026-10-26', protocols)).cacheStatus, 'hit'); + await query('2026-10-03', '2026-10-26', protocols); + assert.equal((await query('2026-10-01', '2026-10-26', protocols)).cacheStatus, 'miss'); + assert.equal(calls, 6); +}); + +test('simultaneous requests share upstream mileage retrieval, including explicit refresh', async () => { + let calls = 0; + let release!: (value: RangeMileageSource) => void; + const query = createMonitoringQueryCache(async () => { + calls++; + return new Promise(resolve => { release = resolve; }); + }); + const first = query('2026-10-01', '2026-10-26', protocols); + const second = query('2026-10-01', '2026-10-26', protocols); + const refresh = query('2026-10-01', '2026-10-26', protocols, true); + assert.equal(calls, 1); + release(result()); + const entries = await Promise.all([first, second, refresh]); + assert.deepEqual(entries.map(entry => entry.cacheStatus), ['miss', 'shared', 'shared']); + assert.ok(entries.every(entry => entry.result === entries[0].result)); +}); + +test('failed refresh removes stale data and failed requests can be retried', async () => { + let fail = false; + let calls = 0; + const query = createMonitoringQueryCache(async (start, end) => { + calls++; + if (fail) throw new Error('upstream unavailable'); + return result(); + }); + await query('2026-10-01', '2026-10-26', protocols); + fail = true; + await assert.rejects(query('2026-10-01', '2026-10-26', protocols, true), /upstream unavailable/); + await assert.rejects(query('2026-10-01', '2026-10-26', protocols), /upstream unavailable/); + fail = false; + assert.equal((await query('2026-10-01', '2026-10-26', protocols)).cacheStatus, 'miss'); + assert.equal(calls, 4); +}); + +test('summary cache isolates normalized vehicle scopes and respects provider snapshot expiry', async () => { + let time=1_000,calls=0; + const query=createMonitoringQueryCache(async()=>{ + calls++; + const value=result();value.summary.expiresAt=new Date(2_000).toISOString();return value; + },()=>time); + await query('2026-10-01','2026-10-26',protocols,false,[' b ','A']); + assert.equal((await query('2026-10-01','2026-10-26',protocols,false,['A','B'])).cacheStatus,'hit'); + await query('2026-10-01','2026-10-26',protocols,false,['A']); + assert.equal(calls,2); + time=2_000; + assert.equal((await query('2026-10-01','2026-10-26',protocols,false,['A','B'])).cacheStatus,'miss'); +}); diff --git a/src/server/routes/mileage/query-cache.ts b/src/server/routes/mileage/query-cache.ts new file mode 100644 index 0000000..2ad6413 --- /dev/null +++ b/src/server/routes/mileage/query-cache.ts @@ -0,0 +1,58 @@ +import { fetchOneOsDailyMileage, type OneOsDailyMileage } from './oneos-api.js'; +import { previousMileageDate } from './cache-model.js'; +import { fetchMileageSummary, type MileageSummary } from './summary-api.js'; +import type { OneOsProtocol } from './source-policy.js'; + +// Cache upstream mileage only; ownership, permissions and business filters stay live. +const TTL_MS = 5 * 60 * 1000; +const MAX_ENTRIES = 4; + +export interface RangeMileageSource { + summary: MileageSummary; + yesterdayRows: OneOsDailyMileage[]; +} + +type Entry = { result: RangeMileageSource; queriedAt: string; expiresAt: number }; + +async function loadSource(start: string, end: string, protocols: OneOsProtocol[], refresh = false, plates?: string[]): Promise { + const [summary, yesterdayRows] = await Promise.all([ + fetchMileageSummary(start, end, protocols, plates), + plates ? Promise.resolve([]) : fetchOneOsDailyMileage(previousMileageDate(start), undefined, protocols, refresh), + ]); + return { summary, yesterdayRows }; +} + +export function createMonitoringQueryCache( + query = loadSource, + now: () => number = Date.now, +) { + const entries = new Map(); + const inflight = new Map>(); + + return async (start: string, end: string, protocols: OneOsProtocol[], refresh = false, plates?: string[]) => { + const selected = plates ? [...new Set(plates.map(p => p.trim().toUpperCase()))].sort() : undefined; + const key = JSON.stringify([start, end, protocols, selected]); + const timestamp = now(); + for (const [entryKey, entry] of entries) { + if (entry.expiresAt <= timestamp) entries.delete(entryKey); + } + const pending = inflight.get(key); + if (pending) return { ...await pending, cacheStatus: 'shared' as const }; + const hit = entries.get(key); + if (hit && !refresh) return { ...hit, cacheStatus: 'hit' as const }; + + // A failed explicit refresh must not silently fall back to the old result. + entries.delete(key); + const request = query(start, end, protocols, refresh, selected).then(result => { + const completedAt = now(); + const entry = { result, queriedAt: new Date(completedAt).toISOString(), expiresAt: Math.min(completedAt + TTL_MS, Date.parse(result.summary.expiresAt)) }; + entries.set(key, entry); + if (entries.size > MAX_ENTRIES) entries.delete(entries.keys().next().value!); + return entry; + }).finally(() => inflight.delete(key)); + inflight.set(key, request); + return { ...await request, cacheStatus: refresh ? 'refresh' as const : 'miss' as const }; + }; +} + +export const queryMonitoringRange = createMonitoringQueryCache(); diff --git a/src/server/routes/mileage/summary-api.test.ts b/src/server/routes/mileage/summary-api.test.ts new file mode 100644 index 0000000..504d18b --- /dev/null +++ b/src/server/routes/mileage/summary-api.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { fetchMileageSummary, parseMileageSummary, MileageSummaryError } from './summary-api.js'; + +const originalConfig = { url: process.env.ONEOS_MILEAGE_API_BASE_URL, key: process.env.ONEOS_MILEAGE_API_KEY }; +test.before(() => { process.env.ONEOS_MILEAGE_API_BASE_URL = 'https://oneos.test'; process.env.ONEOS_MILEAGE_API_KEY = 'test-only'; }); +test.after(() => { + if (originalConfig.url === undefined) delete process.env.ONEOS_MILEAGE_API_BASE_URL; else process.env.ONEOS_MILEAGE_API_BASE_URL = originalConfig.url; + if (originalConfig.key === undefined) delete process.env.ONEOS_MILEAGE_API_KEY; else process.env.ONEOS_MILEAGE_API_KEY = originalConfig.key; +}); + +const start = '2026-10-01', end = '2026-10-02'; +const vehicle = (plate: string) => ({ plateNumber: plate, vin: 'VIN', periodMileageKm: 0, endDateTotalMileageKm: null, + endDateDataTime: null, endDateSourceProtocol: null, validDays: 1, missingDays: 1, anomalyDays: 0, + status: 'PARTIAL_DATA', qualityReasons: ['CARRIED_FORWARD'], sourceProtocols: ['MQTT'], updatedAt: null }); +const payload = () => ({ code: 'SUCCESS', startDate: start, endDate: end, timezone: 'Asia/Shanghai', vehicleCount: 1, + data: { vehicles: [vehicle('TEST0')], dailyTrend: [ + { date: start, mileageKm: 0, validVehicleCount: 1, missingVehicleCount: 0, anomalyVehicleCount: 0 }, + { date: end, mileageKm: null, validVehicleCount: 0, missingVehicleCount: 1, anomalyVehicleCount: 0 }, + ] }, snapshotId: 's1', generatedAt: '2026-10-27T00:00:00Z', expiresAt: '2099-01-01T00:00:00Z', nextCursor: null as string | null }); + +test('summary boundary preserves real zero and missing trend; rejects malformed counts/status/values', () => { + const p = payload(); + assert.equal(parseMileageSummary(p,start,end).vehicles[0].periodMileageKm,0); + assert.equal(parseMileageSummary(p,start,end).dailyTrend[1].mileageKm,null); + for (const mutate of [ + (p: ReturnType) => { p.data.vehicles[0].validDays = 2; }, + (p: ReturnType) => { p.data.vehicles[0].status = 'NORMAL'; }, + (p: ReturnType) => { p.data.vehicles[0].periodMileageKm = NaN; }, + (p: ReturnType) => { p.data.dailyTrend[1].mileageKm = 0; }, + ]) { const bad = payload(); mutate(bad); assert.throws(() => parseMileageSummary(bad,start,end)); } +}); + +test('summary pagination keeps the snapshot and reuses one fleet trend; empty scope never means all', async t => { + t.mock.method(globalThis, 'fetch', async (_url: string, options: RequestInit) => { + const body = JSON.parse(options.body as string); + assert.deepEqual(body.plateNumbers, ['TEST0','TEST1']); + assert.equal(body.includeDailyTrend,true); + assert.equal(body.pageSize,5000); + const p = payload(); p.vehicleCount = 2; + p.data.vehicles = [vehicle(body.cursor ? 'TEST1':'TEST0')]; + p.data.dailyTrend[0].validVehicleCount = 2; + p.data.dailyTrend[1].missingVehicleCount = 2; + p.nextCursor = body.cursor ? null:'next'; + return Response.json(p); + }); + const result = await fetchMileageSummary(start,end,['MQTT'],['TEST0','TEST1']); + assert.equal(result.vehicles.length,2); + assert.equal(result.dailyTrend.length,2); + assert.equal(result.dailyTrend[1].mileageKm,null); + await assert.rejects(fetchMileageSummary(start,end,['MQTT'],[]), /空车牌/); +}); + +test('summary authorization, throttling and timeout errors are explicit and never return empty success', async t => { + for (const status of [401,403,429,504]) { + const mock = t.mock.method(globalThis, 'fetch', async () => Response.json({code:'TEST_ERROR',traceId:'test-trace'}, {status,headers:{'Retry-After':'30'}})); + await assert.rejects(fetchMileageSummary(start,end,['MQTT']), e => e instanceof MileageSummaryError && e.status === status && e.traceId === 'test-trace' && e.retryAfter === '30'); + mock.mock.restore(); + } +}); + +test('snapshot changes and duplicate vehicles reject partial pagination', async t => { + for (const changedSnapshot of [true,false]) { + const mock=t.mock.method(globalThis,'fetch',async (_url:string,options:RequestInit)=>{ + const body=JSON.parse(options.body as string), p=payload(); p.vehicleCount=2; + p.data.dailyTrend[0].validVehicleCount=2; p.data.dailyTrend[1].missingVehicleCount=2; + p.nextCursor=body.cursor?null:'next'; + if(body.cursor && changedSnapshot)p.snapshotId='s2'; + return Response.json(p); + }); + await assert.rejects(fetchMileageSummary(start,end,['MQTT']),changedSnapshot?/快照/:/重复车辆/); + mock.mock.restore(); + } +}); diff --git a/src/server/routes/mileage/summary-api.ts b/src/server/routes/mileage/summary-api.ts new file mode 100644 index 0000000..4170216 --- /dev/null +++ b/src/server/routes/mileage/summary-api.ts @@ -0,0 +1,116 @@ +import dotenv from 'dotenv'; +import { mileageDatesBetween } from './cache-model.js'; +import type { OneOsProtocol } from './source-policy.js'; + +dotenv.config(); + +export interface MileageSummaryVehicle { + vin: string; + plateNumber: string; + periodMileageKm: number | null; + endDateTotalMileageKm: number | null; + endDateDataTime: string | null; + endDateSourceProtocol: OneOsProtocol | null; + validDays: number; + missingDays: number; + anomalyDays: number; + status: 'NORMAL' | 'PARTIAL_DATA' | 'NO_DATA' | 'DATA_ANOMALY'; + qualityReasons: string[]; + sourceProtocols: OneOsProtocol[]; + updatedAt: string | null; +} +export interface MileageSummaryTrend { + date: string; + mileageKm: number | null; + validVehicleCount: number; + missingVehicleCount: number; + anomalyVehicleCount: number; +} +export interface MileageSummary { + vehicles: MileageSummaryVehicle[]; + dailyTrend: MileageSummaryTrend[]; + snapshotId: string; + generatedAt: string; + expiresAt: string; +} + +export class MileageSummaryError extends Error { + constructor(readonly status: number, readonly code: string, readonly traceId?: string, readonly retryAfter?: string) { + const message = status === 401 ? '里程服务凭据无效或未覆盖所选日期' + : status === 403 ? '里程服务未授权所选车辆或完整日期区间' + : status === 429 ? `里程查询频率受限,请${retryAfter ? ` ${retryAfter} 秒` : '稍'}后重试` + : status === 504 ? '里程查询超时,请缩小车辆或日期范围后重试' + : status === 400 ? '里程查询参数或快照无效,请重新查询' + : '里程服务暂不可用,请重试'; + super(message); + } +} + +// Validate once at the provider boundary; invalid responses must not become zero mileage. +export function parseMileageSummary(value: unknown, start: string, end: string): MileageSummary & { nextCursor: string | null; vehicleCount: number } { + const p = value as any; + const dates = mileageDatesBetween(start, end); + const protocols = new Set(['GB32960', 'MQTT', 'JT808']); + const count = (n: unknown) => Number.isInteger(n) && Number(n) >= 0; + const km = (n: unknown) => n === null || typeof n === 'number' && Number.isFinite(n) && n >= 0; + const time = (s: unknown) => s === null || typeof s === 'string' && Number.isFinite(Date.parse(s)); + if (!p || p.code !== 'SUCCESS' || p.startDate !== start || p.endDate !== end || p.timezone !== 'Asia/Shanghai' + || !count(p.vehicleCount) || p.vehicleCount > 5000 || !p.snapshotId || typeof p.snapshotId !== 'string' + || typeof p.generatedAt !== 'string' || !time(p.generatedAt) || typeof p.expiresAt !== 'string' || !time(p.expiresAt) + || !(p.nextCursor === null || typeof p.nextCursor === 'string' && p.nextCursor.length > 0) + || !Array.isArray(p.data?.vehicles) || !Array.isArray(p.data?.dailyTrend)) throw new Error('里程汇总响应格式不符合契约'); + for (const v of p.data.vehicles) { + if (!v || typeof v.plateNumber !== 'string' || !v.plateNumber.trim() || typeof v.vin !== 'string' + || !km(v.periodMileageKm) || !km(v.endDateTotalMileageKm) || !time(v.endDateDataTime) || !time(v.updatedAt) + || !(v.endDateSourceProtocol === null || protocols.has(v.endDateSourceProtocol)) + || ![v.validDays,v.missingDays,v.anomalyDays].every(count) || v.validDays + v.missingDays + v.anomalyDays !== dates.length + || (v.validDays === 0) !== (v.periodMileageKm === null) + || !Array.isArray(v.qualityReasons) || !v.qualityReasons.every((x: unknown) => typeof x === 'string') + || !Array.isArray(v.sourceProtocols) || !v.sourceProtocols.every((x: string) => protocols.has(x))) throw new Error('里程汇总车辆字段不符合契约'); + const status = v.anomalyDays > 0 ? 'DATA_ANOMALY' : v.validDays === 0 ? 'NO_DATA' : v.missingDays > 0 ? 'PARTIAL_DATA' : 'NORMAL'; + if (v.status !== status) throw new Error('里程汇总质量状态不符合契约'); + } + if (p.data.dailyTrend.length !== dates.length || p.data.dailyTrend.some((d: any, i: number) => !d || d.date !== dates[i] + || !km(d.mileageKm) || ![d.validVehicleCount,d.missingVehicleCount,d.anomalyVehicleCount].every(count) + || d.validVehicleCount + d.missingVehicleCount + d.anomalyVehicleCount !== p.vehicleCount + || (d.validVehicleCount === 0) !== (d.mileageKm === null))) throw new Error('里程每日趋势不符合契约'); + return { vehicles: p.data.vehicles, dailyTrend: p.data.dailyTrend, snapshotId: p.snapshotId, + generatedAt: p.generatedAt, expiresAt: p.expiresAt, nextCursor: p.nextCursor, vehicleCount: p.vehicleCount }; +} + +export async function fetchMileageSummary(start: string, end: string, protocols: OneOsProtocol[], plates?: string[]): Promise { + // An empty BI scope must never become the provider's "all authorized vehicles". + if (plates?.length === 0) throw new Error('不能使用空车牌集合查询里程汇总'); + const base = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, ''); + const key = process.env.ONEOS_MILEAGE_API_KEY; + if (!base || !key) throw new Error('OneOS mileage API is not configured'); + const vehicles: MileageSummaryVehicle[] = []; + const seen = new Set(); + let first: ReturnType | undefined; + let cursor: string | null = null; + do { + const response = await fetch(`${base}/api/v1/vehicles/mileage/summary/query`, { + method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ startDate: start, endDate: end, protocolPriority: protocols, + includeDailyTrend: true, pageSize: 5000, ...(plates ? { plateNumbers: plates } : {}), ...(cursor ? { cursor } : {}) }), + signal: AbortSignal.timeout(Math.max(15000, Number(process.env.ONEOS_MILEAGE_API_TIMEOUT_MS) || 20000)), + }); + const payload = await response.json(); + if (!response.ok || payload.code !== 'SUCCESS') throw new MileageSummaryError(response.status === 200 ? 502 : response.status, + payload.code || 'UNKNOWN', payload.traceId, response.headers.get('Retry-After') || undefined); + const page = parseMileageSummary(payload, start, end); + if (first && (page.snapshotId !== first.snapshotId || page.vehicleCount !== first.vehicleCount + || page.generatedAt !== first.generatedAt || page.expiresAt !== first.expiresAt)) throw new Error('里程汇总分页快照发生变化,请重新查询'); + first ??= page; + for (const v of page.vehicles) { + const plate = v.plateNumber.trim().toUpperCase(); + if (seen.has(plate)) throw new Error('里程汇总分页出现重复车辆'); + seen.add(plate); + vehicles.push({ ...v, plateNumber: plate }); + } + if (vehicles.length > first.vehicleCount || (page.nextCursor && page.vehicles.length === 0)) throw new Error('里程汇总分页数量不一致'); + cursor = page.nextCursor; + } while (cursor); + if (vehicles.length !== first!.vehicleCount) throw new Error('里程汇总未返回完整车辆集合'); + return { ...first!, vehicles }; // Reuse one complete trend; never sum trends from pages. +} diff --git a/src/server/routes/mileage/summary-model.test.ts b/src/server/routes/mileage/summary-model.test.ts new file mode 100644 index 0000000..049b17b --- /dev/null +++ b/src/server/routes/mileage/summary-model.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildSummaryMileage } from './summary-model.js'; +import type { MileageSummary } from './summary-api.js'; +import type { VehicleInfoRow } from './types.js'; + +test('summary mapping preserves valid partial amounts, independent odometers and observation timestamps',()=>{ + const info:VehicleInfoRow={plate:'TEST',vin:'VIN',customer:null,department:'A',manager:null,manager_id:'1',rent_status:null,entity:null,project:null,inventory_location:null,brand_label:null}; + const summary:MileageSummary={snapshotId:'s',generatedAt:'2026-10-27T00:00:00Z',expiresAt:'2099-01-01T00:00:00Z',dailyTrend:[],vehicles:[{ + plateNumber:'TEST',vin:'VIN',periodMileageKm:25,endDateTotalMileageKm:100,endDateDataTime:'2026-10-26T00:00:00Z',endDateSourceProtocol:'JT808', + validDays:1,missingDays:0,anomalyDays:1,status:'DATA_ANOMALY',qualityReasons:['BASELINE_MISSING'],sourceProtocols:['MQTT','JT808'],updatedAt:'2026-10-26T01:00:00Z', + }]}; + const map=new Map([['TEST',info]]); + const row=buildSummaryMileage(summary,[],map,[],{})[0]; + assert.equal(row.periodMileageKm,25);assert.equal(row.isDataSynced,true);assert.equal(row.sourceCategory,'MIXED');assert.equal(row.totalKm,100); + assert.equal(row.dataTime,summary.vehicles[0].endDateDataTime);assert.equal(row.updatedAt,summary.vehicles[0].updatedAt);assert.equal(row.calculatedAt,null,'query generation time is not a vehicle observation time'); + summary.vehicles[0]={...summary.vehicles[0],periodMileageKm:null,validDays:0,anomalyDays:2}; + const missing=buildSummaryMileage(summary,[],map,[],{})[0];assert.equal(missing.periodMileageKm,null);assert.equal(missing.totalKm,100);assert.equal(missing.isDataSynced,false); +}); diff --git a/src/server/routes/mileage/summary-model.ts b/src/server/routes/mileage/summary-model.ts new file mode 100644 index 0000000..e26529b --- /dev/null +++ b/src/server/routes/mileage/summary-model.ts @@ -0,0 +1,26 @@ +import type { MileageSummary } from './summary-api.js'; +import type { OneOsDailyMileage } from './oneos-model.js'; +import { buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, type TargetRow } from './cache-model.js'; +import type { CachedVehicle, VehicleInfoRow } from './types.js'; +import { sourceCategoryFromProtocol } from './source-policy.js'; + +export function buildSummaryMileage(summary: MileageSummary, yesterdayRows: OneOsDailyMileage[], infoMap: Map, targets: TargetRow[], regions: Record): CachedVehicle[] { + const byPlate = new Map(summary.vehicles.map(v => [v.plateNumber, v])); + return mergeMonitoringVehicles([], infoMap, dailyMileageMap(yesterdayRows), buildPlateTargetNamesMap(targets), regions).map(vehicle => { + const row = byPlate.get(vehicle.plate.trim().toUpperCase()); + if (!row) return { ...vehicle, periodMileageKm: null }; + const categories = new Set(row.sourceProtocols.map(sourceCategoryFromProtocol)); + return { ...vehicle, vin: row.vin || vehicle.vin, + dailyKm: row.periodMileageKm ?? 0, periodMileageKm: row.periodMileageKm, + totalKm: row.endDateTotalMileageKm, + validDays: row.validDays, missingDays: row.missingDays, anomalyDays: row.anomalyDays, + qualityStatus: row.status, qualityReasons: row.qualityReasons, sourceProtocols: row.sourceProtocols, + mileageAnomaly: row.anomalyDays ? row.qualityReasons.join(', ') || 'DATA_ANOMALY' : null, + source: row.validDays > 0 ? 'ONEOS_API' : 'NONE', isDataSynced: row.validDays > 0, + isOnline: row.periodMileageKm != null && row.periodMileageKm > 0, + sourceProtocol: row.endDateSourceProtocol, + sourceCategory: categories.size > 1 ? 'MIXED' : categories.values().next().value || null, + dataTime: row.endDateDataTime, updatedAt: row.updatedAt, calculatedAt: null, + }; + }); +} diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index 823a22d..d4421b6 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -3,6 +3,13 @@ export interface CachedVehicle { plate: string; vin: string; dailyKm: number; + periodMileageKm?: number | null; + validDays?: number; + missingDays?: number; + anomalyDays?: number; + qualityStatus?: 'NORMAL' | 'PARTIAL_DATA' | 'NO_DATA' | 'DATA_ANOMALY'; + qualityReasons?: string[]; + sourceProtocols?: ('GB32960' | 'MQTT' | 'JT808')[]; mileageAnomaly?: string | null; dailyMileage?: Record; totalKm: number | null; @@ -64,6 +71,8 @@ export interface MonitoringStats { totalAll: number; vehicleCount: number; yesterdayTotal: number; + validVehicleCount?: number; + totalMileageVehicleCount?: number; } /** /monitoring 完整响应 */ @@ -71,11 +80,12 @@ export interface MonitoringResponse { vehicles: CachedVehicle[]; stats: MonitoringStats; filters: MonitoringFilters; - rangeDailyTotals?: { date: string; totalKm: number }[]; + rangeDailyTotals?: { date: string; totalKm: number | null }[]; dateRange?: { start: string; end: string }; total: number; page: number; totalPages: number; + queriedAt?: string; updatedAt: string; sourcePriority: ('instrument' | 'gps')[]; } diff --git a/src/server/routes/mileage/vehicle-recent.ts b/src/server/routes/mileage/vehicle-recent.ts index 184b74f..df6b871 100644 --- a/src/server/routes/mileage/vehicle-recent.ts +++ b/src/server/routes/mileage/vehicle-recent.ts @@ -1,4 +1,7 @@ import { Hono } from 'hono'; +import { fetchVehicleInfoByPlates } from './vehicle-info.js'; +import { filterByPermission } from '../../auth/permissions.js'; +import type { AuthUser } from '../../auth/types.js'; import { validDailyMileage } from './oneos-model.js'; import { fetchOneOsMileageDates } from './oneos-api.js'; import { @@ -60,6 +63,12 @@ app.get('/:plate/recent', async (c) => { } try { + const user = c.get('user' as never) as AuthUser | undefined; + if (user) { + const info = await fetchVehicleInfoByPlates([plate]); + const visible = filterByPermission([...info.values()].map(v => ({ ...v, managerId: v.manager_id })), user); + if (!visible.length) return c.json({ error: '无权查看该车辆里程' }, 403); + } const dates: string[] = []; const dateCursor = new Date(start); while (dateCursor <= end) {