Compare commits
2
Commits
dbe0421bfe
...
e2f932232f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f932232f | ||
|
|
8afe13c9a3 |
@@ -0,0 +1 @@
|
|||||||
|
period_month,scheduled_for,captured_at,definition_version,source_reference,vehicle_id,plate_number,vin,operation_status,vehicle_status,subject_org,department_name,manager_id,manager_name,vehicle_type,model,brand,color,ownership,rent_company,province,city,contract_no,customer_name,contract_org,project_name
|
||||||
|
@@ -0,0 +1,169 @@
|
|||||||
|
## 当前展示口径(2026-10-24 更新)
|
||||||
|
|
||||||
|
按用户最新要求,停止展示交还记录推算的历史资产数据。正式路由只提供当前实时数据和真实归档快照,已取消 `ASSET_HISTORY_RECONSTRUCTION_FILE` 的接入;即使保留旧环境变量,也不能通过趋势或明细接口读取回溯文件。旧核对表和私有回溯文件保留用于离线核查,不删除、不写入正式快照。
|
||||||
|
|
||||||
|
2026年10月前的缺失期间返回空值,不补零、不提供估算明细。页面仅显示一句提示:“历史库存状态快照缺失,核对中,暂不展示。” 当前10月继续读取实时数据;今后只有采集成功的真实快照才能归档展示。服务端仍返回存储和采集状态供运维检查,页面不再重复展示部署及回溯说明。
|
||||||
|
|
||||||
|
本地验收服务同步停用回溯文件。生产迁移、部署和自动采集仍未执行;该展示调整不等于采集已经启用。下文的回溯回填章节保留为历史实施记录,其环境变量接入和页面展示说明已被本节取代。
|
||||||
|
|
||||||
|
# 资产历史趋势
|
||||||
|
|
||||||
|
资产概览顶部指标之后新增历史趋势。月度选择年份,年度每年一个年末点;运营和库存为堆叠柱,总数为折线。下方期间按钮支持键盘和触摸选点,月度明细可分类、搜索车牌/VIN及分页。
|
||||||
|
|
||||||
|
## 口径及时间
|
||||||
|
|
||||||
|
- 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。
|
||||||
|
- 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。
|
||||||
|
- 月末最后一天 `Asia/Shanghai` 17:00 为计划时点;只允许在 17:00:00(含)到 17:01:00(不含)内开始读取。
|
||||||
|
- `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。
|
||||||
|
- 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。
|
||||||
|
- 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。
|
||||||
|
- 实时点每五秒走 `/live`,不重读全部历史;切换年份/公司/模式、手动刷新、跨月或归档边界时更新历史。
|
||||||
|
- 实时数据沿用现有资产仓储的五秒缓存,实时 `asOf` 为本次服务端查询时间,不代表源台账所有记录的最后修改时间;固定快照绕过该缓存。
|
||||||
|
- 缺失及未来数量为 `null`,合法零数量为 `0`,不插值、不跨缺失点连线。比较只取相邻期间;一月使用上年十二月作为基期。
|
||||||
|
- 默认年度显示最近五年;更早有归档或导入时自动扩展年份。月度允许查询 2000 年至当前年份。
|
||||||
|
|
||||||
|
## 权限及历史字段
|
||||||
|
|
||||||
|
本功能是资产存量统计。实时和归档都复用 `filterAssetVehiclesByPermission`:库存对账号公共可见,其余车辆沿用全量、部门或个人权限。公共库存按所查询时点的运营状态判断;历史公司、部门和负责人取快照,角色及个人身份别名取当前认证系统。公司筛选在权限过滤范围内取 `subjectOrg`。
|
||||||
|
|
||||||
|
不连接今日车辆列表补字段,不因车辆退出、删除或转部门丢失历史。当前来源的部门权限使用部门名称,快照如实保存该名称,不虚构来源没有的部门 ID。未来源系统若增加稳定部门 ID,需版本化升级口径。
|
||||||
|
|
||||||
|
归档汇总从授权后的同一批逐车数据计算,不返回批次全量计数。明细客户名称复用既有脱敏。历史公司仅出现在有权限的相关历史数据中;顶部公司列表中的历史独有公司标注“历史主体”,不改变现有实时公司接口含义。权限视角切换会由现有认证容器重建页面并取消旧请求。
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
`bi_asset_history_settings` 保存首次实际启用时间。重复启用保留首次时间。
|
||||||
|
|
||||||
|
`bi_asset_history_batches` 保存年月、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。年月与口径版本唯一。正式归档为 `COMPLETE`,失败为 `FAILED`,错过为 `MISSED`。
|
||||||
|
|
||||||
|
`bi_asset_history_vehicles` 保存车辆字符串 ID、原始运营/车辆状态、归档分类、公司、部门、负责人 ID 和冻结车辆 JSON。批次内车辆唯一;不以当前车辆表为外键。JSON 保留车牌、VIN、型号、品牌、业务归属等明细信息,排除无关的异动工作详情和待交车附件信息。
|
||||||
|
|
||||||
|
采集用 MySQL 同一连接的命名锁防止多实例并发,以只读一致性事务执行原始 SQL,再以独立事务原子发布头表、全部明细和 COMPLETE 状态。窗口外不读源数据。读接口、普通模块导入和只读预览不建表、不采集。读到不存在的表时显示部署提示并保留当前实时点;真实数据库故障返回错误,不伪装成空数据。
|
||||||
|
|
||||||
|
归档读取会校验车辆唯一性、分类、明细计数和批次计数,不把损坏的明细显示为合法零快照。已提交但提交确认丢失时,重试先检查 COMPLETE,不覆盖已发布数据。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
全部接口经现有认证和权限预览中间件,响应 `Cache-Control: private, no-store`。
|
||||||
|
|
||||||
|
| GET | 参数 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/api/asset-history/trend` | `mode=monthly\|annual`、`year`、`subject` | 趋势点、相邻差值、可选年份及相关历史公司 |
|
||||||
|
| `/api/asset-history/live` | `mode`、`subject` | 仅更新当前实时点,不查历史明细 |
|
||||||
|
| `/api/asset-history/details` | `month=YYYY-MM`、`batchId`、`subject`、`category=all\|operating\|inventory`、`search`、`page`、`pageSize` | 月度明细,默认 50 台,最大 100 台;页面每页 30 台 |
|
||||||
|
|
||||||
|
归档明细入口附带批次 ID,服务端同时核对月份。没有可用快照返回 409,不回退到实时台账。400 为参数错误,401 为未认证,503 为读取或数据质量异常。没有 HTTP 采集、写入和导入接口。
|
||||||
|
|
||||||
|
## 迁移与启用
|
||||||
|
|
||||||
|
默认关闭:`ASSET_HISTORY_CAPTURE_ENABLED` 只有精确设为 `1` 才启动采集。迁移不等于启用,迁移本身不会写入首次启用时间。
|
||||||
|
|
||||||
|
在项目根目录审阅迁移(不连接数据库):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --import tsx scripts/migrate-asset-history.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
取得目标数据库迁移授权后,使用受控环境变量配置正确数据库,再显式执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --import tsx scripts/migrate-asset-history.ts --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
如部分建表失败,修正原因后可重复执行;脚本使用 `CREATE TABLE IF NOT EXISTS`。新建空表不会伪造以前历史。
|
||||||
|
|
||||||
|
授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `enabled_at`,启用前的时点不会追补。如果在月末 17:00 之后才首次启用,即使仍处于该分钟,也从下一个月末开始采集。
|
||||||
|
|
||||||
|
按本需求确认日期 2026-10-19,如果在 2026-10-31 17:00 前成功启用,首个计划时点为北京时间 **2026-10-31 17:00**;实际计划以数据库首次启用时间和服务时间计算,不硬编码。
|
||||||
|
|
||||||
|
保持服务在计划时点在线。停止期间错过的 eligible 月份会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含年月及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。
|
||||||
|
|
||||||
|
建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。
|
||||||
|
|
||||||
|
管理员可用以下只读 SQL 检查,不通过普通用户接口暴露全量计数:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT enabled_at FROM bi_asset_history_settings WHERE id=1;
|
||||||
|
SELECT period_month, scheduled_for, captured_at, completed_at,
|
||||||
|
definition_version, source, status, failure_code,
|
||||||
|
total_count, operating_count, inventory_count
|
||||||
|
FROM bi_asset_history_batches ORDER BY period_month DESC LIMIT 12;
|
||||||
|
SELECT b.period_month, b.total_count, COUNT(v.vehicle_id) AS detail_count
|
||||||
|
FROM bi_asset_history_batches b
|
||||||
|
LEFT JOIN bi_asset_history_vehicles v ON v.batch_id=b.id
|
||||||
|
WHERE b.status='COMPLETE'
|
||||||
|
GROUP BY b.id, b.period_month, b.total_count;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 回退
|
||||||
|
|
||||||
|
1. 设置 `ASSET_HISTORY_CAPTURE_ENABLED=0` 并重启服务,停止后续采集。
|
||||||
|
2. 回退应用代码;已有归档表可原样保留,不影响旧代码。
|
||||||
|
3. 若确需撤销表,先备份并取得数据库删除授权;依次删除明细表、批次表、设置表。默认不删除任何归档数据。
|
||||||
|
|
||||||
|
读接口不自动建表,因此回退后也不会被请求重新创建。再次上线可继续读取既有归档;首次启用时间不自动重置。
|
||||||
|
|
||||||
|
## 后续逐车导入契约(本次不提供导入写接口)
|
||||||
|
|
||||||
|
提供仅表头模板 `docs/asset-history-import-template.csv`。使用 UTF-8 CSV,每行一辆车,同一文件只允许一个年月、一个口径版本和一个统计时点。
|
||||||
|
|
||||||
|
必填字段:`period_month`、`scheduled_for`、`captured_at`、`definition_version`、`source_reference`、`vehicle_id`、`operation_status`。空缺车牌/VIN使用空值,不造车辆 ID。ID 均按文本读取。`scheduled_for` 必须是指定年月最后一天北京时间 17:00,`captured_at` 必须来自可信历史来源,不能把导入执行时间当作历史读取时间。
|
||||||
|
|
||||||
|
其余字段见模板。部门、负责人、主体缺失须显式留空,不能用今日台账补齐;部门/个人权限因此可能看不到未归属的运营车辆,这是数据缺失而非自动扩大授权的理由。展示型号、品牌、原始车辆状态按历史来源保存;历史状态映射复用现有分类函数,不接受退出运营车辆或未知运营状态作为有效存量。归档分类由导入校验程序计算,不直接信任文件中的分类列。
|
||||||
|
|
||||||
|
状态映射:`1`/`租赁`、`2`/`自营`属于运营;`3`/`可运营`/`在库`、`4`/`待运营`属于库存;`5`/退出运营及未知值拒绝计入存量。`vehicle_status` 为历史原值,待交车或异动不改变上述归档分类。展示用 `status` 应按现有 `mapStatus` 生成。
|
||||||
|
|
||||||
|
后续导入器必须先提供 dry-run:校验日期和时区、来源、未来时间、车辆唯一性、状态、权限所需字段、计数对平及同年月冲突,再原子发布来源为 IMPORT 的批次和逐车数据。`import_provenance` 至少保存文件校验和、来源引用、导入人、导入时间、校验报告与修订原因;不要在日志保存原始敏感明细。
|
||||||
|
|
||||||
|
现有 COMPLETE 批次不自动覆盖;相同口径同月冲突应拒绝。修订需新增可追溯版本并明确有效版本切换及跨版本读取策略,不直接改已归档车辆。版本切换功能属于后续导入工作,本次只提供存储空间及契约。
|
||||||
|
|
||||||
|
仅三个汇总数字的文件不符合逐车导入规范,不能提供个人/部门历史明细。若将来支持汇总导入,需另设权限和“无明细”模型。
|
||||||
|
|
||||||
|
## 交还业务回溯的本地回填
|
||||||
|
|
||||||
|
2026-10-19 按用户确认的回溯核对表,将 **2022-06 至 2026-09 共52个月、22,319条车辆月度记录** 发布到本地私有 JSON 文件。原始核查资料截至北京时间 `2026-10-19 14:38:33`。2026-10 未结期不回填,继续走当前实时查询。未向真实数据库建表、插入或更新。
|
||||||
|
|
||||||
|
这批数据保留 `HANDOVER_REVIEW_V1` 方法、核对表文件名与 SHA-256、原始核查时间、逐车交还单号、业务时间、分类依据及未判状态数量。车辆 ID 使用字符串,候选车辆的车牌/VIN来自交还单原值;未用当前台账补公司、部门、负责人、客户或未知运营状态。
|
||||||
|
|
||||||
|
它是独立的 **RECONSTRUCTED(历史回溯·估算)** 来源,不发布为 `AUTO` / `IMPORT` 正式快照,也不填写月末实际采集时间。`counts` 为 `null`;运营/库存推算数、未分类数、当前已退出子集和来源信息在 `point.reconstruction` 中返回。总数折线保持空白,页面明确显示“总数待核实”。正式归档始终优先,不覆盖已有归档或归档失败诊断。
|
||||||
|
|
||||||
|
权限单独收紧:只有当前全量权限且未选择公司时可读取这批估算。它缺少可靠历史主体和个人/部门归属,不能按今天的车辆权限、公司或库存状态补足历史授权。部门、个人及公司筛选继续显示原有正式快照或空白;直接访问回溯明细、篡改批次或公司参数也不能放宽权限。正式归档和实时数据原有权限规则不变。
|
||||||
|
|
||||||
|
年度仅复用各年12月的回溯记录。相邻两个回溯点可比较推算运营/库存,不计算总数差值;回溯点与实时或正式快照不作差,防止把不同覆盖范围的差异显示为业务增长。回溯明细支持分类、搜索、分页,汇总与逐车记录在加载前对平;未知归属明确标为“历史未提供”。
|
||||||
|
|
||||||
|
显式本地发布命令(没有数据库依赖,默认只校验):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --import tsx scripts/backfill-asset-reconstruction.ts --input /private/reviewed-reconstruction.json
|
||||||
|
node --import tsx scripts/backfill-asset-reconstruction.ts --input /private/reviewed-reconstruction.json --output /private/asset-history/reconstruction-v1.json --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
数据文件格式为 `ReconstructionData`(`src/server/routes/asset-history/reconstruction.ts`)。必须提供来源及文件校验和,每月车辆唯一、逐车分类与计数对齐、参考月份早于原始核查时间。合法的0与未知总数区分。发布使用私有文件权限和原子创建;相同内容幂等,不同内容拒绝覆盖。文件不能放入公开静态目录或源码目录。
|
||||||
|
|
||||||
|
启动时显式设置 `ASSET_HISTORY_RECONSTRUCTION_FILE` 为已发布文件的绝对路径;没有该变量时不读取估算数据。不会启用自动采集、初始化快照表或运行其他后台任务。读取结果在进程内缓存;修订时保留原文件和核对表,发布到新文件后更新变量并重启。回退时移除该变量并重启,正式归档及实时数据不受影响。本地验收启动脚本单独注入该变量,回退需同步移除该脚本的配置。
|
||||||
|
|
||||||
|
本地验收地址仍为 `http://127.0.0.1:3000/asset#assets`。当前仅本地回填,生产未部署、采集未启用;正式存储迁移及生产回填仍需明确指定目标和生产授权。
|
||||||
|
|
||||||
|
## 验证入口
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --import tsx --test src/server/routes/asset-history/*.test.ts src/modules/assets/history/*.test.ts
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
单元、接口和事务故障测试只用模拟仓储与事务数据库替身,不访问真实数据库。开发启动若加载现有 `src/server/index.ts`,会执行其他模块的后台初始化;验收应使用隔离服务、不导入 bootstrap,并阻断所有数据库连接,避免顺带运行现有后台任务。
|
||||||
|
|
||||||
|
## 本次交付验证(2026-10-19)
|
||||||
|
|
||||||
|
- 新增 32 项针对性测试,全量 452 项通过;`npm run lint`、`npm test`、`npm run build`、`git diff --check` 均通过。
|
||||||
|
- 构建仍报告部分现有重依赖分包超过 500 kB 的体积提示;不影响构建,本次不扩展为全站拆包重构。
|
||||||
|
- 使用已有 Playwright Chromium(当前会话没有 Browser 插件),在 `http://127.0.0.1:3146/asset#assets` 隔离服务验收;测试服务未导入 bootstrap,数据库入口被阻断。该地址为临时验收环境,非生产部署。
|
||||||
|
- 1440×1200、390×844 验证交互,补充 390×1400 截图。13 组浏览器检查通过:页面身份/非空/无框架错误、月度归档及明细、分页搜索、年度、缺失/未来/零值、公司筛选、请求竞态、实时刷新不重读历史、部门及个人权限、存储未部署、503 重试、原有资产分析、手机触摸及无横向溢出。
|
||||||
|
- 浏览器无未解释的控制台错误或警告;模拟 503 的错误属于已验证的故障注入。最终验收记录中数据库访问尝试为 0。
|
||||||
|
- 参考图的卡片、堆叠柱/折线和选中期间指标保留;历史曲线按真实归档缺失留空,不复制示例数值。顶部现有资产样式保留;手机明细使用卡片而非横向表格。
|
||||||
|
- 迁移审阅命令已运行,未执行 `--apply`。事务、锁、故障和提交确认丢失在隔离数据库替身中验证,未向真实 MySQL 实例执行 DDL 或采集。生产迁移和启用需按上述授权步骤执行。
|
||||||
|
- 当前交付状态:本地实现完成,未发布,自动采集默认关闭;正式逐车快照导入仅提供契约和模板。用户确认的交还业务回溯另用上述显式本地文件发布入口,不混入正式快照。
|
||||||
|
- 回溯回填补充验证:新增8项测试,全量460项通过;类型检查与构建通过。52个月运营/库存与核对表逐月对齐,API明细分类数及分页核对通过。1440×1200、390×844完成月度、年度、明细、搜索分页、触摸、焦点关闭、公司范围及缺失月份验收;无未解释控制台错误、无横向溢出。本地只读保护记录的写入尝试为0,采集及后台任务均关闭。
|
||||||
@@ -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)。
|
||||||
@@ -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/`。模拟测试用于验证请求触发条件,不用于性能结论。
|
||||||
@@ -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`。原始响应含业务数据,不应直接发布到公共仓库或转发。
|
||||||
@@ -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、快照当前实现与复用能力、聚合接口可行性,以及可交付的第一阶段范围与排期。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Explicit migration only; API reads and module imports never create storage.
|
||||||
|
CREATE TABLE IF NOT EXISTS bi_asset_history_settings (
|
||||||
|
id TINYINT NOT NULL PRIMARY KEY,
|
||||||
|
enabled_at DATETIME(3) NOT NULL,
|
||||||
|
CONSTRAINT chk_asset_history_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bi_asset_history_batches (
|
||||||
|
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
period_month CHAR(7) NOT NULL,
|
||||||
|
definition_version VARCHAR(32) NOT NULL,
|
||||||
|
scheduled_for DATETIME(3) NOT NULL,
|
||||||
|
captured_at DATETIME(3) NULL,
|
||||||
|
completed_at DATETIME(3) NULL,
|
||||||
|
timezone VARCHAR(32) NOT NULL DEFAULT 'Asia/Shanghai',
|
||||||
|
source ENUM('AUTO','IMPORT') NOT NULL,
|
||||||
|
status ENUM('COMPLETE','FAILED','MISSED') NOT NULL,
|
||||||
|
failure_code VARCHAR(64) NULL,
|
||||||
|
total_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
operating_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
inventory_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
import_provenance JSON NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE KEY uk_asset_history_period (period_month, definition_version),
|
||||||
|
KEY idx_asset_history_status (status, period_month),
|
||||||
|
CONSTRAINT chk_asset_history_counts CHECK (total_count = operating_count + inventory_count)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bi_asset_history_vehicles (
|
||||||
|
batch_id VARCHAR(36) NOT NULL,
|
||||||
|
vehicle_id VARCHAR(64) NOT NULL,
|
||||||
|
operation_status VARCHAR(50) NOT NULL,
|
||||||
|
vehicle_status VARCHAR(50) NULL,
|
||||||
|
disposition ENUM('operating','inventory') NOT NULL,
|
||||||
|
subject_org VARCHAR(500) NULL,
|
||||||
|
department_name VARCHAR(500) NULL,
|
||||||
|
manager_id VARCHAR(64) NULL,
|
||||||
|
frozen_vehicle JSON NOT NULL,
|
||||||
|
PRIMARY KEY (batch_id, vehicle_id),
|
||||||
|
KEY idx_asset_history_subject (batch_id, subject_org(128)),
|
||||||
|
KEY idx_asset_history_manager (batch_id, manager_id),
|
||||||
|
CONSTRAINT fk_asset_history_batch FOREIGN KEY (batch_id) REFERENCES bi_asset_history_batches (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Does not load dotenv, database pools, application bootstrap or background jobs.
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { resolve, relative, sep } from 'node:path';
|
||||||
|
import { publishReconstruction, validateReconstruction } from '../src/server/routes/asset-history/reconstruction.js';
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const inputIndex = args.indexOf('--input'), outputIndex = args.indexOf('--output');
|
||||||
|
if (inputIndex < 0 || !args[inputIndex + 1]) throw new Error('需要 --input 指定已确认的回溯文件');
|
||||||
|
const data = validateReconstruction(JSON.parse(await readFile(resolve(args[inputIndex + 1]), 'utf8')));
|
||||||
|
console.log(JSON.stringify({ action: args.includes('--apply') ? 'local-file-publication' : 'dry-run', months: data.periods.length,
|
||||||
|
firstMonth: data.periods[0]?.month, lastMonth: data.periods.at(-1)?.month, sourceAsOf: data.sourceAsOf,
|
||||||
|
workbookSha256: data.workbookSha256, historicalTotal: null, databaseWrites: 0 }));
|
||||||
|
if (args.includes('--apply')) {
|
||||||
|
if (outputIndex < 0 || !args[outputIndex + 1]) throw new Error('需要 --output 指定本地私有存储位置');
|
||||||
|
const output = resolve(args[outputIndex + 1]);
|
||||||
|
for (const folder of ['public', 'dist', 'src']) {
|
||||||
|
const within = relative(resolve(folder), output);
|
||||||
|
if (!within || (!within.startsWith(`..${sep}`) && within !== '..' && !within.startsWith(sep))) throw new Error('回溯文件必须保存于源码及公开静态目录之外');
|
||||||
|
}
|
||||||
|
console.log(await publishReconstruction(output, data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
const sql = await readFile(new URL('../migrations/20261019_asset_history.sql', import.meta.url), 'utf8');
|
||||||
|
if (!process.argv.includes('--apply')) {
|
||||||
|
console.log(sql);
|
||||||
|
console.log('Review only. Pass --apply only after approving the configured database migration.');
|
||||||
|
} else {
|
||||||
|
const { default: pool } = await import('../src/server/db.js');
|
||||||
|
try {
|
||||||
|
for (const statement of sql.split(/;\s*(?:\n|$)/).filter(s => s.trim())) await pool.query(statement);
|
||||||
|
console.log('Asset history schema ready. Capture is still disabled unless ASSET_HISTORY_CAPTURE_ENABLED=1.');
|
||||||
|
} finally { await pool.end(); }
|
||||||
|
}
|
||||||
@@ -102,6 +102,10 @@ export default function AssetsModule() {
|
|||||||
// 所属公司(归属主体)筛选 —— 影响全页聚合
|
// 所属公司(归属主体)筛选 —— 影响全页聚合
|
||||||
const [selectedSubject, setSelectedSubject] = useState<string | null>(null);
|
const [selectedSubject, setSelectedSubject] = useState<string | null>(null);
|
||||||
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
|
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
|
||||||
|
const [historySubjects, setHistorySubjects] = useState<string[]>([]);
|
||||||
|
const updateHistorySubjects = useCallback((names: string[]) => setHistorySubjects(names), []);
|
||||||
|
const subjectOptions = useMemo(() => [...subjects, ...historySubjects.filter(name => !subjects.some(s => s.name === name))
|
||||||
|
.map(name => ({ name, total: 0, operating: 0, inventory: 0, historicalOnly: true }))], [subjects, historySubjects]);
|
||||||
const [subjectDropdownOpen, setSubjectDropdownOpen] = useState(false);
|
const [subjectDropdownOpen, setSubjectDropdownOpen] = useState(false);
|
||||||
const [subjectSearch, setSubjectSearch] = useState('');
|
const [subjectSearch, setSubjectSearch] = useState('');
|
||||||
const subjectDropdownRef = useRef<HTMLDivElement>(null);
|
const subjectDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -485,7 +489,7 @@ export default function AssetsModule() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
selectedSubject={selectedSubject}
|
selectedSubject={selectedSubject}
|
||||||
setSelectedSubject={setSelectedSubject}
|
setSelectedSubject={setSelectedSubject}
|
||||||
subjects={subjects}
|
subjects={subjectOptions}
|
||||||
subjectDropdownOpen={subjectDropdownOpen}
|
subjectDropdownOpen={subjectDropdownOpen}
|
||||||
setSubjectDropdownOpen={setSubjectDropdownOpen}
|
setSubjectDropdownOpen={setSubjectDropdownOpen}
|
||||||
subjectSearch={subjectSearch}
|
subjectSearch={subjectSearch}
|
||||||
@@ -504,6 +508,8 @@ export default function AssetsModule() {
|
|||||||
|
|
||||||
{tabReady && activeTab === 'overview' && (
|
{tabReady && activeTab === 'overview' && (
|
||||||
<OverviewView
|
<OverviewView
|
||||||
|
selectedSubject={selectedSubject}
|
||||||
|
onHistorySubjects={updateHistorySubjects}
|
||||||
summary={SUMMARY}
|
summary={SUMMARY}
|
||||||
operatingRate={operatingRate}
|
operatingRate={operatingRate}
|
||||||
inventoryRate={inventoryRate}
|
inventoryRate={inventoryRate}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { fetchJson } from '../../auth/api-client';
|
|||||||
const BASE = '/api/vehicles';
|
const BASE = '/api/vehicles';
|
||||||
|
|
||||||
export interface SubjectOption {
|
export interface SubjectOption {
|
||||||
|
historicalOnly?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
total: number;
|
total: number;
|
||||||
inventory: number;
|
inventory: number;
|
||||||
|
|||||||
@@ -170,9 +170,9 @@ export function AssetsHeader({
|
|||||||
<span className="truncate">{s.name}</span>
|
<span className="truncate">{s.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||||
{s.total} 台
|
{s.historicalOnly ? '历史主体' : <>{s.total} 台
|
||||||
<span className="mx-1 text-gray-200">·</span>
|
<span className="mx-1 text-gray-200">·</span>
|
||||||
<span className="text-green-500">运营 {s.operating}</span>
|
<span className="text-green-500">运营 {s.operating}</span></>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AssetSummarySection } from './overview/AssetSummarySection';
|
|||||||
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||||
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||||
import { SummaryMetrics } from './overview/SummaryMetrics';
|
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||||
|
import { AssetHistorySection } from '../history/AssetHistorySection';
|
||||||
import type { OverviewViewProps } from './overview/types';
|
import type { OverviewViewProps } from './overview/types';
|
||||||
|
|
||||||
export function OverviewView(props: OverviewViewProps) {
|
export function OverviewView(props: OverviewViewProps) {
|
||||||
@@ -15,6 +16,8 @@ export function OverviewView(props: OverviewViewProps) {
|
|||||||
setVehicleSelection={props.setVehicleSelection}
|
setVehicleSelection={props.setVehicleSelection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
|
||||||
|
|
||||||
<FlowStatisticsCard
|
<FlowStatisticsCard
|
||||||
flowRange={props.flowRange}
|
flowRange={props.flowRange}
|
||||||
setFlowRange={props.setFlowRange}
|
setFlowRange={props.setFlowRange}
|
||||||
|
|||||||
@@ -66,17 +66,17 @@ export function SummaryMetrics({
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-3 py-2 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||||
<div className="grid min-w-0 flex-1 grid-cols-2 divide-x divide-slate-100 text-center">
|
<div className="grid min-w-0 flex-1 grid-cols-2 divide-x divide-slate-100 text-center">
|
||||||
<div className="px-2">
|
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2">
|
||||||
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||||
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
<div className="text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2">
|
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2">
|
||||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
<div className="text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export type OverviewViewProps = SummaryMetricsProps
|
|||||||
& AssetSummarySectionProps
|
& AssetSummarySectionProps
|
||||||
& InventoryStatisticsSectionProps
|
& InventoryStatisticsSectionProps
|
||||||
& {
|
& {
|
||||||
|
selectedSubject: string | null;
|
||||||
|
onHistorySubjects: (names: string[]) => void;
|
||||||
allTypesExpanded: boolean;
|
allTypesExpanded: boolean;
|
||||||
toggleAllAssetTypes: () => void;
|
toggleAllAssetTypes: () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useCallback, useEffect, useState, type MouseEvent } from 'react';
|
||||||
|
import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts';
|
||||||
|
import { ChevronRight, RefreshCw } from 'lucide-react';
|
||||||
|
import { HISTORY_STATE_LABELS, historyTime, shanghaiMonth, type HistoryMode, type HistoryPoint, type HistoryTrend } from '../../../shared/assets/history';
|
||||||
|
import { fetchHistoryLive, fetchHistoryTrend } from './api';
|
||||||
|
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation';
|
||||||
|
import { HistoryDetailsDialog } from './HistoryDetailsDialog';
|
||||||
|
import { HistorySelect } from './HistorySelect';
|
||||||
|
import './history.css';
|
||||||
|
|
||||||
|
export function AssetHistorySection({ subject, onSubjects }: { subject: string | null; onSubjects?: (names: string[]) => void }) {
|
||||||
|
const [mode, setMode] = useState<HistoryMode>('monthly');
|
||||||
|
const [year, setYear] = useState(() => Number(shanghaiMonth(new Date()).slice(0, 4)));
|
||||||
|
const [data, setData] = useState<HistoryTrend | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [details, setDetails] = useState<HistoryPoint | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [retry, setRetry] = useState(0);
|
||||||
|
const closeDetails = useCallback(() => setDetails(null), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
let current: HistoryTrend | null = null;
|
||||||
|
let busy = false;
|
||||||
|
let observedMonth = shanghaiMonth(new Date());
|
||||||
|
setData(null); setSelected(null); setDetails(null); setError(''); setLoading(true);
|
||||||
|
const load = async () => {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await fetchHistoryTrend(mode, year, subject, abort.signal);
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
current = result; setData(result); setSelected(old => old && result.points.some(p => p.period === old) ? old : defaultHistoryPeriod(result.points));
|
||||||
|
onSubjects?.(result.subjects); setError('');
|
||||||
|
} catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '趋势加载失败'); }
|
||||||
|
finally { busy = false; if (!abort.signal.aborted) setLoading(false); }
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (busy || !current || abort.signal.aborted) return;
|
||||||
|
const month = shanghaiMonth(new Date());
|
||||||
|
if (month !== observedMonth) { observedMonth = month; await load(); return; }
|
||||||
|
if (current.points.some(p => p.state === 'CAPTURING')) { await load(); return; }
|
||||||
|
if (!current.points.some(p => p.state === 'LIVE')) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const live = await fetchHistoryLive(mode, subject, abort.signal);
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
if (live.state !== 'LIVE') { await load(); return; }
|
||||||
|
current = { ...current, points: updateLivePoint(current.points, live) }; setData(current); setError('');
|
||||||
|
} catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '实时数据刷新失败'); }
|
||||||
|
finally { busy = false; }
|
||||||
|
}, 5000);
|
||||||
|
return () => { abort.abort(); clearInterval(interval); };
|
||||||
|
}, [mode, year, subject, retry, onSubjects]);
|
||||||
|
|
||||||
|
const point = data?.points.find(p => p.period === selected);
|
||||||
|
const hasComparison = point && (['total', 'inventory', 'operating'] as const).some(key => displayHistoryDifference(point, key) != null);
|
||||||
|
const chart = data?.points.map(p => ({ ...p, total: displayHistoryCounts(p)?.total ?? null, operating: displayHistoryCounts(p)?.operating ?? null, inventory: displayHistoryCounts(p)?.inventory ?? null })) || [];
|
||||||
|
const selectBar = (entry: { originalDataIndex: number }, _index: number, event: MouseEvent<SVGPathElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const item = chart[entry.originalDataIndex];
|
||||||
|
if (item) setSelected(item.period);
|
||||||
|
};
|
||||||
|
return <section className="asset-history" aria-labelledby="asset-history-title" data-testid="asset-history">
|
||||||
|
<div className="asset-history-controls"><header className="asset-history-header"><h2 id="asset-history-title">资产历史趋势</h2>
|
||||||
|
<div className="asset-history-modes" role="group" aria-label="历史统计方式">{(['annual', 'monthly'] as const).map(value => <button type="button" key={value} aria-pressed={mode === value} onClick={() => { setMode(value); setDetails(null); }}>{value === 'annual' ? '年度' : '月度'}</button>)}</div>
|
||||||
|
</header>
|
||||||
|
<div className="asset-history-toolbar">
|
||||||
|
{mode === 'monthly' && <HistorySelect label="历史年份" value={String(year)} options={(data?.years || [year]).map(y => ({ value: String(y), label: `${y}年` }))} onChange={value => { setYear(Number(value)); setDetails(null); }} />}
|
||||||
|
{data && <HistorySelect label="统计期间" value={selected || ''} onChange={value => { setSelected(value); setDetails(null); }} options={data.points.map(p => ({ value: p.period, label: p.label, detail: p.state === 'RECONSTRUCTED' ? '估算' : p.state === 'LIVE' ? '实时' : p.state === 'ARCHIVED' ? '归档' : p.state === 'FUTURE' ? '未来' : p.state === 'MISSING' ? '缺失' : HISTORY_STATE_LABELS[p.state] }))} />}
|
||||||
|
<span title="每月最后一天北京时间17:00采集;年度复用12月快照">月末 17:00</span>
|
||||||
|
<button type="button" className="asset-history-refresh" aria-label="刷新资产历史趋势" disabled={loading} onClick={() => setRetry(x => x + 1)}><RefreshCw size={15} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading && !data ? <p className="asset-history-empty" role="status">正在加载资产历史趋势…</p> : !data ? <p className="asset-history-empty" role="alert">{error || '暂无可用数据'} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : <>
|
||||||
|
{error && <p className="asset-history-warning" role="alert">{error} · 保留上次查询结果 <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p>}
|
||||||
|
<p className="asset-history-unit">数量(台)</p>
|
||||||
|
<div className="asset-history-chart" role="img" aria-label="运营数与库存数堆叠柱,资产总数折线;精确数值和缺失状态可通过上方统计期间选择框查看">
|
||||||
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}>
|
||||||
|
<ComposedChart data={chart} margin={{ top: 10, right: 14, bottom: 0, left: 0 }} onClick={state => { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}>
|
||||||
|
<CartesianGrid stroke="#e9eff7" vertical={false} /><XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} axisLine={{ stroke: '#dbe4ef' }} tickLine={false} interval={mode === 'monthly' ? 0 : 'preserveStartEnd'} minTickGap={4} />
|
||||||
|
<YAxis allowDecimals={false} width={44} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip content={({ active, payload }) => {
|
||||||
|
const item = payload?.[0]?.payload as HistoryPoint | undefined;
|
||||||
|
const values = item && displayHistoryCounts(item);
|
||||||
|
return active && item ? <div className="asset-history-tooltip"><strong>{item.label} · {HISTORY_STATE_LABELS[item.state]}</strong>{values ? <p>总数 {values.total ?? '待核实'} · 运营 {values.operating} · 库存 {values.inventory}</p> : <p>暂无数量</p>}</div> : null;
|
||||||
|
}} />
|
||||||
|
<Bar onClick={selectBar} dataKey="operating" name="运营数" stackId="assets" fill="#347df5" maxBarSize={32} isAnimationActive={false} />
|
||||||
|
<Bar onClick={selectBar} dataKey="inventory" name="库存数" stackId="assets" fill="#9ed1fa" maxBarSize={32} isAnimationActive={false} />
|
||||||
|
<Line dataKey="total" name="资产总数" stroke="#132e57" strokeWidth={2} connectNulls={false} dot={{ r: 3, fill: '#fff', strokeWidth: 2 }} activeDot={{ r: 5 }} isAnimationActive={false}><LabelList dataKey="total" position="top" offset={8} fontSize={10} fill="#132e57" /></Line>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="asset-history-legend" aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span></div>
|
||||||
|
{point && <div className="asset-history-selected" aria-live="polite">
|
||||||
|
<div className="asset-history-selected-heading"><strong>{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}</strong><span>{HISTORY_STATE_LABELS[point.state]}</span>{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}</div>
|
||||||
|
<div className="asset-history-counts">{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) => <div key={key}><span aria-label={`${label}(台)`}>{label}(台)</span><strong>{displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}</strong></div>)}</div>
|
||||||
|
{hasComparison ? <div className="asset-history-comparison"><span>{point.comparisonLabel}</span><div>{(['total', 'inventory', 'operating'] as const).map(key => {
|
||||||
|
const difference = displayHistoryDifference(point, key);
|
||||||
|
return <span key={key} className={difference != null && difference > 0 ? 'asset-history-increase' : difference != null && difference < 0 ? 'asset-history-decrease' : ''}>{difference != null ? signedCount(difference) : '—'}</span>;
|
||||||
|
})}</div></div> : <p>{point.comparisonLabel}</p>}
|
||||||
|
</div>}
|
||||||
|
{point && mode === 'monthly' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={18} /></button>}
|
||||||
|
<p className="asset-history-footnote">历史库存状态快照缺失,核对中,暂不展示。</p>
|
||||||
|
</>}
|
||||||
|
{details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useDialogFocus } from '../components/useDialogFocus';
|
||||||
|
import { HISTORY_STATE_LABELS, historyTime, type HistoryCategory, type HistoryDetails, type HistoryPoint } from '../../../shared/assets/history';
|
||||||
|
import { fetchHistoryDetails } from './api';
|
||||||
|
import { displayHistoryCounts } from './presentation';
|
||||||
|
|
||||||
|
export function HistoryDetailsDialog({ point, subject, close }: { point: HistoryPoint; subject: string | null; close: () => void }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [category, setCategory] = useState<HistoryCategory>('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [data, setData] = useState<HistoryDetails | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [retry, setRetry] = useState(0);
|
||||||
|
useDialogFocus(true, ref, close);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => { setQuery(search.trim()); setPage(1); }, 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
setLoading(true); setError(''); setData(null);
|
||||||
|
fetchHistoryDetails(point.month, point.batchId, subject, category, query, page, abort.signal)
|
||||||
|
.then(result => { if (!abort.signal.aborted) setData(result); })
|
||||||
|
.catch(e => { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '明细加载失败'); })
|
||||||
|
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [point.month, point.batchId, subject, category, query, page, retry]);
|
||||||
|
const pages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
|
||||||
|
return createPortal(<div className="asset-history-overlay" onMouseDown={e => { if (e.target === e.currentTarget) close(); }}>
|
||||||
|
<div className="asset-history-dialog" role="dialog" aria-modal="true" aria-labelledby="asset-history-detail-title" tabIndex={-1} ref={ref}>
|
||||||
|
<header><div><h2 id="asset-history-detail-title">{point.month.replace('-', '年')}月资产明细</h2>
|
||||||
|
<p>{subject || '全部公司'} · {HISTORY_STATE_LABELS[point.state]} · {point.reconstruction ? '参考时点' : point.state === 'ARCHIVED' ? '快照' : '截至'} {historyTime(point.reconstruction ? point.scheduledFor : data?.point.asOf || point.asOf)}</p></div>
|
||||||
|
<button type="button" onClick={close} aria-label="关闭月度明细"><X size={20} /></button></header>
|
||||||
|
<div className="asset-history-detail-tools">
|
||||||
|
<label>车辆分类<select aria-label="月度明细分类" value={category} onChange={e => { setCategory(e.target.value as HistoryCategory); setPage(1); }}>
|
||||||
|
<option value="all">{point.reconstruction ? '全部推算车辆' : '全部资产'}</option><option value="operating">运营车辆</option><option value="inventory">库存车辆</option></select></label>
|
||||||
|
<label className="asset-history-search"><Search size={16} /><input aria-label="搜索历史车牌或VIN" placeholder="搜索车牌 / VIN" value={search} onChange={e => setSearch(e.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
<div className="asset-history-detail-body" aria-busy={loading} aria-live="polite">
|
||||||
|
{loading ? <p className="asset-history-empty">正在加载月度明细…</p> : error ? <p className="asset-history-empty" role="alert">{error} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : data && <>
|
||||||
|
<p className="asset-history-detail-note">{data.point.reconstruction ? '以下为核对表中的交还记录推算车辆,归属未知项留空。历史总数待核实,不能将两类相加当作历史资产总数。' : data.point.state === 'ARCHIVED' ? '以下为当时归档的车辆及归属信息。' : '以下为最新实时明细,数量可能随业务更新。'} 当前范围:总数 {displayHistoryCounts(data.point)?.total ?? '待核实'} · 运营 {displayHistoryCounts(data.point)?.operating} · 库存 {displayHistoryCounts(data.point)?.inventory}</p>
|
||||||
|
{data.items.length ? <><div className="asset-history-detail-table"><table><thead><tr><th>车辆</th><th>分类</th><th>公司 / 部门</th><th>负责人 / 客户</th></tr></thead>
|
||||||
|
<tbody>{data.items.map(item => <tr key={item.id}><td><strong>{item.plateNumber || '未登记车牌'}</strong><small>{item.model}</small><small>VIN:{item.vin || '未提供'}</small>{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}</td>
|
||||||
|
<td>{item.disposition === 'operating' ? '运营' : '库存'}</td><td>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></td>
|
||||||
|
<td>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small><small>{item.contractNo || ''}</small></td></tr>)}</tbody></table></div>
|
||||||
|
<div className="asset-history-mobile-vehicles">{data.items.map(item => <article key={item.id}>
|
||||||
|
<div className="asset-history-mobile-vehicle-heading"><strong>{item.plateNumber || '未登记车牌'}</strong><span>{item.disposition === 'operating' ? '运营' : '库存'}</span></div>
|
||||||
|
<p>{item.model}</p><small>VIN:{item.vin || '未提供'}</small>
|
||||||
|
{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}
|
||||||
|
<dl><div><dt>公司 / 部门</dt><dd>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></dd></div>
|
||||||
|
<div><dt>负责人 / 客户</dt><dd>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small></dd></div></dl>
|
||||||
|
{item.contractNo && <small>合同:{item.contractNo}</small>}
|
||||||
|
</article>)}</div></> : <p className="asset-history-empty">当前条件下没有车辆</p>}
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
<footer><span>{data ? `${point.reconstruction ? '推算' : '筛选'}结果 ${data.total} 台 · 第 ${page} / ${pages} 页` : '每页 30 台'}</span><div>
|
||||||
|
<button type="button" aria-label="上一页" disabled={loading || !data || page <= 1} onClick={() => setPage(x => x - 1)}><ChevronLeft size={16} /></button>
|
||||||
|
<button type="button" aria-label="下一页" disabled={loading || !data || page >= pages} onClick={() => setPage(x => x + 1)}><ChevronRight size={16} /></button></div></footer>
|
||||||
|
</div>
|
||||||
|
</div>, document.body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Select } from 'radix-ui';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface HistorySelectProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: { value: string; label: string; detail?: string }[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistorySelect({ label, value, options, onChange }: HistorySelectProps) {
|
||||||
|
return <Select.Root value={value} onValueChange={onChange}>
|
||||||
|
<Select.Trigger className="asset-history-select" aria-label={label}>
|
||||||
|
<Select.Value />
|
||||||
|
<Select.Icon className="asset-history-select-icon"><ChevronDown size={14} /></Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content className="asset-history-select-menu" position="popper" align="start" sideOffset={6} collisionPadding={10}>
|
||||||
|
<Select.ScrollUpButton className="asset-history-select-scroll"><ChevronUp size={14} /></Select.ScrollUpButton>
|
||||||
|
<Select.Viewport className="asset-history-select-options">
|
||||||
|
{options.map(option => <Select.Item className="asset-history-select-option" key={option.value} value={option.value} textValue={option.label}>
|
||||||
|
<Select.ItemText>{option.label}</Select.ItemText>
|
||||||
|
{option.detail && <span className="asset-history-option-detail">{option.detail}</span>}
|
||||||
|
<Select.ItemIndicator className="asset-history-option-check"><Check size={14} /></Select.ItemIndicator>
|
||||||
|
</Select.Item>)}
|
||||||
|
</Select.Viewport>
|
||||||
|
<Select.ScrollDownButton className="asset-history-select-scroll"><ChevronDown size={14} /></Select.ScrollDownButton>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select.Root>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { fetchAuthenticated } from '../../../auth/api-client';
|
||||||
|
import type { HistoryMode, HistoryTrend, HistoryPoint, HistoryDetails, HistoryCategory } from '../../../shared/assets/history';
|
||||||
|
|
||||||
|
async function get<T>(path: string, params: Record<string, string | number | null | undefined>, signal: AbortSignal): Promise<T> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) if (value != null && value !== '') query.set(key, String(value));
|
||||||
|
const response = await fetchAuthenticated(`/api/asset-history/${path}?${query}`, { signal });
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok) throw new Error(body.error || '历史趋势加载失败,请重试');
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
export function fetchHistoryTrend(mode: HistoryMode, year: number, subject: string | null, signal: AbortSignal) {
|
||||||
|
return get<HistoryTrend>('trend', { mode, year, subject }, signal);
|
||||||
|
}
|
||||||
|
export function fetchHistoryLive(mode: HistoryMode, subject: string | null, signal: AbortSignal) {
|
||||||
|
return get<HistoryPoint>('live', { mode, subject }, signal);
|
||||||
|
}
|
||||||
|
export function fetchHistoryDetails(month: string, batchId: string | null, subject: string | null, category: HistoryCategory, search: string, page: number, signal: AbortSignal) {
|
||||||
|
return get<HistoryDetails>('details', { month, batchId, subject, category, search, page, pageSize: 30 }, signal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
.asset-history { --history-blue:#1559e8; margin:8px 0 16px; padding:16px; border:1px solid #e6edf5; border-radius:20px; background:#fff; color:#142b4e; box-shadow:0 5px 24px rgb(22 53 90 / 3%); }
|
||||||
|
.asset-history button,.asset-history-dialog button { cursor:pointer; }
|
||||||
|
.asset-history button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; }
|
||||||
|
.asset-history button:focus-visible,.asset-history select:focus-visible,.asset-history-dialog :is(button,input,select):focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
|
||||||
|
.asset-history-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||||
|
.asset-history-header h2 { margin:0; font-size:20px; font-weight:750; letter-spacing:-.5px; }
|
||||||
|
.asset-history-modes { display:flex; gap:2px; background:#eef3fa; border:1px solid #e7edf6; border-radius:11px; padding:3px; }
|
||||||
|
.asset-history-modes button { padding:7px 20px; min-height:34px; border-radius:8px; font-size:13px; font-weight:600; transition:background .15s,color .15s,box-shadow .15s; color:#64748b; }
|
||||||
|
.asset-history-modes button[aria-pressed=true] { background:#fff; color:#1559e8; box-shadow:0 1px 4px #193c6920, inset 0 0 0 1px #dce7f5; }
|
||||||
|
.asset-history-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0 4px; font-size:12px; color:#64748b; }
|
||||||
|
.asset-history-toolbar>span { margin-left:auto; }
|
||||||
|
.asset-history-toolbar .asset-history-annual-note { margin-left:0; margin-right:auto; }
|
||||||
|
.asset-history-refresh { display:grid; place-items:center; padding:8px; border-radius:8px; color:#7c8da5; transition:background .15s,color .15s; }
|
||||||
|
.asset-history-warning { margin:8px 0; font-size:12px; line-height:1.7; color:#74613a; background:#fff9ec; padding:9px 12px; border-radius:9px; }
|
||||||
|
.asset-history-warning button,.asset-history-empty button { color:#1559e8; text-decoration:underline; padding:6px; }
|
||||||
|
.asset-history-unit { font-size:12px; color:#64748b; margin:6px 0 0; }
|
||||||
|
.asset-history-chart { height:200px; width:100%; min-width:0; }
|
||||||
|
.asset-history-tooltip { background:#fff; border:1px solid #dce5ef; border-radius:10px; padding:10px 12px; box-shadow:0 4px 16px #142b4e15; font-size:12px; }
|
||||||
|
.asset-history-tooltip p { margin-top:4px; }
|
||||||
|
.asset-history-legend { display:flex; justify-content:center; flex-wrap:wrap; gap:22px; color:#64748b; font-size:12px; margin:6px 0; }
|
||||||
|
.asset-history-legend span { display:flex; align-items:center; gap:7px; }
|
||||||
|
.asset-history-legend i { display:inline-block; height:11px; width:11px; border-radius:2px; }
|
||||||
|
.asset-history-legend .operating { background:#347df5; }.asset-history-legend .inventory { background:#9ed1fa; }
|
||||||
|
.asset-history-legend .total { width:20px; height:2px; background:#132e57; }
|
||||||
|
.asset-history-selected { margin-top:8px; display:grid; grid-template-columns:90px minmax(0,1fr); align-items:center; gap:0 10px; padding:10px 14px; background:linear-gradient(115deg,#f3f7fe,#edf5ff); border:1px solid #e7effb; border-radius:12px; }
|
||||||
|
.asset-history-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; }
|
||||||
|
.asset-history-selected-heading>strong { color:#1559e8; font-size:17px; font-weight:750; }
|
||||||
|
.asset-history-selected-heading>span,.asset-history-selected-heading>time { color:#64748b; font-size:11px; }
|
||||||
|
.asset-history-counts { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||||
|
.asset-history-counts>div { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; min-width:0; border-right:1px solid #d4e2f4; }
|
||||||
|
.asset-history-counts>div:last-child { border:0; }
|
||||||
|
.asset-history-counts>div>span { color:#64748b; font-size:12px; }
|
||||||
|
.asset-history-counts strong { font-size:23px; font-weight:750; line-height:1.2; font-variant-numeric:tabular-nums; }
|
||||||
|
.asset-history-increase { color:#bf2f30; }.asset-history-decrease { color:#14864a; }
|
||||||
|
.asset-history-selected>p { grid-column:1 / -1; color:#64748b; font-size:11px; text-align:center; padding-top:6px; }
|
||||||
|
.asset-history-detail-button { display:flex; width:100%; align-items:center; justify-content:space-between; min-height:44px; padding:8px 12px; margin-top:8px; border:1px solid #dce8f8; border-radius:10px; background:#f7faff; color:#18385d; font-size:14px; font-weight:600; }
|
||||||
|
.asset-history-footnote { display:flex; align-items:center; flex-wrap:wrap; gap:6px; color:#64748b; font-size:11px; margin-top:8px; }
|
||||||
|
.asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:#64748b; }
|
||||||
|
.asset-history-overlay { position:fixed; inset:0; z-index:120; background:#0c1e3b66; display:flex; justify-content:center; align-items:center; padding:24px; }
|
||||||
|
.asset-history-dialog { display:flex; flex-direction:column; width:min(980px,100%); max-height:88dvh; border-radius:18px; background:white; color:#183454; box-shadow:0 24px 64px #142b4e30; overflow:hidden; outline:none; }
|
||||||
|
.asset-history-dialog>header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:20px 22px; border-bottom:1px solid #e6edf5; }
|
||||||
|
.asset-history-dialog h2 { font-size:18px; font-weight:700; }
|
||||||
|
.asset-history-dialog header p { font-size:12px; line-height:1.7; margin-top:5px; color:#64748b; }
|
||||||
|
.asset-history-dialog header button { padding:8px; flex-shrink:0; }
|
||||||
|
.asset-history-detail-tools { display:flex; flex-wrap:wrap; gap:12px; padding:14px 22px; border-bottom:1px solid #e6edf5; }
|
||||||
|
.asset-history-detail-tools label { display:flex; align-items:center; gap:8px; font-size:12px; color:#64748b; }
|
||||||
|
.asset-history-detail-tools select { border:1px solid #dce5f0; border-radius:7px; padding:8px; color:#183454; background:white; }
|
||||||
|
.asset-history-search { border:1px solid #dce5f0; border-radius:7px; padding:6px 10px; flex:1; min-width:170px; }
|
||||||
|
.asset-history-search input { width:100%; min-width:0; padding:3px 0; outline:none; color:#183454; }
|
||||||
|
.asset-history-detail-body { overflow:auto; flex:1; min-height:100px; }
|
||||||
|
.asset-history-detail-note { font-size:12px; line-height:1.8; padding:12px 22px; color:#64748b; background:#f8fbff; }
|
||||||
|
.asset-history-detail-table { overflow:auto; }
|
||||||
|
.asset-history-detail-table table { width:100%; border-collapse:collapse; min-width:650px; font-size:12px; }
|
||||||
|
.asset-history-detail-table th { text-align:left; background:#f7faff; color:#64748b; font-weight:500; padding:12px 20px; }
|
||||||
|
.asset-history-detail-table td { vertical-align:top; padding:14px 20px; border-bottom:1px solid #edf2f8; max-width:280px; overflow-wrap:anywhere; }
|
||||||
|
.asset-history-detail-table td strong { font-size:13px; font-weight:650; }.asset-history-detail-table small { display:block; font-size:11px; color:#64748b; margin-top:5px; }
|
||||||
|
.asset-history-mobile-vehicles { display:none; }
|
||||||
|
.asset-history-dialog footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 22px; border-top:1px solid #e6edf5; color:#64748b; font-size:12px; }
|
||||||
|
.asset-history-dialog footer>div { display:flex; gap:8px; }.asset-history-dialog footer button { padding:9px; border:1px solid #dce5f0; border-radius:7px; }
|
||||||
|
@media(max-width:640px) {
|
||||||
|
.asset-history { padding:12px; border-radius:16px; }
|
||||||
|
.asset-history-header h2 { font-size:18px; }.asset-history-modes button { padding:7px 15px; min-height:38px; }
|
||||||
|
.asset-history-toolbar { gap:6px; }.asset-history-toolbar>span { font-size:10px; }
|
||||||
|
.asset-history-chart { height:170px; }
|
||||||
|
.asset-history-selected { grid-template-columns:72px minmax(0,1fr); gap:0 6px; padding:8px 10px; }.asset-history-selected-heading { gap:2px; }.asset-history-selected-heading>span,.asset-history-selected-heading>time { font-size:10px; }
|
||||||
|
.asset-history-counts strong { font-size:21px; }.asset-history-counts>div>span { font-size:11px; }
|
||||||
|
.asset-history-overlay { padding:10px; }.asset-history-dialog { max-height:94dvh; border-radius:14px; }
|
||||||
|
.asset-history-dialog>header { padding:14px; }.asset-history-dialog h2 { font-size:16px; }.asset-history-detail-tools { padding:12px 14px; }
|
||||||
|
.asset-history-detail-note { padding:10px 14px; }.asset-history-dialog :is(input,select) { font-size:16px; }.asset-history-dialog footer { padding:12px 14px; }
|
||||||
|
.asset-history-dialog :is(header,footer) button { min-height:44px; min-width:44px; }
|
||||||
|
.asset-history-detail-table { display:none; }
|
||||||
|
.asset-history-mobile-vehicles { display:block; padding:0 14px; }
|
||||||
|
.asset-history-mobile-vehicles article { padding:16px 0; border-bottom:1px solid #e6edf5; overflow-wrap:anywhere; }
|
||||||
|
.asset-history-mobile-vehicle-heading { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||||
|
.asset-history-mobile-vehicle-heading strong { font-size:14px; font-weight:700; }.asset-history-mobile-vehicle-heading span { font-size:11px; color:#1559e8; background:#edf5ff; border-radius:5px; padding:3px 8px; }
|
||||||
|
.asset-history-mobile-vehicles p { margin-top:6px; font-size:12px; color:#475569; }.asset-history-mobile-vehicles small { display:block; font-size:11px; color:#64748b; margin-top:5px; }
|
||||||
|
.asset-history-mobile-vehicles dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:12px 0; font-size:12px; }
|
||||||
|
.asset-history-mobile-vehicles dt { font-size:11px; color:#64748b; margin-bottom:5px; }.asset-history-mobile-vehicles dd { margin:0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(min-width:900px) {
|
||||||
|
.asset-history-controls { display:flex; align-items:center; flex-wrap:wrap; gap:8px 24px; }
|
||||||
|
.asset-history-header { flex:1; gap:16px; }
|
||||||
|
.asset-history-toolbar { margin:0; }
|
||||||
|
.asset-history-selected { display:grid; grid-template-columns:minmax(150px,1fr) 3fr; align-items:center; gap:0 18px; }
|
||||||
|
.asset-history-selected-heading { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:2px 12px; margin:0; }
|
||||||
|
.asset-history-selected-heading>strong { grid-row:span 2; }
|
||||||
|
.asset-history-selected-heading>span,.asset-history-selected-heading>time { grid-column:2; }
|
||||||
|
.asset-history-selected>p { grid-column:1 / -1; }
|
||||||
|
}
|
||||||
|
@media(max-width:640px) {
|
||||||
|
.asset-history-modes button,.asset-history-refresh { min-height:44px; }
|
||||||
|
.asset-history-refresh { min-width:44px; }
|
||||||
|
.asset-history-toolbar { gap:4px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-history-comparison { grid-column:1 / -1; display:grid; grid-template-columns:90px minmax(0,1fr); gap:10px; align-items:center; margin-top:6px; padding-top:6px; border-top:1px solid #d4e2f4; font-size:11px; color:#64748b; }
|
||||||
|
.asset-history-comparison>div { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); text-align:center; font-size:13px; font-weight:600; }
|
||||||
|
@media(min-width:900px) {
|
||||||
|
.asset-history-comparison { grid-template-columns:minmax(150px,1fr) 3fr; gap:18px; }
|
||||||
|
}
|
||||||
|
@media(max-width:640px) {
|
||||||
|
.asset-history-comparison { grid-template-columns:72px minmax(0,1fr); gap:6px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-history-select { display:inline-flex; align-items:center; justify-content:space-between; gap:12px; min-width:76px; min-height:36px; padding:7px 11px; border:1px solid #dce5f1; border-radius:9px; background:#fff; color:#254365; font-size:13px; font-weight:550; box-shadow:0 1px 2px #173c6310; transition:border-color .15s,box-shadow .15s; }
|
||||||
|
.asset-history-select[data-state=open] { border-color:#8fb4f2; box-shadow:0 0 0 3px #dbeaff80; }
|
||||||
|
.asset-history-select-icon { color:#8b9db5; display:flex; transition:transform .15s; }
|
||||||
|
.asset-history-select[data-state=open] .asset-history-select-icon { transform:rotate(180deg); }
|
||||||
|
.asset-history-select-menu { z-index:160; min-width:max(132px,var(--radix-select-trigger-width)); max-height:min(320px,var(--radix-select-content-available-height)); max-width:calc(100vw - 20px); overflow:hidden; padding:4px; border:1px solid #e0e8f4; border-radius:12px; background:#fff; color:#254365; box-shadow:0 12px 32px #18365420,0 2px 6px #18365408; }
|
||||||
|
.asset-history-select-options { padding:2px; }
|
||||||
|
.asset-history-select-option { position:relative; display:flex; align-items:center; gap:16px; min-height:36px; padding:8px 30px 8px 10px; border-radius:7px; outline:none; font-size:13px; cursor:pointer; user-select:none; }
|
||||||
|
.asset-history-select-option[data-state=checked] { color:#1559e8; background:#f0f6ff; font-weight:600; }
|
||||||
|
.asset-history-select-option[data-highlighted] { background:#eaf2ff; color:#1559e8; }
|
||||||
|
.asset-history-option-detail { margin-left:auto; color:#7b8da5; font-size:11px; font-weight:400; }
|
||||||
|
.asset-history-option-check { position:absolute; right:9px; display:flex; color:#2563eb; }
|
||||||
|
.asset-history-select-scroll { display:flex; align-items:center; justify-content:center; height:24px; color:#7b8da5; }
|
||||||
|
.asset-history-detail-button>svg { padding:3px; width:24px; height:24px; border-radius:7px; background:#eaf2ff; color:#527eb7; }
|
||||||
|
@media(hover:hover) {
|
||||||
|
.asset-history-modes button:not([aria-pressed=true]):hover { color:#1559e8; background:#e5edf9; }
|
||||||
|
.asset-history-select:hover { border-color:#adc5e8; }
|
||||||
|
.asset-history-refresh:hover { color:#2563eb; background:#eef5ff; }
|
||||||
|
.asset-history-detail-button:hover { background:#eef5ff; border-color:#bcd3f3; }
|
||||||
|
}
|
||||||
|
@media(max-width:640px) {
|
||||||
|
.asset-history-select { min-height:44px; padding:8px 10px; font-size:12px; gap:10px; }
|
||||||
|
.asset-history-select-option { min-height:44px; }
|
||||||
|
}
|
||||||
|
@media(prefers-reduced-motion:reduce) {
|
||||||
|
.asset-history-modes button,.asset-history-select,.asset-history-select-icon,.asset-history-refresh { transition:none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation.js';
|
||||||
|
import { makePoint } from '../../../server/routes/asset-history/model.js';
|
||||||
|
import { testBatch } from '../../../server/routes/asset-history/fixtures.js';
|
||||||
|
|
||||||
|
test('selection prefers live then latest archive; missing is never a numerical zero',()=>{
|
||||||
|
const now=new Date('2026-10-19T00:00Z');
|
||||||
|
const archived=makePoint('2026-09',now,testBatch('2026-09'),[]);
|
||||||
|
const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now);
|
||||||
|
assert.equal(defaultHistoryPeriod([missing,archived,live]),'2026-10');
|
||||||
|
assert.equal(defaultHistoryPeriod([missing,archived]),'2026-09');
|
||||||
|
assert.equal(archived.counts!.total,0);assert.equal(missing.counts,null);
|
||||||
|
assert.equal(signedCount(3),'+3');assert.equal(signedCount(-3),'-3');
|
||||||
|
});
|
||||||
|
test('estimated counts keep total unknown and live refresh cannot compare against a reconstructed cohort',()=>{
|
||||||
|
const now=new Date('2026-10-19T00:00Z');
|
||||||
|
const estimate={...makePoint('2026-09',now),state:'RECONSTRUCTED' as const,source:'RECONSTRUCTED' as const,
|
||||||
|
reconstruction:{operating:3,inventory:0,unclassified:2,currentlyExited:1,sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'核对表',workbookSha256:'a'.repeat(64),difference:{operating:1,inventory:-1}}};
|
||||||
|
assert.deepEqual(displayHistoryCounts(estimate),{total:null,operating:3,inventory:0});assert.equal(displayHistoryDifference(estimate,'total'),null);
|
||||||
|
assert.equal(displayHistoryDifference(estimate,'inventory'),-1);assert.equal(defaultHistoryPeriod([estimate]),'2026-09');
|
||||||
|
const live=makePoint('2026-10',now);live.counts={total:5,operating:2,inventory:3};
|
||||||
|
assert.equal(updateLivePoint([estimate,live],live)[1].difference,null);
|
||||||
|
});
|
||||||
|
test('live refresh preserves January comparison with December outside visible year',()=>{
|
||||||
|
const now=new Date('2026-01-19T00:00Z');const initial=makePoint('2026-01',now);
|
||||||
|
initial.counts={total:10,operating:6,inventory:4};initial.difference={total:2,operating:1,inventory:1};
|
||||||
|
const refreshed={...initial,counts:{total:12,operating:7,inventory:5},difference:null};
|
||||||
|
const result=updateLivePoint([initial],refreshed)[0];
|
||||||
|
assert.deepEqual(result.difference,{total:4,operating:2,inventory:2});assert.equal(result.comparisonLabel,'截至当前较上月末');
|
||||||
|
});
|
||||||
|
test('live refresh cannot mutate an archive, bridge a hole or overwrite a different period',()=>{
|
||||||
|
const now=new Date('2026-10-19T00:00Z');const archive=makePoint('2026-09',now,testBatch('2026-09'),[]);
|
||||||
|
const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now);
|
||||||
|
assert.equal(updateLivePoint([archive],live)[0],archive);
|
||||||
|
assert.equal(updateLivePoint([missing,live],{...live,counts:{total:9,operating:4,inventory:5}})[1].difference,null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { HistoryPoint } from '../../../shared/assets/history';
|
||||||
|
|
||||||
|
/** Accessible period buttons and chart clicks share one selection model. */
|
||||||
|
export function defaultHistoryPeriod(points: HistoryPoint[]): string | null {
|
||||||
|
return points.find(p => p.state === 'LIVE')?.period || [...points].reverse().find(p => p.state === 'ARCHIVED' || p.state === 'RECONSTRUCTED')?.period || points.at(-1)?.period || null;
|
||||||
|
}
|
||||||
|
export function updateLivePoint(points: HistoryPoint[], live: HistoryPoint): HistoryPoint[] {
|
||||||
|
return points.map((point, index) => {
|
||||||
|
if (point.state !== 'LIVE' || point.period !== live.period) return point;
|
||||||
|
if (points[index - 1]?.state === 'RECONSTRUCTED') return { ...live, difference: null, comparisonLabel: '推算与实时或正式快照口径不同,暂无可比数据' };
|
||||||
|
const baseline = points[index - 1]?.counts || (point.counts && point.difference ? {
|
||||||
|
total: point.counts.total - point.difference.total,
|
||||||
|
operating: point.counts.operating - point.difference.operating,
|
||||||
|
inventory: point.counts.inventory - point.difference.inventory,
|
||||||
|
} : null);
|
||||||
|
return { ...live, difference: baseline && live.counts ? {
|
||||||
|
total: live.counts.total - baseline.total,
|
||||||
|
operating: live.counts.operating - baseline.operating,
|
||||||
|
inventory: live.counts.inventory - baseline.inventory,
|
||||||
|
} : null, comparisonLabel: baseline ? `截至当前较${live.period.length === 4 ? '上年末' : '上月末'}` : '暂无可比数据' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export const signedCount = (value: number) => value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString();
|
||||||
|
export function displayHistoryCounts(point: HistoryPoint) {
|
||||||
|
return point.reconstruction ? { total: null, operating: point.reconstruction.operating, inventory: point.reconstruction.inventory } : point.counts;
|
||||||
|
}
|
||||||
|
export function displayHistoryDifference(point: HistoryPoint, key: 'total' | 'operating' | 'inventory'): number | null {
|
||||||
|
return point.reconstruction ? key === 'total' ? null : point.reconstruction.difference?.[key] ?? null : point.difference?.[key] ?? null;
|
||||||
|
}
|
||||||
@@ -48,7 +48,6 @@ export default function MonitoringView() {
|
|||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||||
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
|
|
||||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||||
|
|
||||||
// New filters from image
|
// New filters from image
|
||||||
@@ -62,7 +61,6 @@ export default function MonitoringView() {
|
|||||||
const [filterRegion, setFilterRegion] = useState('All');
|
const [filterRegion, setFilterRegion] = useState('All');
|
||||||
const [filterBrands, setFilterBrands] = useState<string[]>([]);
|
const [filterBrands, setFilterBrands] = useState<string[]>([]);
|
||||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
||||||
@@ -72,7 +70,7 @@ export default function MonitoringView() {
|
|||||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] });
|
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ 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 [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -85,7 +83,7 @@ export default function MonitoringView() {
|
|||||||
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const monitoringQuery = useMemo<MonitoringQueryModel>(() => ({
|
const draftQuery = useMemo<MonitoringQueryModel>(() => ({
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
@@ -98,35 +96,24 @@ export default function MonitoringView() {
|
|||||||
targetNames: filterTargetNames,
|
targetNames: filterTargetNames,
|
||||||
region: filterRegion,
|
region: filterRegion,
|
||||||
plates: filterPlates,
|
plates: filterPlates,
|
||||||
mileageRange: appliedMileageRange,
|
mileageRange: filterMileageRange,
|
||||||
rangeStart,
|
rangeStart,
|
||||||
rangeEnd,
|
rangeEnd,
|
||||||
brands: filterBrands,
|
brands: filterBrands,
|
||||||
sourcePriority,
|
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
|
const [monitoringQuery, setMonitoringQuery] = useState(draftQuery);
|
||||||
// project, entity and mileage-range, so adding those states would change both
|
const [queryError, setQueryError] = useState('');
|
||||||
// its request parameters and refresh triggers.
|
const hasPendingChanges = JSON.stringify(draftQuery) !== JSON.stringify(monitoringQuery);
|
||||||
const fullscreenQuery = useMemo<MonitoringQueryModel>(() => ({
|
const queryDisabled = pageLoading || manualRefreshing || fullscreenLoading || !rangeStart || !rangeEnd || rangeStart > rangeEnd;
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
const handleQuery = () => {
|
||||||
searchTerm,
|
if (queryDisabled) return;
|
||||||
department: filterDept,
|
setQueryError('');
|
||||||
customer: filterCustomer,
|
setMonitoringQuery({ ...draftQuery });
|
||||||
project: 'All',
|
setIsFilterOpen(false);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
||||||
@@ -145,45 +132,48 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
|
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
|
||||||
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|
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;
|
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
||||||
}, [filterTargetNames]);
|
}, [monitoringQuery.targetNames]);
|
||||||
|
|
||||||
const queryRequestRef = useRef(0);
|
const queryRequestRef = useRef(0);
|
||||||
|
|
||||||
// 加载首页数据
|
// 加载首页数据
|
||||||
const loadFirstPage = useCallback((showPageLoading = true) => {
|
const loadFirstPage = useCallback((showPageLoading = true, refresh = false) => {
|
||||||
const requestId = ++queryRequestRef.current;
|
const requestId = ++queryRequestRef.current;
|
||||||
|
setQueryError('');
|
||||||
if (showPageLoading) setPageLoading(true);
|
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;
|
if (requestId !== queryRequestRef.current) return;
|
||||||
setVehicles(d.vehicles);
|
setVehicles(d.vehicles);
|
||||||
setStats(d.stats);
|
setStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
setRangeDailyTotals(d.rangeDailyTotals || []);
|
setRangeDailyTotals(d.rangeDailyTotals || []);
|
||||||
setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd });
|
setEffectiveRange(d.dateRange || { start: monitoringQuery.rangeStart, end: monitoringQuery.rangeEnd });
|
||||||
setTotal(d.total);
|
setTotal(d.total);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setHasMore(d.page < d.totalPages);
|
setHasMore(d.page < d.totalPages);
|
||||||
setLastRefreshedAt(new Date());
|
setLastRefreshedAt(d.queriedAt ? new Date(d.queriedAt) : new Date());
|
||||||
}).catch(() => {}).finally(() => {
|
}).catch(error => {
|
||||||
|
if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '里程查询失败,请点击查询重试');
|
||||||
|
}).finally(() => {
|
||||||
if (requestId === queryRequestRef.current) setPageLoading(false);
|
if (requestId === queryRequestRef.current) setPageLoading(false);
|
||||||
});
|
});
|
||||||
}, [monitoringQuery, rangeStart, rangeEnd]);
|
}, [monitoringQuery]);
|
||||||
|
|
||||||
const handleManualRefresh = useCallback(async () => {
|
const handleManualRefresh = useCallback(async () => {
|
||||||
if (manualRefreshing || pageLoading) return;
|
if (manualRefreshing || pageLoading || hasPendingChanges) return;
|
||||||
setManualRefreshing(true);
|
setManualRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await loadFirstPage(false);
|
await loadFirstPage(false, true);
|
||||||
} finally {
|
} finally {
|
||||||
setManualRefreshing(false);
|
setManualRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [loadFirstPage, manualRefreshing, pageLoading]);
|
}, [loadFirstPage, manualRefreshing, pageLoading, hasPendingChanges]);
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (pageLoading || loadingMore || !hasMore) return;
|
if (pageLoading || manualRefreshing || loadingMore || !hasMore || hasPendingChanges || queryError) return;
|
||||||
const requestId = queryRequestRef.current;
|
const requestId = queryRequestRef.current;
|
||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
@@ -192,15 +182,17 @@ export default function MonitoringView() {
|
|||||||
setVehicles(prev => [...prev, ...d.vehicles]);
|
setVehicles(prev => [...prev, ...d.vehicles]);
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
setHasMore(nextPage < d.totalPages);
|
setHasMore(nextPage < d.totalPages);
|
||||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
}).catch(error => {
|
||||||
}, [monitoringQuery, page, pageLoading, loadingMore, hasMore]);
|
if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '加载更多失败,请重试');
|
||||||
|
}).finally(() => setLoadingMore(false));
|
||||||
|
}, [monitoringQuery, page, pageLoading, manualRefreshing, loadingMore, hasMore, hasPendingChanges, queryError]);
|
||||||
|
|
||||||
// 筛选/排序变化时重新加载
|
// 首次进入和确认查询后加载;编辑筛选条件不触发请求。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFirstPage();
|
loadFirstPage();
|
||||||
}, [loadFirstPage]);
|
}, [loadFirstPage]);
|
||||||
|
|
||||||
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
// 保留区域级联清理;清理后的草稿也必须确认后才能请求。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filterPlates.length === 0) return;
|
if (filterPlates.length === 0) return;
|
||||||
const valid = new Set(filterOptions.plates);
|
const valid = new Set(filterOptions.plates);
|
||||||
@@ -209,24 +201,18 @@ export default function MonitoringView() {
|
|||||||
}, [filterOptions.plates, filterPlates]);
|
}, [filterOptions.plates, filterPlates]);
|
||||||
|
|
||||||
// 下载当前筛选结果为 xlsx
|
// 下载当前筛选结果为 xlsx
|
||||||
const handleDownload = useCallback(async () => {
|
const handleDownload = useCallback(async (dailyDetails = false) => {
|
||||||
if (exporting) return;
|
if (exporting || hasPendingChanges || pageLoading || queryError) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const d = await fetchMonitoring(buildExportMonitoringQuery(monitoringQuery));
|
const d = await fetchMonitoring({ ...buildExportMonitoringQuery(monitoringQuery), dailyDetails });
|
||||||
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || monitoringQuery.rangeStart, endDate: d.dateRange?.end || monitoringQuery.rangeEnd, sortBy: monitoringQuery.sortBy });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('export failed', err);
|
setQueryError(err instanceof Error ? err.message : '导出失败,请重试');
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}, [exporting, monitoringQuery, rangeStart, rangeEnd, sortBy]);
|
}, [exporting, monitoringQuery, hasPendingChanges, pageLoading, queryError]);
|
||||||
|
|
||||||
// 每分钟自动刷新
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [loadFirstPage]);
|
|
||||||
|
|
||||||
// 触底检测:用 IntersectionObserver 监听哨兵元素
|
// 触底检测:用 IntersectionObserver 监听哨兵元素
|
||||||
const loadMoreRef = useRef(loadMore);
|
const loadMoreRef = useRef(loadMore);
|
||||||
@@ -272,16 +258,20 @@ export default function MonitoringView() {
|
|||||||
setIsFullscreen(!isFullscreen);
|
setIsFullscreen(!isFullscreen);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
// 全屏使用已确认条件,编辑筛选后仍需点击查询。
|
||||||
useEffect(() => {
|
const loadFullscreen = useCallback((refresh = false) => {
|
||||||
if (!isFullscreen) return;
|
setQueryError('');
|
||||||
setFullscreenLoading(true);
|
setFullscreenLoading(true);
|
||||||
fetchMonitoring(buildFullscreenMonitoringQuery(fullscreenQuery)).then(d => {
|
return fetchMonitoring({ ...buildFullscreenMonitoringQuery(monitoringQuery), refresh }).then(d => {
|
||||||
setFullscreenVehicles(d.vehicles);
|
setFullscreenVehicles(d.vehicles);
|
||||||
setFullscreenStats(d.stats);
|
setFullscreenStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
}).catch(error => setQueryError(error instanceof Error ? error.message : '全屏查询失败')).finally(() => setFullscreenLoading(false));
|
||||||
}, [isFullscreen, fullscreenQuery, fullscreenRefresh]);
|
}, [monitoringQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFullscreen) void loadFullscreen();
|
||||||
|
}, [isFullscreen, loadFullscreen]);
|
||||||
|
|
||||||
// 全屏时禁止背景滚动
|
// 全屏时禁止背景滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -322,9 +312,13 @@ export default function MonitoringView() {
|
|||||||
forceLandscape={forceLandscape}
|
forceLandscape={forceLandscape}
|
||||||
fullscreenStats={fullscreenStats}
|
fullscreenStats={fullscreenStats}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sourcePriority={sourcePriority}
|
sourcePriority={monitoringQuery.sourcePriority}
|
||||||
onRefresh={() => { setFullscreenRefresh(n => n + 1); }}
|
onQuery={handleQuery}
|
||||||
|
queryDisabled={queryDisabled}
|
||||||
|
hasPendingChanges={hasPendingChanges}
|
||||||
|
onRefresh={() => { void loadFullscreen(true); }}
|
||||||
fullscreenLoading={fullscreenLoading}
|
fullscreenLoading={fullscreenLoading}
|
||||||
|
error={queryError}
|
||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
filterTargetNames={filterTargetNames}
|
filterTargetNames={filterTargetNames}
|
||||||
onFilterTargetNamesChange={setFilterTargetNames}
|
onFilterTargetNamesChange={setFilterTargetNames}
|
||||||
@@ -351,8 +345,13 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
<MonitoringToolbar
|
<MonitoringToolbar
|
||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
onDownload={handleDownload}
|
onDownload={() => { void handleDownload(); }}
|
||||||
|
onDownloadDetails={() => { void handleDownload(true); }}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
|
onQuery={handleQuery}
|
||||||
|
queryDisabled={queryDisabled}
|
||||||
|
hasPendingChanges={hasPendingChanges}
|
||||||
|
resultsDisabled={pageLoading || hasPendingChanges || !!queryError}
|
||||||
lastRefreshedAt={lastRefreshedAt}
|
lastRefreshedAt={lastRefreshedAt}
|
||||||
onManualRefresh={handleManualRefresh}
|
onManualRefresh={handleManualRefresh}
|
||||||
manualRefreshing={manualRefreshing}
|
manualRefreshing={manualRefreshing}
|
||||||
@@ -415,8 +414,8 @@ export default function MonitoringView() {
|
|||||||
onFilterPlatesChange={setFilterPlates}
|
onFilterPlatesChange={setFilterPlates}
|
||||||
onFilterTargetNamesChange={setFilterTargetNames}
|
onFilterTargetNamesChange={setFilterTargetNames}
|
||||||
onFilterRegionChange={setFilterRegion}
|
onFilterRegionChange={setFilterRegion}
|
||||||
onAppliedMileageRangeChange={setAppliedMileageRange}
|
onQuery={handleQuery}
|
||||||
onFilterOpenChange={setIsFilterOpen}
|
queryDisabled={queryDisabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -445,8 +444,7 @@ export default function MonitoringView() {
|
|||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchTermChange={setSearchTerm}
|
onSearchTermChange={setSearchTerm}
|
||||||
onFilterMileageRangeChange={setFilterMileageRange}
|
onFilterMileageRangeChange={setFilterMileageRange}
|
||||||
appliedMileageRange={appliedMileageRange}
|
mileageRange={filterMileageRange}
|
||||||
onAppliedMileageRangeChange={setAppliedMileageRange}
|
|
||||||
filterPlatePrefix={filterPlatePrefix}
|
filterPlatePrefix={filterPlatePrefix}
|
||||||
onFilterPlatePrefixChange={setFilterPlatePrefix}
|
onFilterPlatePrefixChange={setFilterPlatePrefix}
|
||||||
rangeStart={rangeStart}
|
rangeStart={rangeStart}
|
||||||
@@ -455,10 +453,12 @@ export default function MonitoringView() {
|
|||||||
onRangeEndChange={setRangeEnd}
|
onRangeEndChange={setRangeEnd}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{queryError && <div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700">{queryError}</div>}
|
||||||
|
|
||||||
{/* Sticky header: KPI + 清单标题 */}
|
{/* Sticky header: KPI + 清单标题 */}
|
||||||
<MonitoringSummary
|
<MonitoringSummary
|
||||||
pageLoading={pageLoading}
|
pageLoading={pageLoading}
|
||||||
sortBy={sortBy}
|
sortBy={monitoringQuery.sortBy}
|
||||||
isRangeMode={isRangeMode}
|
isRangeMode={isRangeMode}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
rangeLabel={rangeLabel}
|
rangeLabel={rangeLabel}
|
||||||
@@ -482,8 +482,9 @@ export default function MonitoringView() {
|
|||||||
|
|
||||||
<VehicleDetailModal
|
<VehicleDetailModal
|
||||||
vehicle={detailVehicle}
|
vehicle={detailVehicle}
|
||||||
|
selectedRange={effectiveRange}
|
||||||
onClose={() => setDetailVehicle(null)}
|
onClose={() => setDetailVehicle(null)}
|
||||||
sourcePriority={sourcePriority}
|
sourcePriority={monitoringQuery.sourcePriority}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ interface Props {
|
|||||||
vehicle: MonitoringVehicle | null;
|
vehicle: MonitoringVehicle | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sourcePriority: MileageSourceGroup[];
|
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 }[] = [
|
const RANGE_TABS: { key: RangeKey; label: string }[] = [
|
||||||
|
{ key: 'selected', label: '当前区间' },
|
||||||
{ key: 'last15', label: '近 15 天' },
|
{ key: 'last15', label: '近 15 天' },
|
||||||
{ key: 'month', label: '本月' },
|
{ key: 'month', label: '本月' },
|
||||||
{ key: 'quarter', label: '本季度' },
|
{ key: 'quarter', label: '本季度' },
|
||||||
@@ -48,10 +50,6 @@ function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: stri
|
|||||||
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
|
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isToday(date: string): boolean {
|
|
||||||
return date === fmtYmd(new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLabel(date: string, key: RangeKey): string {
|
function formatLabel(date: string, key: RangeKey): string {
|
||||||
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
||||||
void key;
|
void key;
|
||||||
@@ -64,30 +62,32 @@ function daySourceLabel(day: VehicleRecentDay): string {
|
|||||||
return day.isDataSynced ? '来源待接口' : '无数据';
|
return day.isDataSynced ? '来源待接口' : '无数据';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
|
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority, selectedRange }: Props) {
|
||||||
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [range, setRange] = useState<RangeKey>('last15');
|
const [error, setError] = useState('');
|
||||||
|
const [range, setRange] = useState<RangeKey>('selected');
|
||||||
const dragControls = useDragControls();
|
const dragControls = useDragControls();
|
||||||
|
|
||||||
// 切换车辆时重置区间为默认
|
// 切换车辆时重置区间为默认
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (vehicle) setRange('last15');
|
if (vehicle) setRange('selected');
|
||||||
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 拉取数据(车辆或区间变化)
|
// 拉取数据(车辆或区间变化)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!vehicle) return;
|
if (!vehicle) return;
|
||||||
const { start, end } = rangeFor(range);
|
const { start, end } = range === 'selected' ? selectedRange : rangeFor(range);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
setDays([]);
|
setDays([]);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
||||||
.then(d => { if (!cancelled) setDays(d.days); })
|
.then(d => { if (!cancelled) setDays(d.days); })
|
||||||
.catch(() => { if (!cancelled) setDays([]); })
|
.catch(() => { if (!cancelled) setError('单车里程加载失败,请重新打开详情重试'); })
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -96,8 +96,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [vehicle]);
|
}, [vehicle]);
|
||||||
|
|
||||||
// 排除"今日"列(数据未到位时易引起误读)
|
// 与主列表区间一致;缺失日保留缺口,真实零保留为零。
|
||||||
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
const historyDays = useMemo(() => days, [days]);
|
||||||
const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
|
const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
|
||||||
const chartDays = useMemo(() => historyDays.map(day => ({
|
const chartDays = useMemo(() => historyDays.map(day => ({
|
||||||
...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
|
...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
|
||||||
@@ -106,11 +106,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
// 骨架天数:根据区间预估
|
// 骨架天数:根据区间预估
|
||||||
const skeletonCount = useMemo(() => {
|
const skeletonCount = useMemo(() => {
|
||||||
if (range === 'last15') return 15;
|
if (range === 'last15') return 15;
|
||||||
const { start, end } = rangeFor(range);
|
const { start, end } = range === 'selected' ? selectedRange : rangeFor(range);
|
||||||
const s = new Date(start);
|
const s = new Date(start);
|
||||||
const e = new Date(end);
|
const e = new Date(end);
|
||||||
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
||||||
}, [range]);
|
}, [range, selectedRange.start, selectedRange.end]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -171,11 +171,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
<button aria-label="关闭车辆详情" onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && <div role="alert" className="mx-4 mt-2 rounded-lg bg-red-50 p-2 text-xs text-red-700">{error}</div>}
|
||||||
{/* 时间范围切换 */}
|
{/* 时间范围切换 */}
|
||||||
<div className="px-4 pt-3">
|
<div className="px-4 pt-3">
|
||||||
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
TargetVehicle,
|
TargetVehicle,
|
||||||
TrendPoint,
|
TrendPoint,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson, fetchAuthenticated } from '../../auth/api-client';
|
||||||
import type {
|
import type {
|
||||||
DailyMileageReport,
|
DailyMileageReport,
|
||||||
MileageReportHistoryResponse,
|
MileageReportHistoryResponse,
|
||||||
@@ -26,6 +26,9 @@ export type {
|
|||||||
const BASE = '/api/mileage';
|
const BASE = '/api/mileage';
|
||||||
|
|
||||||
export async function fetchMonitoring(params?: {
|
export async function fetchMonitoring(params?: {
|
||||||
|
refresh?: boolean;
|
||||||
|
includeDailyTrend?: boolean;
|
||||||
|
dailyDetails?: boolean;
|
||||||
sortBy?: 'today' | 'total' | 'statisticTime';
|
sortBy?: 'today' | 'total' | 'statisticTime';
|
||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -50,6 +53,9 @@ export async function fetchMonitoring(params?: {
|
|||||||
sourcePriority?: MileageSourceGroup[];
|
sourcePriority?: MileageSourceGroup[];
|
||||||
}): Promise<MonitoringData> {
|
}): Promise<MonitoringData> {
|
||||||
const query = new URLSearchParams();
|
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?.sortBy) query.set('sortBy', params.sortBy);
|
||||||
if (params?.sortOrder) query.set('sortOrder', params.sortOrder);
|
if (params?.sortOrder) query.set('sortOrder', params.sortOrder);
|
||||||
if (params?.limit) query.set('limit', String(params.limit));
|
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?.endDate) query.set('endDate', params.endDate);
|
||||||
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
return fetchJson<MonitoringData>(`${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<TargetSummary[]> {
|
export async function fetchTargets(): Promise<TargetSummary[]> {
|
||||||
|
|||||||
@@ -21,3 +21,10 @@ test('列表全区间缺失时显示横杠,即使存在累计里程', () => {
|
|||||||
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0);
|
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0);
|
||||||
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417);
|
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}),'—');
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { MonitoringVehicle } from './types';
|
|||||||
import type { VehicleRecentDay } from './api';
|
import type { VehicleRecentDay } from './api';
|
||||||
|
|
||||||
export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string {
|
export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string {
|
||||||
|
if (vehicle.periodMileageKm !== undefined) return vehicle.periodMileageKm ?? '—';
|
||||||
if (vehicle.mileageAnomaly) return '数据异常';
|
if (vehicle.mileageAnomaly) return '数据异常';
|
||||||
if (!vehicle.isDataSynced) return '—';
|
if (!vehicle.isDataSynced) return '—';
|
||||||
return Math.max(0, vehicle.dailyKm || 0);
|
return Math.max(0, vehicle.dailyKm || 0);
|
||||||
@@ -22,3 +23,8 @@ export function recentMileageStats(days: VehicleRecentDay[]) {
|
|||||||
totalDays: days.length,
|
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}天异常`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ export default function ActiveFilterTags({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
onSearchTermChange,
|
onSearchTermChange,
|
||||||
onFilterMileageRangeChange,
|
onFilterMileageRangeChange,
|
||||||
appliedMileageRange,
|
mileageRange,
|
||||||
onAppliedMileageRangeChange,
|
|
||||||
filterPlatePrefix,
|
filterPlatePrefix,
|
||||||
onFilterPlatePrefixChange,
|
onFilterPlatePrefixChange,
|
||||||
rangeStart,
|
rangeStart,
|
||||||
@@ -58,8 +57,7 @@ export default function ActiveFilterTags({
|
|||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
onSearchTermChange: (value: string) => void;
|
onSearchTermChange: (value: string) => void;
|
||||||
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
|
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
|
||||||
appliedMileageRange: MileageRange;
|
mileageRange: MileageRange;
|
||||||
onAppliedMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
|
|
||||||
filterPlatePrefix: string;
|
filterPlatePrefix: string;
|
||||||
onFilterPlatePrefixChange: (value: string) => void;
|
onFilterPlatePrefixChange: (value: string) => void;
|
||||||
rangeStart: string;
|
rangeStart: string;
|
||||||
@@ -84,8 +82,8 @@ export default function ActiveFilterTags({
|
|||||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') });
|
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') });
|
||||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') });
|
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') });
|
||||||
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => onSearchTermChange('') });
|
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 (mileageRange.min) tags.push({ label: `里程≥${mileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); } });
|
||||||
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, max: '' })); } });
|
if (mileageRange.max) tags.push({ label: `里程≤${mileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); } });
|
||||||
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') });
|
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') });
|
||||||
if (rangeStart || rangeEnd) tags.push({
|
if (rangeStart || rangeEnd) tags.push({
|
||||||
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
|
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
|
||||||
@@ -99,7 +97,7 @@ export default function ActiveFilterTags({
|
|||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All');
|
onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All');
|
||||||
onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All');
|
onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All');
|
||||||
onFilterMileageRangeChange({ min: '', max: '' }); onAppliedMileageRangeChange({ min: '', max: '' });
|
onFilterMileageRangeChange({ min: '', max: '' });
|
||||||
onFilterBrandsChange([]);
|
onFilterBrandsChange([]);
|
||||||
const today = defaultMileageDate();
|
const today = defaultMileageDate();
|
||||||
onRangeStartChange(today); onRangeEndChange(today);
|
onRangeStartChange(today); onRangeEndChange(today);
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export default function FilterPanel({
|
|||||||
onFilterPlatesChange,
|
onFilterPlatesChange,
|
||||||
onFilterTargetNamesChange,
|
onFilterTargetNamesChange,
|
||||||
onFilterRegionChange,
|
onFilterRegionChange,
|
||||||
onAppliedMileageRangeChange,
|
onQuery,
|
||||||
onFilterOpenChange,
|
queryDisabled,
|
||||||
}: {
|
}: {
|
||||||
rangeStart: string;
|
rangeStart: string;
|
||||||
rangeEnd: string;
|
rangeEnd: string;
|
||||||
@@ -65,8 +65,8 @@ export default function FilterPanel({
|
|||||||
onFilterPlatesChange: (value: string[]) => void;
|
onFilterPlatesChange: (value: string[]) => void;
|
||||||
onFilterTargetNamesChange: (value: string[]) => void;
|
onFilterTargetNamesChange: (value: string[]) => void;
|
||||||
onFilterRegionChange: (value: string) => void;
|
onFilterRegionChange: (value: string) => void;
|
||||||
onAppliedMileageRangeChange: (value: MileageRange) => void;
|
onQuery: () => void;
|
||||||
onFilterOpenChange: (value: boolean) => void;
|
queryDisabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -82,6 +82,7 @@ export default function FilterPanel({
|
|||||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
|
aria-label="开始日期"
|
||||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||||
value={rangeStart}
|
value={rangeStart}
|
||||||
onChange={(e) => onRangeStartChange(e.target.value)}
|
onChange={(e) => onRangeStartChange(e.target.value)}
|
||||||
@@ -89,11 +90,13 @@ export default function FilterPanel({
|
|||||||
<span className="text-[10px] font-bold text-slate-300">至</span>
|
<span className="text-[10px] font-bold text-slate-300">至</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
|
aria-label="结束日期"
|
||||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||||
value={rangeEnd}
|
value={rangeEnd}
|
||||||
onChange={(e) => onRangeEndChange(e.target.value)}
|
onChange={(e) => onRangeEndChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{rangeStart && rangeEnd && rangeStart > rangeEnd && <p role="alert" className="text-xs text-red-600">开始日期不能晚于结束日期</p>}
|
||||||
<div className="flex items-center gap-1.5 pt-1">
|
<div className="flex items-center gap-1.5 pt-1">
|
||||||
{([
|
{([
|
||||||
['today', '今天'],
|
['today', '今天'],
|
||||||
@@ -240,13 +243,13 @@ export default function FilterPanel({
|
|||||||
onFilterDeptChange('All');
|
onFilterDeptChange('All');
|
||||||
onFilterPlatesChange([]);
|
onFilterPlatesChange([]);
|
||||||
onFilterCustomerChange('All');
|
onFilterCustomerChange('All');
|
||||||
|
onFilterRentStatusChange('All');
|
||||||
onFilterProjectChange('All');
|
onFilterProjectChange('All');
|
||||||
onFilterEntityChange('All');
|
onFilterEntityChange('All');
|
||||||
onFilterPlatePrefixChange('All');
|
onFilterPlatePrefixChange('All');
|
||||||
onFilterTargetNamesChange([]);
|
onFilterTargetNamesChange([]);
|
||||||
onFilterRegionChange('All');
|
onFilterRegionChange('All');
|
||||||
onFilterMileageRangeChange({ min: '', max: '' });
|
onFilterMileageRangeChange({ min: '', max: '' });
|
||||||
onAppliedMileageRangeChange({ min: '', max: '' });
|
|
||||||
onFilterBrandsChange([]);
|
onFilterBrandsChange([]);
|
||||||
const today = defaultMileageDate();
|
const today = defaultMileageDate();
|
||||||
onRangeStartChange(today);
|
onRangeStartChange(today);
|
||||||
@@ -257,13 +260,11 @@ export default function FilterPanel({
|
|||||||
重置所有
|
重置所有
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={onQuery}
|
||||||
onAppliedMileageRangeChange({ ...filterMileageRange });
|
disabled={queryDisabled}
|
||||||
onFilterOpenChange(false);
|
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100 disabled:opacity-50"
|
||||||
}}
|
|
||||||
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100"
|
|
||||||
>
|
>
|
||||||
完成筛选
|
查询
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { monitoringMileageValue } from '../../mileage-display';
|
import { monitoringMileageValue, mileageQualityLabel } from '../../mileage-display';
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
@@ -15,8 +15,12 @@ export default function FullscreenMonitoring({
|
|||||||
fullscreenStats,
|
fullscreenStats,
|
||||||
sortBy,
|
sortBy,
|
||||||
sourcePriority,
|
sourcePriority,
|
||||||
|
onQuery,
|
||||||
|
queryDisabled,
|
||||||
|
hasPendingChanges,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
fullscreenLoading,
|
fullscreenLoading,
|
||||||
|
error,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
filterTargetNames,
|
filterTargetNames,
|
||||||
onFilterTargetNamesChange,
|
onFilterTargetNamesChange,
|
||||||
@@ -42,8 +46,12 @@ export default function FullscreenMonitoring({
|
|||||||
fullscreenStats: MonitoringStats;
|
fullscreenStats: MonitoringStats;
|
||||||
sortBy: SortBy;
|
sortBy: SortBy;
|
||||||
sourcePriority: MileageSourceGroup[];
|
sourcePriority: MileageSourceGroup[];
|
||||||
|
onQuery: () => void;
|
||||||
|
queryDisabled: boolean;
|
||||||
|
hasPendingChanges: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
fullscreenLoading: boolean;
|
fullscreenLoading: boolean;
|
||||||
|
error: string;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
filterTargetNames: string[];
|
filterTargetNames: string[];
|
||||||
onFilterTargetNamesChange: Dispatch<SetStateAction<string[]>>;
|
onFilterTargetNamesChange: Dispatch<SetStateAction<string[]>>;
|
||||||
@@ -93,13 +101,13 @@ export default function FullscreenMonitoring({
|
|||||||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-[10px]">
|
<div className="flex items-center gap-3 text-[10px]">
|
||||||
<span className="text-slate-500">区间 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
<span className="text-slate-500">区间 <span className="text-white font-black">{fullscreenStats.validVehicleCount === 0 ? '—' : Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
<span className="text-slate-500">累计 <span className="text-white font-black">{fullscreenStats.totalMileageVehicleCount === 0 ? '—' : Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
<span className="text-slate-500">均 <span className="text-white font-black">{(sortBy === 'total' ? fullscreenStats.totalMileageVehicleCount : fullscreenStats.validVehicleCount) === 0 ? '—' : (fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
来源 <span className="text-white font-black">
|
来源 <span className="text-white font-black">
|
||||||
@@ -109,7 +117,11 @@ export default function FullscreenMonitoring({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={onQuery} disabled={queryDisabled} className="rounded bg-blue-600 px-3 py-1 text-xs text-white disabled:opacity-50">查询</button>
|
||||||
|
{hasPendingChanges && <span role="status" className="text-xs text-amber-400">待查询</span>}
|
||||||
<button
|
<button
|
||||||
|
aria-label="刷新全屏数据"
|
||||||
|
disabled={fullscreenLoading || hasPendingChanges}
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -121,6 +133,7 @@ export default function FullscreenMonitoring({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && <div role="alert" className="bg-red-950 px-3 py-2 text-sm text-red-200">{error}</div>}
|
||||||
{/* Batch selector + legend */}
|
{/* Batch selector + legend */}
|
||||||
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
|
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
|
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
|
||||||
@@ -298,7 +311,7 @@ export default function FullscreenMonitoring({
|
|||||||
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
|
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||||
title={sourceDisplay.title}
|
title={[sourceDisplay.title, mileageQualityLabel(v), v.qualityReasons?.join(', ')].filter(Boolean).join(' · ')}
|
||||||
>
|
>
|
||||||
{sourceDisplay.label}
|
{sourceDisplay.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ export default function MonitoringSummary({
|
|||||||
isRangeMode: boolean;
|
isRangeMode: boolean;
|
||||||
stats: MonitoringStats;
|
stats: MonitoringStats;
|
||||||
rangeLabel: string;
|
rangeLabel: string;
|
||||||
rangeDailyTotals: { date: string; totalKm: number }[];
|
rangeDailyTotals: { date: string; totalKm: number | null }[];
|
||||||
total: number;
|
total: number;
|
||||||
}) {
|
}) {
|
||||||
const averageDailyKm = rangeDailyTotals.length > 0
|
const hasMileage = (sortBy === 'total' ? stats.totalMileageVehicleCount : stats.validVehicleCount) !== 0;
|
||||||
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
|
const validDays = rangeDailyTotals.filter(item => item.totalKm != null);
|
||||||
: 0;
|
const averageDailyKm = validDays.length ? validDays.reduce((sum, item) => sum + item.totalKm!, 0) / validDays.length : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
|
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
|
||||||
@@ -29,13 +29,13 @@ export default function MonitoringSummary({
|
|||||||
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
|
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
|
||||||
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}总里程</div>
|
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}总里程</div>
|
||||||
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
||||||
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
|
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{hasMileage ? Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString() : '—'} <span className="text-[8px] text-slate-400">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||||
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
||||||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div>
|
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : hasMileage ? (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0) : '—'}</div>
|
||||||
<div className="text-[7px] text-slate-400">km/台</div>
|
<div className="text-[7px] text-slate-400">km/台</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
|
||||||
@@ -57,27 +57,27 @@ export default function MonitoringSummary({
|
|||||||
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} 天 · km</div>
|
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} 天 · km</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[58px] min-w-0">
|
<div className="h-[58px] min-w-0">
|
||||||
{rangeDailyTotals.length === 0 ? (
|
{rangeDailyTotals.length === 0 || validDays.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300">暂无趋势</div>
|
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300">无有效里程数据</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={58} minWidth={0}>
|
<ResponsiveContainer width="100%" height={58} minWidth={0}>
|
||||||
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
|
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
|
||||||
<XAxis dataKey="date" hide />
|
<XAxis dataKey="date" hide />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) => [`${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)}`}
|
labelFormatter={(label) => `日期 ${String(label)}`}
|
||||||
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
|
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
|
||||||
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
|
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
|
||||||
/>
|
/>
|
||||||
{averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />}
|
{averageDailyKm != null && averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />}
|
||||||
<Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" />
|
<Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-[9px] font-black text-slate-400">日均</div>
|
<div className="text-[9px] font-black text-slate-400">有效日均</div>
|
||||||
<div className="text-xs font-black text-slate-800">{Math.round(averageDailyKm).toLocaleString()}</div>
|
<div className="text-xs font-black text-slate-800">{averageDailyKm == null ? '—' : Math.round(averageDailyKm).toLocaleString()}</div>
|
||||||
<div className="text-[9px] font-bold text-slate-400">km</div>
|
<div className="text-[9px] font-bold text-slate-400">km</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,11 +92,11 @@ export default function MonitoringSummary({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||||
<div className="text-[9px] font-black text-blue-400">当日总计</div>
|
<div className="text-[9px] font-black text-blue-400">当日总计</div>
|
||||||
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{stats.validVehicleCount === 0 ? '—' : Math.round(stats.totalToday).toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
||||||
<div className="text-[9px] font-black text-slate-400">日均单车</div>
|
<div className="text-[9px] font-black text-slate-400">日均单车</div>
|
||||||
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.validVehicleCount === 0 ? '—' : stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ type SortBy = 'today' | 'total' | 'statisticTime';
|
|||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
export default function MonitoringToolbar({
|
export default function MonitoringToolbar({
|
||||||
|
onQuery,
|
||||||
|
queryDisabled,
|
||||||
|
hasPendingChanges,
|
||||||
|
resultsDisabled,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onDownloadDetails,
|
||||||
exporting,
|
exporting,
|
||||||
lastRefreshedAt,
|
lastRefreshedAt,
|
||||||
onManualRefresh,
|
onManualRefresh,
|
||||||
@@ -44,8 +49,13 @@ export default function MonitoringToolbar({
|
|||||||
onApplyRangePreset,
|
onApplyRangePreset,
|
||||||
rangeLabel,
|
rangeLabel,
|
||||||
}: {
|
}: {
|
||||||
|
onQuery: () => void;
|
||||||
|
queryDisabled: boolean;
|
||||||
|
hasPendingChanges: boolean;
|
||||||
|
resultsDisabled: boolean;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
|
onDownloadDetails: () => void;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
lastRefreshedAt: Date | null;
|
lastRefreshedAt: Date | null;
|
||||||
onManualRefresh: () => void;
|
onManualRefresh: () => void;
|
||||||
@@ -88,9 +98,13 @@ export default function MonitoringToolbar({
|
|||||||
<button onClick={onToggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
|
<button onClick={onToggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
|
||||||
<Maximize2 size={14} />
|
<Maximize2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
|
<details className="relative">
|
||||||
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
|
<summary aria-label="导出" className="cursor-pointer list-none p-1 text-slate-400">{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}</summary>
|
||||||
</button>
|
<div className="absolute left-0 top-full z-40 w-36 rounded-lg border border-slate-200 bg-white p-1 shadow-lg">
|
||||||
|
<button onClick={onDownload} disabled={exporting || resultsDisabled} className="block w-full rounded p-2 text-left text-xs hover:bg-slate-50 disabled:opacity-50">导出区间汇总</button>
|
||||||
|
<button onClick={onDownloadDetails} disabled={exporting || resultsDisabled} className="block w-full rounded p-2 text-left text-xs hover:bg-slate-50 disabled:opacity-50">导出逐日明细</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-1.5">
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||||
@@ -100,15 +114,15 @@ export default function MonitoringToolbar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||||
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
<span title="查询结果获取时间" className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
|
||||||
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
|
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onManualRefresh}
|
onClick={onManualRefresh}
|
||||||
disabled={manualRefreshing}
|
disabled={manualRefreshing || resultsDisabled}
|
||||||
className="flex h-7 items-center gap-1 whitespace-nowrap rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
|
className="flex h-7 items-center gap-1 whitespace-nowrap rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
|
||||||
title="局部刷新当前筛选数据"
|
title="重新获取当前筛选数据"
|
||||||
>
|
>
|
||||||
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
|
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
|
||||||
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
||||||
@@ -145,7 +159,8 @@ export default function MonitoringToolbar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto no-scrollbar">
|
||||||
{([
|
{([
|
||||||
['today', '今天'],
|
['today', '今天'],
|
||||||
['yesterday', '昨天'],
|
['yesterday', '昨天'],
|
||||||
@@ -166,7 +181,10 @@ export default function MonitoringToolbar({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
|
</div>
|
||||||
|
<button type="button" onClick={onQuery} disabled={queryDisabled} className="shrink-0 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50">查询</button>
|
||||||
|
{hasPendingChanges && <span role="status" className="shrink-0 text-[10px] text-amber-700">待查询</span>}
|
||||||
|
<span className="hidden shrink-0 rounded-lg sm:block bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { monitoringMileageValue } from '../../mileage-display';
|
import { monitoringMileageValue, mileageQualityLabel } from '../../mileage-display';
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import { Truck } from 'lucide-react';
|
import { Truck } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
@@ -87,7 +87,8 @@ export default function VehicleList({
|
|||||||
{sourceDisplay.label}
|
{sourceDisplay.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{v.mileageAnomaly && <div className="text-[9px] font-bold text-amber-600" title={v.mileageAnomaly}>{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}</div>}
|
{v.qualityStatus && <div className="text-[9px] text-slate-500" title={v.qualityReasons?.join(', ')}>{mileageQualityLabel(v)}</div>}
|
||||||
|
{!v.qualityStatus && v.mileageAnomaly && <div className="text-[9px] font-bold text-amber-600" title={v.mileageAnomaly}>{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}</div>}
|
||||||
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||||
{statisticTime.label}
|
{statisticTime.label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,10 +135,10 @@ test('four monitoring query builders preserve pagination and fullscreen differen
|
|||||||
assert.equal(initial.plate, '粤A00001,粤A00002');
|
assert.equal(initial.plate, '粤A00001,粤A00002');
|
||||||
|
|
||||||
const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3);
|
const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3);
|
||||||
assert.deepEqual(nextPage, { ...initial, page: 3 });
|
assert.deepEqual(nextPage, { ...initial, page: 3, includeDailyTrend: false });
|
||||||
|
|
||||||
const exported = buildExportMonitoringQuery(queryModel);
|
const exported = buildExportMonitoringQuery(queryModel);
|
||||||
assert.deepEqual(exported, { ...initial, limit: 9999 });
|
assert.deepEqual(exported, { ...initial, limit: 9999, includeDailyTrend: false });
|
||||||
|
|
||||||
const fullscreen = buildFullscreenMonitoringQuery(queryModel);
|
const fullscreen = buildFullscreenMonitoringQuery(queryModel);
|
||||||
assert.equal(fullscreen.limit, 9999);
|
assert.equal(fullscreen.limit, 9999);
|
||||||
|
|||||||
@@ -82,15 +82,15 @@ export function buildNextPageMonitoringQuery(
|
|||||||
pageSize: number,
|
pageSize: number,
|
||||||
page: number,
|
page: number,
|
||||||
): MonitoringRequestParams {
|
): MonitoringRequestParams {
|
||||||
return buildPagedQuery(model, pageSize, page);
|
return { ...buildPagedQuery(model, pageSize, page), includeDailyTrend: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
|
export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
|
||||||
return buildPagedQuery(model, 9999, 1);
|
return { ...buildPagedQuery(model, 9999, 1), includeDailyTrend: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
|
export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
|
||||||
// Characterization boundary: fullscreen historically omits these four filters.
|
// Characterization boundary: fullscreen historically omits these four filters.
|
||||||
// Keep project, entity, mileageMin and mileageMax out until a separate bug fix is approved.
|
// 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export interface MonitoringVehicle {
|
|||||||
plate: string;
|
plate: string;
|
||||||
vin: string;
|
vin: string;
|
||||||
dailyKm: number;
|
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;
|
mileageAnomaly?: string | null;
|
||||||
dailyMileage?: Record<string, number | null>;
|
dailyMileage?: Record<string, number | null>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
@@ -35,6 +42,8 @@ export interface MonitoringStats {
|
|||||||
totalAll: number;
|
totalAll: number;
|
||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
yesterdayTotal: number;
|
yesterdayTotal: number;
|
||||||
|
validVehicleCount?: number;
|
||||||
|
totalMileageVehicleCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringFilters {
|
export interface MonitoringFilters {
|
||||||
@@ -54,11 +63,12 @@ export interface MonitoringData {
|
|||||||
vehicles: MonitoringVehicle[];
|
vehicles: MonitoringVehicle[];
|
||||||
stats: MonitoringStats;
|
stats: MonitoringStats;
|
||||||
filters: MonitoringFilters;
|
filters: MonitoringFilters;
|
||||||
rangeDailyTotals?: { date: string; totalKm: number }[];
|
rangeDailyTotals?: { date: string; totalKm: number | null }[];
|
||||||
dateRange?: { start: string; end: string };
|
dateRange?: { start: string; end: string };
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
queriedAt?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
sourcePriority: MileageSourceGroup[];
|
sourcePriority: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface ExportContext {
|
|||||||
|
|
||||||
const BASE_HEADERS = [
|
const BASE_HEADERS = [
|
||||||
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||||
'运营区域',
|
'运营区域', '有效天数', '缺失天数', '异常天数', '质量说明',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function sourceLabel(v: MonitoringVehicle): string {
|
function sourceLabel(v: MonitoringVehicle): string {
|
||||||
@@ -22,6 +22,7 @@ function sourceLabel(v: MonitoringVehicle): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(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 '未对接';
|
if (!v.isDataSynced) return '未对接';
|
||||||
return v.isOnline ? '在线' : '离线';
|
return v.isOnline ? '在线' : '离线';
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,7 @@ function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | nu
|
|||||||
if (kind === 'today') {
|
if (kind === 'today') {
|
||||||
return monitoringMileageValue(v);
|
return monitoringMileageValue(v);
|
||||||
}
|
}
|
||||||
return v.totalKm != null ? v.totalKm : '未对接';
|
return v.totalKm != null ? v.totalKm : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
||||||
@@ -62,7 +63,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
v.department || '',
|
v.department || '',
|
||||||
v.project || '',
|
v.project || '',
|
||||||
v.rentStatus || '',
|
v.rentStatus || '',
|
||||||
v.region || '',
|
v.region || '', v.validDays ?? '', v.missingDays ?? '', v.anomalyDays ?? '', v.qualityReasons?.join(', ') || '',
|
||||||
];
|
];
|
||||||
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
|
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
|
||||||
return [
|
return [
|
||||||
@@ -86,6 +87,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
{ wch: 16 }, // 项目
|
{ wch: 16 }, // 项目
|
||||||
{ wch: 10 }, // 租赁状态
|
{ wch: 10 }, // 租赁状态
|
||||||
{ wch: 12 }, // 运营区域
|
{ wch: 12 }, // 运营区域
|
||||||
|
{ wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 36 },
|
||||||
];
|
];
|
||||||
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
|
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
|
||||||
wsCols.push({ wch: 14 }); // 区间合计
|
wsCols.push({ wch: 14 }); // 区间合计
|
||||||
@@ -177,6 +179,6 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
: ctx.sortBy === 'statisticTime'
|
: ctx.sortBy === 'statisticTime'
|
||||||
? '统计时间'
|
? '统计时间'
|
||||||
: isRange ? '区间' : '今日';
|
: isRange ? '区间' : '今日';
|
||||||
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
|
const filename = `${dayKeys.length ? '里程逐日明细' : '里程区间汇总'}_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
|
||||||
XLSX.writeFile(wb, filename);
|
XLSX.writeFile(wb, filename);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
|
|||||||
import vehiclesRouter from './routes/vehicles.js';
|
import vehiclesRouter from './routes/vehicles.js';
|
||||||
import assetAssistantRouter from './routes/asset-assistant/index.js';
|
import assetAssistantRouter from './routes/asset-assistant/index.js';
|
||||||
import assetFaultRouter from './routes/asset-faults/index.js';
|
import assetFaultRouter from './routes/asset-faults/index.js';
|
||||||
|
import assetHistoryRouter from './routes/asset-history/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 HTTP 应用,但不监听端口、不访问数据库,也不启动后台任务。
|
* 创建 HTTP 应用,但不监听端口、不访问数据库,也不启动后台任务。
|
||||||
@@ -31,6 +32,7 @@ export function createApp(): Hono {
|
|||||||
app.use('/api/*', createPreviewMiddleware());
|
app.use('/api/*', createPreviewMiddleware());
|
||||||
|
|
||||||
app.route('/api/vehicles', vehiclesRouter);
|
app.route('/api/vehicles', vehiclesRouter);
|
||||||
|
app.route('/api/asset-history', assetHistoryRouter);
|
||||||
app.route('/api/asset-faults', assetFaultRouter);
|
app.route('/api/asset-faults', assetFaultRouter);
|
||||||
app.route('/api/asset-assistant', assetAssistantRouter);
|
app.route('/api/asset-assistant', assetAssistantRouter);
|
||||||
app.route('/api/mileage', mileageRouter);
|
app.route('/api/mileage', mileageRouter);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Vehicle } from '../types.js';
|
|||||||
import { isInventoryVehicle } from '../routes/vehicles/model.js';
|
import { isInventoryVehicle } from '../routes/vehicles/model.js';
|
||||||
|
|
||||||
/** 当前库存为公共资产;非库存车辆继续遵守本人/部门权限。历史业务使用原权限过滤。 */
|
/** 当前库存为公共资产;非库存车辆继续遵守本人/部门权限。历史业务使用原权限过滤。 */
|
||||||
export function filterAssetVehiclesByPermission(items: Vehicle[], user: AuthUser): Vehicle[] {
|
export function filterAssetVehiclesByPermission<T extends Vehicle>(items: T[], user: AuthUser): T[] {
|
||||||
if (user.permissionLevel === 'full') return items;
|
if (user.permissionLevel === 'full') return items;
|
||||||
const authorized = new Set(filterByPermission(items, user));
|
const authorized = new Set(filterByPermission(items, user));
|
||||||
return items.filter(vehicle => isInventoryVehicle(vehicle) || authorized.has(vehicle));
|
return items.filter(vehicle => isInventoryVehicle(vehicle) || authorized.has(vehicle));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||||
import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
||||||
|
import { startAssetHistoryScheduler } from './routes/asset-history/runtime.js';
|
||||||
|
|
||||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||||
export function startBackgroundServices(): void {
|
export function startBackgroundServices(): void {
|
||||||
@@ -7,4 +8,5 @@ export function startBackgroundServices(): void {
|
|||||||
console.error('scheduling bootstrap error:', error);
|
console.error('scheduling bootstrap error:', error);
|
||||||
});
|
});
|
||||||
startMileageBackgroundJobs();
|
startMileageBackgroundJobs();
|
||||||
|
startAssetHistoryScheduler();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Synthetic fixtures imported only by tests and the explicitly isolated QA harness.
|
||||||
|
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||||
|
import { counts, freezeLive, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
|
||||||
|
import { monthCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js';
|
||||||
|
import type { HistoryReader } from './store.js';
|
||||||
|
|
||||||
|
export function testVehicle(id: string, overrides: Partial<Vehicle> = {}): Vehicle {
|
||||||
|
return { id, plateNumber: `测试-${id}`, vin: `TESTVIN${id}`, type: '18T', model: '测试18T厢货', color: '白色',
|
||||||
|
location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市', status: 'Operating', operationStatus: '1', vehicleStatus: '5',
|
||||||
|
ownership: 'Self', rentCompany: '', contractNo: '测试合同', customerName: '测试客户公司', orgName: '业务公司',
|
||||||
|
departmentName: '部门甲', subjectOrg: '公司甲', projectName: '测试项目', customerManager: '负责人甲', managerId: 'manager-a', brandLabel: '测试品牌', ...overrides };
|
||||||
|
}
|
||||||
|
export function testRow(id: string, operation = '1'): VehicleRow {
|
||||||
|
return { id, 车牌号: `测试-${id}`, vin: `TESTVIN${id}`, 车辆租赁状态: '5', 车辆租赁状态Label: operation,
|
||||||
|
主体: '公司甲', 合同归属部门: '部门甲', 经理ID: 'manager-a', 车辆型号Label: '18吨厢式货车' } as VehicleRow;
|
||||||
|
}
|
||||||
|
export function testBatch(month: string, vehicles: Vehicle[] = [], overrides: Partial<HistoryBatch> = {}): HistoryBatch {
|
||||||
|
return { id: `batch-${month}`, month, status: 'COMPLETE', source: 'AUTO', scheduledFor: shanghaiTimestamp(monthCutoff(month)),
|
||||||
|
capturedAt: shanghaiTimestamp(monthCutoff(month)), counts: counts(vehicles), ...overrides };
|
||||||
|
}
|
||||||
|
export class MemoryHistoryReader implements HistoryReader {
|
||||||
|
settingsValue: HistorySettings | null = { enabledAt: '2026-08-01T00:00:00+08:00' };
|
||||||
|
catalog: HistoryBatch[] = [];
|
||||||
|
rows = new Map<string, HistoryVehicle[]>();
|
||||||
|
calls: string[] = [];
|
||||||
|
async settings() { this.calls.push('settings'); return this.settingsValue; }
|
||||||
|
async batches() { this.calls.push('batches'); return this.catalog; }
|
||||||
|
async vehicles(ids: string[]) { this.calls.push('vehicles'); return new Map(ids.map(id => [id, this.rows.get(id) || []])); }
|
||||||
|
add(month: string, vehicles: Vehicle[], overrides: Partial<HistoryBatch> = {}) {
|
||||||
|
const batch = testBatch(month, vehicles, overrides); this.catalog.push(batch); this.rows.set(batch.id, freezeLive(vehicles)); return batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
import pool from '../../db.js';
|
||||||
|
import { vehicleRepository } from '../vehicles/repository.js';
|
||||||
|
import { MysqlHistoryStore } from './store.js';
|
||||||
|
import { HistoryService, HistoryInputError, HistoryPeriodUnavailable } from './service.js';
|
||||||
|
|
||||||
|
export const historyStore = new MysqlHistoryStore(pool);
|
||||||
|
// Published routes expose live data and verified snapshots only.
|
||||||
|
// The retired handover reconstruction remains an offline audit artifact.
|
||||||
|
export const historyService = new HistoryService(historyStore, () => vehicleRepository.getVehicles());
|
||||||
|
export function createHistoryRouter(service: Pick<HistoryService, 'trend' | 'details' | 'livePoint'> = historyService) {
|
||||||
|
const router = new Hono<{ Variables: { user: AuthUser } }>();
|
||||||
|
router.use('*', async (c, next) => {
|
||||||
|
c.header('Cache-Control', 'private, no-store');
|
||||||
|
if (!c.get('user')) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
router.get('/trend', async c => c.json(await service.trend(c.get('user'), c.req.query())));
|
||||||
|
router.get('/live', async c => c.json(await service.livePoint(c.get('user'), c.req.query())));
|
||||||
|
router.get('/details', async c => c.json(await service.details(c.get('user'), c.req.query())));
|
||||||
|
router.onError((error, c) => {
|
||||||
|
if (error instanceof HistoryInputError) return c.json({ error: error.message }, 400);
|
||||||
|
if (error instanceof HistoryPeriodUnavailable) return c.json({ error: error.message }, 409);
|
||||||
|
console.error('[asset-history] read failed');
|
||||||
|
return c.json({ error: '资产历史数据暂不可用,请稍后重试' }, 503);
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
export default createHistoryRouter();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { monthCutoff, nextCapture, shanghaiMonth, shanghaiTimestamp } from '../../../shared/assets/history.js';
|
||||||
|
import { counts, freezeRows, makePoint, freezeLive } from './model.js';
|
||||||
|
import { testBatch, testRow, testVehicle } from './fixtures.js';
|
||||||
|
|
||||||
|
test('Shanghai month-end cutoff covers leap years, short/long months and cross-year boundaries', () => {
|
||||||
|
for (const [month, expected] of [['2024-02','2024-02-29'], ['2026-02','2026-02-28'], ['2026-04','2026-04-30'], ['2026-10','2026-10-31'], ['2026-12','2026-12-31']]) {
|
||||||
|
assert.equal(shanghaiTimestamp(monthCutoff(month)), `${expected}T17:00:00.000+08:00`);
|
||||||
|
assert.equal(monthCutoff(month).toISOString(), `${expected}T09:00:00.000Z`);
|
||||||
|
}
|
||||||
|
assert.equal(shanghaiMonth(new Date('2026-12-31T16:00:00Z')), '2027-01');
|
||||||
|
assert.equal(nextCapture(new Date('2026-12-31T09:00:00Z')), '2027-01-31T17:00:00.000+08:00');
|
||||||
|
assert.throws(() => monthCutoff('2026-13'));
|
||||||
|
});
|
||||||
|
test('host TZ cannot change cutoff or Shanghai calendar', () => {
|
||||||
|
const script = "import {monthCutoff,shanghaiMonth} from './src/shared/assets/history.ts'; console.log(monthCutoff('2024-02').toISOString(),shanghaiMonth(new Date('2026-12-31T16:01:00Z')))";
|
||||||
|
const run = (TZ: string) => execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', script], { env: { ...process.env, TZ }, encoding: 'utf8' }).trim();
|
||||||
|
assert.equal(run('UTC'), run('America/Los_Angeles'));
|
||||||
|
});
|
||||||
|
test('source rows preserve precise IDs and raw states; pending and abnormal do not add to inventory', () => {
|
||||||
|
const rows = [testRow('9223372036854775000'), testRow('self','2'), { ...testRow('pending','3'), 车辆租赁状态: '4' }, { ...testRow('abnormal','4'), 车辆租赁状态: '14' }];
|
||||||
|
const frozen = freezeRows(rows);
|
||||||
|
assert.deepEqual(counts(frozen), { total:4, operating:2, inventory:2 });
|
||||||
|
assert.equal(frozen[0].id, '9223372036854775000');
|
||||||
|
assert.equal(frozen[2].vehicleStatus, '4');
|
||||||
|
assert.equal(frozen[3].vehicleStatus, '14');
|
||||||
|
assert.equal('abnormalMove' in frozen[0], false);
|
||||||
|
assert.throws(() => freezeRows([testRow('1'),testRow('1')]), /重复/);
|
||||||
|
for (const state of ['5','99','']) assert.throws(() => freezeRows([testRow('bad',state)]), /未分类/);
|
||||||
|
});
|
||||||
|
test('cutoff switches live to capturing/missing without fallback; a zero snapshot is archived', () => {
|
||||||
|
const live = freezeLive([testVehicle('1')]);
|
||||||
|
assert.equal(makePoint('2026-10', new Date('2026-10-31T08:59:59Z'), undefined, [], live).state, 'LIVE');
|
||||||
|
assert.equal(makePoint('2026-10', new Date('2026-10-31T09:00:00Z'), undefined, [], live).state, 'CAPTURING');
|
||||||
|
const missing = makePoint('2026-10', new Date('2026-10-31T09:01:00Z'), undefined, [], live);
|
||||||
|
assert.equal(missing.state, 'MISSING'); assert.equal(missing.counts, null);
|
||||||
|
const archived = makePoint('2026-10', new Date('2026-10-31T09:00:20Z'), testBatch('2026-10'), [], live);
|
||||||
|
assert.equal(archived.state, 'ARCHIVED'); assert.deepEqual(archived.counts, { total:0,operating:0,inventory:0 });
|
||||||
|
assert.equal(makePoint('2026-11', new Date('2026-10-19T00:00Z')).state, 'FUTURE');
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { filterAssetVehiclesByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||||
|
import { isOperatingVehicle, isInventoryVehicle, transformRow } from '../vehicles/model.js';
|
||||||
|
import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiTimestamp, type HistoryCounts, type HistoryPoint } from '../../../shared/assets/history.js';
|
||||||
|
|
||||||
|
export interface HistoryVehicle extends Vehicle {
|
||||||
|
vehicleStatus: string | null;
|
||||||
|
disposition: 'operating' | 'inventory';
|
||||||
|
}
|
||||||
|
export interface HistoryBatch {
|
||||||
|
id: string; month: string; status: 'COMPLETE' | 'FAILED' | 'MISSED';
|
||||||
|
source: 'AUTO' | 'IMPORT'; scheduledFor: string; capturedAt: string | null;
|
||||||
|
counts: HistoryCounts;
|
||||||
|
}
|
||||||
|
export interface HistorySettings { enabledAt: string }
|
||||||
|
export class HistoryDataError extends Error {}
|
||||||
|
export class HistoryStorageUnavailable extends Error {}
|
||||||
|
|
||||||
|
export function validateVehicles(vehicles: Vehicle[]): void {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const vehicle of vehicles) {
|
||||||
|
if (typeof vehicle.id !== 'string' || !vehicle.id || ids.has(vehicle.id)) throw new HistoryDataError('车辆 ID 缺失或重复,无法生成可信快照');
|
||||||
|
ids.add(vehicle.id);
|
||||||
|
if (!isOperatingVehicle(vehicle) && !isInventoryVehicle(vehicle)) throw new HistoryDataError('存在未分类运营状态,无法生成可信快照');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function freezeRows(rows: VehicleRow[]): HistoryVehicle[] {
|
||||||
|
const vehicles = rows.map(row => {
|
||||||
|
const vehicle = transformRow(row);
|
||||||
|
return { ...vehicle, vehicleStatus: row.车辆租赁状态,
|
||||||
|
disposition: isOperatingVehicle(vehicle) ? 'operating' as const : 'inventory' as const };
|
||||||
|
});
|
||||||
|
validateVehicles(vehicles);
|
||||||
|
// Only freeze fields used by asset history. Movement work/customer attachments are unnecessary.
|
||||||
|
return vehicles.map(({ abnormalMove: _movement, pendingDelivery: _pending, ...vehicle }) => vehicle);
|
||||||
|
}
|
||||||
|
export function freezeLive(vehicles: Vehicle[]): HistoryVehicle[] {
|
||||||
|
validateVehicles(vehicles);
|
||||||
|
return vehicles.map(({ abnormalMove: _movement, pendingDelivery: _pending, ...v }) => ({ ...v,
|
||||||
|
vehicleStatus: v.vehicleStatus || null, disposition: isOperatingVehicle(v) ? 'operating' : 'inventory' }));
|
||||||
|
}
|
||||||
|
export function authorizedVehicles(vehicles: HistoryVehicle[], user: AuthUser, subject?: string): HistoryVehicle[] {
|
||||||
|
return maskCustomerNames(filterAssetVehiclesByPermission(vehicles, user).filter(v => !subject || v.subjectOrg === subject));
|
||||||
|
}
|
||||||
|
export function counts(vehicles: Vehicle[]): HistoryCounts {
|
||||||
|
return { total: vehicles.length, operating: vehicles.filter(isOperatingVehicle).length, inventory: vehicles.filter(isInventoryVehicle).length };
|
||||||
|
}
|
||||||
|
export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?: HistoryVehicle[], live?: HistoryVehicle[], annual = false): HistoryPoint {
|
||||||
|
const cutoff = monthCutoff(month);
|
||||||
|
let state: HistoryPoint['state'] = 'MISSING';
|
||||||
|
if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, 7)) ? 'LIVE' : 'FUTURE';
|
||||||
|
else if (batch?.status === 'COMPLETE') state = 'ARCHIVED';
|
||||||
|
else if (batch?.status === 'FAILED') state = 'FAILED';
|
||||||
|
else if (now.getTime() < cutoff.getTime() + CAPTURE_WINDOW_MS && batch?.status !== 'MISSED') state = 'CAPTURING';
|
||||||
|
const available = state === 'ARCHIVED' || state === 'LIVE';
|
||||||
|
const pointCounts = state === 'ARCHIVED' ? counts(data || []) : state === 'LIVE' ? counts(live || []) : null;
|
||||||
|
return {
|
||||||
|
period: annual ? month.slice(0, 4) : month,
|
||||||
|
label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month,
|
||||||
|
scheduledFor: shanghaiTimestamp(cutoff),
|
||||||
|
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? batch!.capturedAt : null,
|
||||||
|
state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null,
|
||||||
|
batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts,
|
||||||
|
difference: null, comparisonLabel: '暂无可比数据', detailAvailable: available,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function comparePoint(point: HistoryPoint, previous: HistoryPoint | undefined, annual: boolean): HistoryPoint {
|
||||||
|
if (point.reconstruction || previous?.reconstruction) {
|
||||||
|
const comparable = point.reconstruction && previous?.reconstruction && point.reconstruction.workbookSha256 === previous.reconstruction.workbookSha256;
|
||||||
|
return { ...point, difference: null,
|
||||||
|
...(point.reconstruction ? { reconstruction: { ...point.reconstruction, difference: comparable ? {
|
||||||
|
operating: point.reconstruction.operating - previous!.reconstruction!.operating,
|
||||||
|
inventory: point.reconstruction.inventory - previous!.reconstruction!.inventory,
|
||||||
|
} : null } } : {}),
|
||||||
|
comparisonLabel: comparable ? `推算值较${annual ? '上年年末' : '上月末'}` : '推算与实时或正式快照口径不同,暂无可比数据' };
|
||||||
|
}
|
||||||
|
const label = annual ? '上年末' : '上月末';
|
||||||
|
return { ...point, difference: point.counts && previous?.counts ? {
|
||||||
|
total: point.counts.total - previous.counts.total,
|
||||||
|
inventory: point.counts.inventory - previous.counts.inventory,
|
||||||
|
operating: point.counts.operating - previous.counts.operating,
|
||||||
|
} : null, comparisonLabel: point.counts && previous?.counts ? `${point.state === 'LIVE' ? '截至当前' : ''}较${label}` : '暂无可比数据' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
import { MemoryHistoryReader, testVehicle } from './fixtures.js';
|
||||||
|
import { HistoryService, HistoryPeriodUnavailable } from './service.js';
|
||||||
|
import { FileReconstructionReader, publishReconstruction, reconstructionPoint, validateReconstruction, type ReconstructionData } from './reconstruction.js';
|
||||||
|
|
||||||
|
const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'1',userName:'测试',loginName:'test',depName:'甲',depCode:'a',permissionLevel,roles:[] });
|
||||||
|
const now = () => new Date('2026-10-19T14:38:33+08:00');
|
||||||
|
const data = (): ReconstructionData => ({ schemaVersion:1,method:'HANDOVER_REVIEW_V1',workbookSha256:'a'.repeat(64),sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'测试核对表.xlsx',
|
||||||
|
periods:[{month:'2026-08',operating:1,inventory:0,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'operating',basis:'交车 D:1,2026-08-01 10:00:00'}]},
|
||||||
|
{month:'2026-09',operating:0,inventory:1,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'inventory',basis:'还车 R:2,2026-09-01 10:00:00'}]}] });
|
||||||
|
function setup(value = data()) {
|
||||||
|
const store = new MemoryHistoryReader(); let reads = 0;
|
||||||
|
const service = new HistoryService(store,async()=>[testVehicle('today')],now,{read:async()=>{reads++;return value;}});
|
||||||
|
return { store,service,reads:()=>reads };
|
||||||
|
}
|
||||||
|
test('reconstruction preserves unknown total, source date and string IDs without claiming a captured snapshot', async()=>{
|
||||||
|
const {service}=setup(); const trend=await service.trend(user(),{year:'2026'});
|
||||||
|
const p=trend.points[8];
|
||||||
|
assert.equal(p.state,'RECONSTRUCTED');assert.equal(p.counts,null);assert.equal(p.asOf,null);
|
||||||
|
assert.equal(p.reconstruction!.operating,0);assert.equal(p.reconstruction!.inventory,1);
|
||||||
|
assert.deepEqual(p.reconstruction!.difference,{operating:-1,inventory:1});
|
||||||
|
assert.equal(trend.points[7].reconstruction!.difference,null);
|
||||||
|
assert.equal(trend.points[9].state,'LIVE');assert.equal(trend.points[9].difference,null);
|
||||||
|
assert.equal(trend.points[10].state,'FUTURE');
|
||||||
|
const detail=await service.details(user(),{month:'2026-09',batchId:p.batchId!});
|
||||||
|
assert.equal(detail.counts,null);assert.equal(detail.items[0].id,'90071992547409931');
|
||||||
|
assert.equal(detail.items[0].plateNumber,'历史车牌');assert.equal(detail.items[0].subjectOrg,null);
|
||||||
|
assert.equal(detail.items[0].operationStatus,null);assert.equal(detail.items[0].customerName,null);
|
||||||
|
assert.match(detail.items[0].reconstructionBasis!,/R:2/);
|
||||||
|
});
|
||||||
|
test('company/department/personal scopes cannot load or query unowned reconstruction rows', async()=>{
|
||||||
|
for(const [level,params] of [['department',{}],['personal',{}],['full',{subject:'任意公司'}]] as const){
|
||||||
|
const {service,reads}=setup();const identity=user(level);
|
||||||
|
const trend=await service.trend(identity,{year:'2026',...params});assert.equal(trend.points[8].state,'MISSING');
|
||||||
|
await assert.rejects(()=>service.details(identity,{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`,...params}),HistoryPeriodUnavailable);
|
||||||
|
assert.equal(reads(),0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('formal archives take precedence and forged or stale reconstruction batch IDs are refused',async()=>{
|
||||||
|
const {service,store}=setup();store.add('2026-09',[testVehicle('formal')]);
|
||||||
|
const trend=await service.trend(user(),{});assert.equal(trend.points[8].state,'ARCHIVED');assert.equal(trend.points[8].reconstruction,undefined);
|
||||||
|
await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`}),HistoryPeriodUnavailable);
|
||||||
|
await assert.rejects(()=>service.details(user(),{month:'2026-08',batchId:'rec-forged'}),HistoryPeriodUnavailable);
|
||||||
|
assert.equal((await service.details(user(),{month:'2026-09'})).items[0].id,'formal');
|
||||||
|
});
|
||||||
|
test('reconstructed category/search/pagination counts agree with the selected period; missing months stay absent',async()=>{
|
||||||
|
const value=data();value.periods[1].vehicles=Array.from({length:61},(_,i)=>({id:String(i+1),plateNumber:`历史${i}`,vin:`OLD${i}`,disposition:'inventory',basis:'还车 R:1'}));value.periods[1].inventory=61;
|
||||||
|
const {service}=setup(value);
|
||||||
|
const all=await service.details(user(),{month:'2026-09',page:'2',pageSize:'30'});assert.equal(all.total,61);assert.equal(all.items.length,30);
|
||||||
|
assert.equal((await service.details(user(),{month:'2026-09',category:'operating'})).total,0);
|
||||||
|
assert.equal((await service.details(user(),{month:'2026-09',search:'OLD60'})).total,1);
|
||||||
|
assert.equal((await service.trend(user(),{})).points[6].state,'MISSING');
|
||||||
|
});
|
||||||
|
test('annual estimates reuse December only; no sum, current-month replacement, or cross-source difference',async()=>{
|
||||||
|
const value=data();value.periods.push({...value.periods[0],month:'2025-12'});
|
||||||
|
const {service}=setup(value);const result=await service.trend(user(),{mode:'annual'});
|
||||||
|
assert.equal(result.points.find(p=>p.period==='2025')!.reconstruction!.operating,1);
|
||||||
|
assert.equal(result.points.find(p=>p.period==='2026')!.state,'LIVE');assert.equal(result.points.at(-1)!.difference,null);
|
||||||
|
assert.equal(reconstructionPoint(value,'2026-10',false,now()),null);
|
||||||
|
});
|
||||||
|
test('schema rejects duplicates, unsafe IDs, invented counts, current/future months and missing provenance',()=>{
|
||||||
|
const mutate=[(d:ReconstructionData)=>{d.periods.push(d.periods[0]);},(d:ReconstructionData)=>{d.periods[0].vehicles[0].id=1 as never;},
|
||||||
|
(d:ReconstructionData)=>{d.periods[0].operating=99;},(d:ReconstructionData)=>{d.periods[0].month='2026-10';},
|
||||||
|
(d:ReconstructionData)=>{d.workbookSha256='';},(d:ReconstructionData)=>{d.periods[0].vehicles.push(d.periods[0].vehicles[0]);d.periods[0].operating=2;},
|
||||||
|
(d:ReconstructionData)=>{d.periods[0].unclassified=-1;}];
|
||||||
|
for(const change of mutate){const d=data();change(d);assert.throws(()=>validateReconstruction(d),/无效/);}
|
||||||
|
const d=data();Object.assign(d.periods[0].vehicles[0],{subjectOrg:'今日主体',customerName:'不应导入的客户'});
|
||||||
|
assert.equal(JSON.stringify(validateReconstruction(d)).includes('今日主体'),false);
|
||||||
|
const zero=data();zero.periods[0]={month:'2026-08',operating:0,inventory:0,unclassified:3,currentlyExited:0,vehicles:[]};
|
||||||
|
assert.equal(reconstructionPoint(validateReconstruction(zero),'2026-08',false,now())!.reconstruction!.operating,0);
|
||||||
|
});
|
||||||
|
test('explicit local publication is atomic, idempotent and immutable; reader has no database dependencies',async()=>{
|
||||||
|
const dir=await mkdtemp(join(tmpdir(),'asset-reconstruction-'));
|
||||||
|
try{
|
||||||
|
const path=join(dir,'history.json');const value=data();
|
||||||
|
const results=await Promise.all([publishReconstruction(path,value),publishReconstruction(path,value)]);
|
||||||
|
assert.deepEqual(results.sort(),['created','unchanged']);
|
||||||
|
const before=await readFile(path,'utf8');const different=data();different.workbookSha256='b'.repeat(64);
|
||||||
|
await assert.rejects(()=>publishReconstruction(path,different),/禁止自动覆盖/);assert.equal(await readFile(path,'utf8'),before);
|
||||||
|
assert.deepEqual(await new FileReconstructionReader(path).read(),validateReconstruction(value));
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { readFile, writeFile, link, unlink, mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { monthCutoff, shanghaiTimestamp, type HistoryDetailItem, type HistoryPoint } from '../../../shared/assets/history.js';
|
||||||
|
|
||||||
|
export interface ReconstructionPeriod {
|
||||||
|
month: string; operating: number; inventory: number; unclassified: number; currentlyExited: number;
|
||||||
|
vehicles: { id: string; plateNumber: string; vin: string; disposition: 'operating' | 'inventory'; basis: string }[];
|
||||||
|
}
|
||||||
|
export interface ReconstructionData {
|
||||||
|
schemaVersion: 1; method: 'HANDOVER_REVIEW_V1'; workbookSha256: string;
|
||||||
|
sourceAsOf: string; sourceReference: string; periods: ReconstructionPeriod[];
|
||||||
|
}
|
||||||
|
export function validateReconstruction(input: unknown): ReconstructionData {
|
||||||
|
const fail = (): never => { throw new Error('历史回溯文件无效,未加载任何回填数据'); };
|
||||||
|
if (!input || typeof input !== 'object') return fail();
|
||||||
|
const data = input as ReconstructionData;
|
||||||
|
if (data.schemaVersion !== 1 || data.method !== 'HANDOVER_REVIEW_V1' || !/^[a-f0-9]{64}$/.test(data.workbookSha256) ||
|
||||||
|
typeof data.sourceReference !== 'string' || !data.sourceReference.trim() || data.sourceReference.length > 200 ||
|
||||||
|
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?\+08:00$/.test(data.sourceAsOf) || !Number.isFinite(Date.parse(data.sourceAsOf)) ||
|
||||||
|
!Array.isArray(data.periods) || data.periods.length > 1200) return fail();
|
||||||
|
const months = new Set<string>();
|
||||||
|
for (const period of data.periods) {
|
||||||
|
if (!period || !/^20\d{2}-(0[1-9]|1[0-2])$/.test(period.month) || months.has(period.month) || monthCutoff(period.month).getTime() >= Date.parse(data.sourceAsOf) ||
|
||||||
|
![period.operating, period.inventory, period.unclassified, period.currentlyExited].every(v => Number.isSafeInteger(v) && v >= 0) ||
|
||||||
|
period.currentlyExited > period.operating + period.inventory || !Array.isArray(period.vehicles) || period.vehicles.length !== period.operating + period.inventory) return fail();
|
||||||
|
months.add(period.month);
|
||||||
|
const ids = new Set<string>(); let operating = 0;
|
||||||
|
for (const v of period.vehicles) {
|
||||||
|
if (!v || typeof v.id !== 'string' || !/^[1-9]\d{0,63}$/.test(v.id) || ids.has(v.id) || !['operating', 'inventory'].includes(v.disposition) ||
|
||||||
|
typeof v.plateNumber !== 'string' || v.plateNumber.length > 100 || typeof v.vin !== 'string' || v.vin.length > 100 ||
|
||||||
|
typeof v.basis !== 'string' || !v.basis.trim() || v.basis.length > 1000) return fail();
|
||||||
|
ids.add(v.id); if (v.disposition === 'operating') operating++;
|
||||||
|
}
|
||||||
|
if (operating !== period.operating) return fail();
|
||||||
|
}
|
||||||
|
// Return only reviewed fields. Ignore any attempted current company/customer/role injection.
|
||||||
|
return { schemaVersion: 1, method: data.method, workbookSha256: data.workbookSha256, sourceAsOf: data.sourceAsOf, sourceReference: data.sourceReference,
|
||||||
|
periods: data.periods.map(p => ({ month: p.month, operating: p.operating, inventory: p.inventory, unclassified: p.unclassified, currentlyExited: p.currentlyExited,
|
||||||
|
vehicles: p.vehicles.map(v => ({ id: v.id, plateNumber: v.plateNumber, vin: v.vin, disposition: v.disposition, basis: v.basis })) })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconstructionReader { read(): Promise<ReconstructionData> }
|
||||||
|
/** Opt-in, immutable local file. Reading never creates storage or writes a database. */
|
||||||
|
export class FileReconstructionReader implements ReconstructionReader {
|
||||||
|
private loaded?: Promise<ReconstructionData>;
|
||||||
|
constructor(private readonly path: string) {}
|
||||||
|
read() {
|
||||||
|
this.loaded ||= readFile(this.path, 'utf8').then(text => {
|
||||||
|
if (text.length > 50_000_000) throw new Error('历史回溯文件超出限制');
|
||||||
|
return validateReconstruction(JSON.parse(text));
|
||||||
|
});
|
||||||
|
return this.loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit local publication only: create atomically; never replace a different revision. */
|
||||||
|
export async function publishReconstruction(path: string, input: unknown): Promise<'created' | 'unchanged'> {
|
||||||
|
const text = JSON.stringify(validateReconstruction(input));
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
||||||
|
await writeFile(temporary, text, { flag: 'wx', mode: 0o600 });
|
||||||
|
try {
|
||||||
|
try { await link(temporary, path); return 'created'; }
|
||||||
|
catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||||
|
if (await readFile(path, 'utf8') === text) return 'unchanged';
|
||||||
|
throw new Error('目标已有不同版本的回溯数据,禁止自动覆盖');
|
||||||
|
}
|
||||||
|
} finally { await unlink(temporary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconstructionPoint(data: ReconstructionData, month: string, annual: boolean, now: Date): HistoryPoint | null {
|
||||||
|
const period = data.periods.find(p => p.month === month);
|
||||||
|
if (!period || monthCutoff(month) >= now) return null;
|
||||||
|
return { period: annual ? month.slice(0, 4) : month, label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month,
|
||||||
|
scheduledFor: shanghaiTimestamp(monthCutoff(month)), asOf: null, state: 'RECONSTRUCTED', source: 'RECONSTRUCTED',
|
||||||
|
batchId: `rec-${data.workbookSha256.slice(0, 12)}-${month}`, counts: null, difference: null, comparisonLabel: '暂无可比数据', detailAvailable: true,
|
||||||
|
reconstruction: { operating: period.operating, inventory: period.inventory, unclassified: period.unclassified,
|
||||||
|
currentlyExited: period.currentlyExited, sourceAsOf: data.sourceAsOf, sourceReference: data.sourceReference, workbookSha256: data.workbookSha256, difference: null } };
|
||||||
|
}
|
||||||
|
export function reconstructionItems(period: ReconstructionPeriod): HistoryDetailItem[] {
|
||||||
|
return period.vehicles.map(v => ({ id: v.id, plateNumber: v.plateNumber, vin: v.vin, disposition: v.disposition,
|
||||||
|
type: '', model: '', operationStatus: null, vehicleStatus: null, subjectOrg: null, departmentName: null,
|
||||||
|
customerManager: null, customerName: null, contractNo: null, reconstructionBasis: v.basis }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { historyStore } from './index.js';
|
||||||
|
import { HistoryScheduler } from './scheduler.js';
|
||||||
|
|
||||||
|
let scheduler: HistoryScheduler | undefined;
|
||||||
|
export function startAssetHistoryScheduler(): void {
|
||||||
|
if (process.env.ASSET_HISTORY_CAPTURE_ENABLED !== '1' || scheduler) return;
|
||||||
|
scheduler = new HistoryScheduler(historyStore);
|
||||||
|
scheduler.start();
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { HistoryScheduler, type HistoryCaptureStore } from './scheduler.js';
|
||||||
|
import { testBatch } from './fixtures.js';
|
||||||
|
function fixture(enabledAt='2026-10-19T13:00:00+08:00') {
|
||||||
|
const captures:string[]=[],missed:string[]=[],failed:string[]=[];
|
||||||
|
let activates=0;
|
||||||
|
const store:HistoryCaptureStore={
|
||||||
|
activate:async()=>{activates++;return{enabledAt};},batches:async()=>[],
|
||||||
|
capture:async(month)=>{captures.push(month);return'captured';},
|
||||||
|
recordOutcome:async(month,status)=>{(status==='FAILED'?failed:missed).push(month);},
|
||||||
|
};
|
||||||
|
return {store,captures,missed,failed,activates:()=>activates};
|
||||||
|
}
|
||||||
|
test('scheduler captures only in month-end 17:00 minute and activation is persisted once',async()=>{
|
||||||
|
const f=fixture();let date=new Date('2026-10-31T16:59:59+08:00');
|
||||||
|
const scheduler=new HistoryScheduler(f.store,()=>date,()=>{});
|
||||||
|
await scheduler.tick();assert.deepEqual(f.captures,[]);
|
||||||
|
date=new Date('2026-10-31T17:00:00+08:00');await scheduler.tick();assert.deepEqual(f.captures,['2026-10']);assert.equal(f.activates(),1);
|
||||||
|
});
|
||||||
|
test('restart records missed eligible months without backfilling old live data or pre-activation periods',async()=>{
|
||||||
|
const f=fixture();const scheduler=new HistoryScheduler(f.store,()=>new Date('2027-01-15T08:00:00+08:00'),()=>{});
|
||||||
|
await scheduler.tick();assert.deepEqual(f.captures,[]);assert.deepEqual(f.missed,['2026-10','2026-11','2026-12']);
|
||||||
|
const late=fixture('2026-10-31T17:00:10+08:00');await new HistoryScheduler(late.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{}).tick();
|
||||||
|
assert.deepEqual(late.captures,[]);assert.deepEqual(late.missed,[]);
|
||||||
|
});
|
||||||
|
test('completed December skips duplicate annual capture; failures stay marked without late retries',async()=>{
|
||||||
|
const f=fixture();f.store.batches=async()=>[testBatch('2026-10')];
|
||||||
|
f.store.capture=async()=>{throw new Error('source unavailable');};
|
||||||
|
await new HistoryScheduler(f.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick();
|
||||||
|
assert.deepEqual(f.missed,['2026-11']);assert.deepEqual(f.failed,['2026-12']);
|
||||||
|
const done=fixture('2026-12-01T00:00+08:00');done.store.batches=async()=>[testBatch('2026-12')];
|
||||||
|
await new HistoryScheduler(done.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick();assert.deepEqual(done.captures,[]);
|
||||||
|
});
|
||||||
|
test('concurrent ticks and repeated start do not register duplicate jobs',async()=>{
|
||||||
|
const f=fixture();let release!:()=>void;
|
||||||
|
f.store.capture=async()=>{f.captures.push('2026-10');await new Promise<void>(r=>{release=r;});return'captured';};
|
||||||
|
const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{});
|
||||||
|
const stop=scheduler.start();scheduler.start();
|
||||||
|
await new Promise<void>(r=>setImmediate(r));await scheduler.tick();assert.equal(f.captures.length,1);
|
||||||
|
stop();release();await new Promise<void>(r=>setImmediate(r));
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiMonth, shiftMonth } from '../../../shared/assets/history.js';
|
||||||
|
import type { CaptureResult } from './store.js';
|
||||||
|
import { HistoryDataError, type HistoryBatch, type HistorySettings } from './model.js';
|
||||||
|
|
||||||
|
export interface HistoryCaptureStore {
|
||||||
|
activate(now: Date): Promise<HistorySettings>;
|
||||||
|
batches(): Promise<HistoryBatch[]>;
|
||||||
|
capture(month: string, clock: () => Date): Promise<CaptureResult>;
|
||||||
|
recordOutcome(month: string, status: 'MISSED' | 'FAILED', code: string): Promise<void>;
|
||||||
|
}
|
||||||
|
export class HistoryScheduler {
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private running = false;
|
||||||
|
private ticking = false;
|
||||||
|
private settings: HistorySettings | null = null;
|
||||||
|
constructor(private readonly store: HistoryCaptureStore, private readonly clock: () => Date = () => new Date(),
|
||||||
|
private readonly log: (message: string) => void = console.log) {}
|
||||||
|
|
||||||
|
/** Exported for deterministic testing and explicit operational checks; never mounted as HTTP write. */
|
||||||
|
async tick(): Promise<void> {
|
||||||
|
if (this.ticking) return;
|
||||||
|
this.ticking = true;
|
||||||
|
try {
|
||||||
|
const now = this.clock();
|
||||||
|
this.settings ||= await this.store.activate(now);
|
||||||
|
const enabled = new Date(this.settings.enabledAt);
|
||||||
|
const existing = new Map((await this.store.batches()).map(b => [b.month, b]));
|
||||||
|
for (let month = shanghaiMonth(enabled); month <= shanghaiMonth(now); month = shiftMonth(month, 1)) {
|
||||||
|
const cutoff = monthCutoff(month);
|
||||||
|
if (cutoff < enabled || cutoff > now || existing.get(month)?.status === 'COMPLETE') continue;
|
||||||
|
if (now.getTime() >= cutoff.getTime() + CAPTURE_WINDOW_MS) {
|
||||||
|
if (!existing.has(month)) { await this.store.recordOutcome(month, 'MISSED', 'CAPTURE_WINDOW_MISSED'); this.log(`[asset-history] missed ${month}`); }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try { const result = await this.store.capture(month, this.clock); this.log(`[asset-history] ${month}: ${result}`); }
|
||||||
|
catch (error) {
|
||||||
|
const databaseCode = (error as { code?: unknown })?.code;
|
||||||
|
const code = error instanceof HistoryDataError ? 'INVALID_VEHICLE_DATA' : typeof databaseCode === 'string' && /^[A-Z0-9_]{1,48}$/.test(databaseCode) ? databaseCode : 'CAPTURE_FAILED';
|
||||||
|
await this.store.recordOutcome(month, 'FAILED', code);
|
||||||
|
this.log(`[asset-history] failed ${month} (${code}); no later live data will be substituted`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { this.ticking = false; }
|
||||||
|
}
|
||||||
|
start(): () => void {
|
||||||
|
if (this.running) return () => this.stop();
|
||||||
|
this.running = true;
|
||||||
|
const run = async () => {
|
||||||
|
if (!this.running) return;
|
||||||
|
try { await this.tick(); } catch { this.log('[asset-history] scheduler unavailable; check migration and database access'); }
|
||||||
|
if (!this.running) return;
|
||||||
|
const now = this.clock();
|
||||||
|
const untilCutoff = monthCutoff(shanghaiMonth(now)).getTime() - now.getTime();
|
||||||
|
this.timer = setTimeout(run, untilCutoff > 0 ? Math.min(untilCutoff, 60_000) : 15_000);
|
||||||
|
this.timer.unref?.();
|
||||||
|
};
|
||||||
|
void run();
|
||||||
|
return () => this.stop();
|
||||||
|
}
|
||||||
|
stop(): void { this.running = false; clearTimeout(this.timer); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
import { createPreviewMiddleware } from '../../auth/preview/routes.js';
|
||||||
|
import { createHistoryRouter } from './index.js';
|
||||||
|
import { HistoryService, HistoryInputError, HistoryPeriodUnavailable } from './service.js';
|
||||||
|
import { HistoryStorageUnavailable } from './model.js';
|
||||||
|
import { MemoryHistoryReader, testVehicle } from './fixtures.js';
|
||||||
|
|
||||||
|
const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'manager-a',loginName:'a',userName:'甲',depCode:'a',depName:'部门甲',permissionLevel,roles:[] });
|
||||||
|
const now = () => new Date('2026-10-19T13:00:00+08:00');
|
||||||
|
const inventory = testVehicle('stock',{ operationStatus:'3',status:'Abnormal',departmentName:'部门乙',managerId:'manager-b',subjectOrg:'历史公司' });
|
||||||
|
const leased = testVehicle('own');
|
||||||
|
const foreign = testVehicle('foreign',{ managerId:'manager-b',departmentName:'部门乙' });
|
||||||
|
function setup() {
|
||||||
|
const store = new MemoryHistoryReader(); store.add('2026-09',[leased,foreign,inventory]);
|
||||||
|
const live = [testVehicle('today'), testVehicle('today-stock',{ operationStatus:'4',status:'Pending' })];
|
||||||
|
return { store, live, service:new HistoryService(store,async()=>live,now) };
|
||||||
|
}
|
||||||
|
test('monthly holes/future remain null; current values are real; comparisons never skip missing periods', async () => {
|
||||||
|
const {store,service}=setup(); store.add('2026-07',[leased]);
|
||||||
|
const result=await service.trend(user(),{year:'2026'});
|
||||||
|
assert.equal(result.points.length,12);
|
||||||
|
assert.deepEqual(result.points[8].counts,{total:3,operating:2,inventory:1});
|
||||||
|
assert.equal(result.points[8].difference,null); // August is missing, July cannot substitute.
|
||||||
|
assert.deepEqual(result.points[9].difference,{total:-1,operating:-1,inventory:0});
|
||||||
|
assert.equal(result.points[9].state,'LIVE'); assert.equal(result.points[0].counts,null);
|
||||||
|
assert.equal(result.points[10].state,'FUTURE'); assert.equal(result.points[10].counts,null);
|
||||||
|
assert.ok(result.subjects.includes('历史公司'));
|
||||||
|
});
|
||||||
|
test('annual counts use only December snapshots, not a sum of months', async () => {
|
||||||
|
const {store,service}=setup(); store.add('2025-12',[leased]); store.add('2025-11',[leased,foreign,inventory]);
|
||||||
|
const result=await service.trend(user(),{mode:'annual'});
|
||||||
|
assert.deepEqual(result.points.find(p=>p.period==='2025')!.counts,{total:1,operating:1,inventory:0});
|
||||||
|
assert.equal(result.points.at(-1)!.state,'LIVE');
|
||||||
|
assert.deepEqual(result.points.at(-1)!.difference,{total:1,operating:0,inventory:1});
|
||||||
|
});
|
||||||
|
test('full/department/personal and manager aliases share snapshot/live public inventory policy', async () => {
|
||||||
|
const {service}=setup();
|
||||||
|
for (const level of ['full','department','personal'] as const) {
|
||||||
|
const result=await service.trend(user(level),{});
|
||||||
|
const september=result.points[8];
|
||||||
|
assert.equal(september.counts!.total,level==='full'?3:2);
|
||||||
|
const detail=await service.details(user(level),{month:'2026-09'});
|
||||||
|
assert.equal(detail.total,september.counts!.total); assert.deepEqual(detail.counts,september.counts);
|
||||||
|
assert.ok(detail.items.some(v=>v.id==='stock'));
|
||||||
|
assert.equal(detail.items.some(v=>v.id==='foreign'),level==='full');
|
||||||
|
assert.ok(!JSON.stringify(detail).includes('测试客户公司'));
|
||||||
|
}
|
||||||
|
const alias={...user('personal'),userId:'new-id',managerIds:['manager-a']};
|
||||||
|
assert.equal((await service.details(alias,{month:'2026-09'})).total,2);
|
||||||
|
});
|
||||||
|
test('company filter intersects permission; foreign batch IDs cannot bypass month or identity', async () => {
|
||||||
|
const {service}=setup();
|
||||||
|
const trend=await service.trend(user('personal'),{subject:'历史公司'});
|
||||||
|
assert.deepEqual(trend.points[8].counts,{total:1,operating:0,inventory:1});
|
||||||
|
assert.equal((await service.details(user('personal'),{month:'2026-09',subject:'公司甲'})).total,1);
|
||||||
|
assert.equal((await service.details(user('personal'),{month:'2026-09',subject:'任意公司'})).total,0);
|
||||||
|
await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:'other-batch'}),HistoryPeriodUnavailable);
|
||||||
|
});
|
||||||
|
test('historical vehicles survive current deletion, reassignment and exit without any live join', async () => {
|
||||||
|
const {store}=setup(); let calls=0;
|
||||||
|
const service=new HistoryService(store,async()=>{calls++;throw new Error('live must not be read');},now);
|
||||||
|
const result=await service.details(user('personal'),{month:'2026-09'});
|
||||||
|
assert.deepEqual(result.items.map(v=>v.id).sort(),['own','stock']);
|
||||||
|
assert.equal(calls,0);
|
||||||
|
assert.equal((await service.trend(user(),{year:'2025'})).points.every(p=>p.state==='MISSING'),true);
|
||||||
|
assert.equal(calls,0);
|
||||||
|
});
|
||||||
|
test('pagination/search/category use authorized snapshot only, keeping unsearched counts intact', async () => {
|
||||||
|
const {store,service}=setup();store.add('2026-08',Array.from({length:65},(_,i)=>testVehicle(String(i))));
|
||||||
|
const detail=await service.details(user(),{month:'2026-08',page:'2',pageSize:'30'});
|
||||||
|
assert.equal(detail.total,65);assert.equal(detail.items.length,30);assert.equal(detail.counts!.total,65);
|
||||||
|
const filtered=await service.details(user(),{month:'2026-09',category:'inventory',search:'TESTVINstock'});
|
||||||
|
assert.equal(filtered.total,1);assert.equal(filtered.counts!.total,3);
|
||||||
|
});
|
||||||
|
test('missing storage degrades to live without any DDL; genuine database errors are not hidden', async () => {
|
||||||
|
const {store,live}=setup();store.settings=async()=>{throw new HistoryStorageUnavailable();};store.batches=async()=>{throw new HistoryStorageUnavailable();};
|
||||||
|
const service=new HistoryService(store,async()=>live,now);
|
||||||
|
const result=await service.trend(user(),{});assert.equal(result.storageReady,false);assert.equal(result.points[9].counts!.total,2);assert.equal(result.points[8].counts,null);
|
||||||
|
await assert.rejects(()=>service.details(user(),{month:'2026-09'}),HistoryPeriodUnavailable);
|
||||||
|
store.batches=async()=>{throw new Error('database offline');};await assert.rejects(()=>service.trend(user(),{}),/offline/);
|
||||||
|
});
|
||||||
|
test('once cutoff passes live values cannot replace missing history, even when capture was not enabled', async () => {
|
||||||
|
const {store}=setup();store.settingsValue=null;let liveCalls=0;
|
||||||
|
const service=new HistoryService(store,async()=>{liveCalls++;return[leased];},()=>new Date('2026-10-31T17:00:30+08:00'));
|
||||||
|
const result=await service.trend(user(),{});assert.equal(result.points[9].state,'MISSING');assert.equal(result.points[9].counts,null);
|
||||||
|
assert.equal(liveCalls,0);await assert.rejects(()=>service.details(user(),{month:'2026-10'}),HistoryPeriodUnavailable);
|
||||||
|
});
|
||||||
|
test('snapshot count integrity failure never appears as a legitimate zero', async () => {
|
||||||
|
const {store,service}=setup();store.rows.clear();await assert.rejects(()=>service.trend(user(),{}),/integrity/);
|
||||||
|
});
|
||||||
|
test('invalid parameters fail before querying details or live; valid archived zero retains detail access', async () => {
|
||||||
|
const {store,service}=setup();store.add('2026-08',[]);
|
||||||
|
assert.equal((await service.details(user(),{month:'2026-08'})).total,0);
|
||||||
|
for(const params of [{year:'NaN'},{year:'2027'},{mode:'weekly'}] as Record<string,string>[]) await assert.rejects(()=>service.trend(user(),params),HistoryInputError);
|
||||||
|
for(const params of [{month:'2026-13'},{month:'2026-09',page:'0'},{month:'2026-09',pageSize:'101'},{month:'2026-09',category:'hacked'}] as Record<string,string>[]) await assert.rejects(()=>service.details(user(),params),HistoryInputError);
|
||||||
|
});
|
||||||
|
test('HTTP router authenticates, validates and exposes only read routes with private no-store', async () => {
|
||||||
|
const {service}=setup();const router=createHistoryRouter(service);
|
||||||
|
assert.equal((await router.request('/trend')).status,401);
|
||||||
|
const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user('personal'));await next();});app.route('/',router);
|
||||||
|
const response=await app.request('/trend?year=2026');assert.equal(response.status,200);assert.match(response.headers.get('Cache-Control')!,/private.*no-store/);
|
||||||
|
assert.equal((await app.request('/details?month=2026-08')).status,409);
|
||||||
|
assert.equal((await app.request('/trend?year=x')).status,400);
|
||||||
|
assert.equal((await app.request('/details?month=2026-09&batchId=forged')).status,409);
|
||||||
|
assert.equal((await app.request('/capture',{method:'POST'})).status,404);
|
||||||
|
});
|
||||||
|
test('permission-preview identity applies to history GET; capture/import writes remain blocked', async () => {
|
||||||
|
const {service}=setup();const audit:string[]=[];
|
||||||
|
const preview={current:async()=>({user:user('personal'),sessionId:'test-session'}),audit:async(event:{event:string})=>{audit.push(event.event);}};
|
||||||
|
const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user());await next();});
|
||||||
|
app.use('*',createPreviewMiddleware(preview as never));app.route('/api/asset-history',createHistoryRouter(service));
|
||||||
|
const response=await app.request('/api/asset-history/details?month=2026-09',{headers:{'X-BI-Preview':'isolated-test'}});
|
||||||
|
assert.equal(response.status,200);assert.equal((await response.json()).total,2);
|
||||||
|
assert.equal((await app.request('/api/asset-history/import',{method:'POST',headers:{'X-BI-Preview':'isolated-test'}})).status,403);
|
||||||
|
assert.deepEqual(audit,['read','blocked-write']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
import type { Vehicle } from '../../types.js';
|
||||||
|
import { isOperatingVehicle } from '../vehicles/model.js';
|
||||||
|
import { HISTORY_STATE_LABELS, nextCapture, shanghaiMonth, shiftMonth, monthCutoff, type HistoryMode, type HistoryTrend, type HistoryCategory, type HistoryDetails } from '../../../shared/assets/history.js';
|
||||||
|
import { authorizedVehicles, comparePoint, counts, freezeLive, HistoryStorageUnavailable, makePoint, validateVehicles, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
|
||||||
|
import type { HistoryReader } from './store.js';
|
||||||
|
import { reconstructionItems, reconstructionPoint, type ReconstructionReader } from './reconstruction.js';
|
||||||
|
|
||||||
|
export class HistoryInputError extends Error {}
|
||||||
|
export class HistoryPeriodUnavailable extends Error {}
|
||||||
|
export function parseHistoryQuery(params: Record<string, string>, now: Date) {
|
||||||
|
const mode = params.mode || 'monthly';
|
||||||
|
const year = Number(params.year || shanghaiMonth(now).slice(0, 4));
|
||||||
|
if (!['monthly', 'annual'].includes(mode) || !Number.isInteger(year) || year < 2000 || year > Number(shanghaiMonth(now).slice(0, 4))) throw new HistoryInputError('年份或统计方式无效');
|
||||||
|
if ((params.subject || '').length > 500) throw new HistoryInputError('公司筛选无效');
|
||||||
|
return { mode: mode as HistoryMode, year, subject: params.subject?.trim() || undefined };
|
||||||
|
}
|
||||||
|
export class HistoryService {
|
||||||
|
constructor(private readonly store: HistoryReader, private readonly live: () => Promise<Vehicle[]>, private readonly clock: () => Date = () => new Date(), private readonly reconstruction?: ReconstructionReader) {}
|
||||||
|
|
||||||
|
private async reconstructionData(user: AuthUser, subject?: string) {
|
||||||
|
// These reviewed records have no trustworthy historical owner/dept/company.
|
||||||
|
// Never grant their visibility from today's vehicle IDs or company values.
|
||||||
|
return this.reconstruction && user.permissionLevel === 'full' && !subject ? this.reconstruction.read() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async livePoint(user: AuthUser, params: Record<string, string>) {
|
||||||
|
const now = this.clock();
|
||||||
|
const query = parseHistoryQuery(params, now);
|
||||||
|
const month = query.mode === 'annual' ? `${shanghaiMonth(now).slice(0, 4)}-12` : shanghaiMonth(now);
|
||||||
|
const rows = now < monthCutoff(month) ? authorizedVehicles(freezeLive(await this.live()), user, query.subject) : [];
|
||||||
|
return makePoint(month, now, undefined, [], rows, query.mode === 'annual');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async archives(months: string[], catalog?: HistoryBatch[]) {
|
||||||
|
let settings: HistorySettings | null = null;
|
||||||
|
let batches: HistoryBatch[] = [];
|
||||||
|
try {
|
||||||
|
[settings, batches] = await Promise.all([this.store.settings(), catalog ? Promise.resolve(catalog) : this.store.batches()]);
|
||||||
|
const selected = batches.filter(b => b.status === 'COMPLETE' && months.includes(b.month));
|
||||||
|
const vehicles = await this.store.vehicles(selected.map(b => b.id));
|
||||||
|
for (const batch of selected) {
|
||||||
|
const rows = vehicles.get(batch.id) || [];
|
||||||
|
validateVehicles(rows);
|
||||||
|
const totals = counts(rows);
|
||||||
|
if (!batch.capturedAt || Object.entries(totals).some(([key, value]) => value !== batch.counts[key as keyof typeof totals]) || rows.some(v => v.disposition !== (isOperatingVehicle(v) ? 'operating' : 'inventory'))) throw new Error('Snapshot integrity mismatch');
|
||||||
|
}
|
||||||
|
return { settings, batches, vehicles, ready: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof HistoryStorageUnavailable)) throw error;
|
||||||
|
return { settings: null, batches: [], vehicles: new Map<string, HistoryVehicle[]>(), ready: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async trend(user: AuthUser, params: Record<string, string>): Promise<HistoryTrend> {
|
||||||
|
const now = this.clock();
|
||||||
|
const query = parseHistoryQuery(params, now);
|
||||||
|
const currentYear = Number(shanghaiMonth(now).slice(0, 4));
|
||||||
|
const reconstruction = await this.reconstructionData(user, query.subject);
|
||||||
|
// Metadata is small (one batch/month); only relevant detail batches are loaded.
|
||||||
|
let catalog: HistoryBatch[] = [];
|
||||||
|
try { catalog = await this.store.batches(); } catch (e) { if (!(e instanceof HistoryStorageUnavailable)) throw e; }
|
||||||
|
const firstYear = Math.min(currentYear - 4, query.year, ...catalog.map(b => Number(b.month.slice(0, 4))), ...(reconstruction?.periods.map(p => Number(p.month.slice(0, 4))) || []));
|
||||||
|
const years = Array.from({ length: currentYear - firstYear + 1 }, (_, i) => currentYear - i);
|
||||||
|
const months = query.mode === 'monthly'
|
||||||
|
? Array.from({ length: 12 }, (_, i) => `${query.year}-${String(i + 1).padStart(2, '0')}`)
|
||||||
|
: [...years].reverse().map(y => `${y}-12`);
|
||||||
|
const previous = query.mode === 'monthly' ? shiftMonth(months[0], -1) : `${firstYear - 1}-12`;
|
||||||
|
const range = [previous, ...months];
|
||||||
|
const hasLive = months.some(m => monthCutoff(m) > now && (query.mode === 'annual' ? m.startsWith(String(currentYear)) : m === shanghaiMonth(now)));
|
||||||
|
const [archive, rawLive] = await Promise.all([this.archives(range, catalog), hasLive ? this.live() : Promise.resolve([])]);
|
||||||
|
const live = freezeLive(rawLive);
|
||||||
|
const subjects = new Set(authorizedVehicles(live, user).map(v => v.subjectOrg).filter((s): s is string => !!s));
|
||||||
|
const byMonth = new Map(archive.batches.map(batch => [batch.month, batch]));
|
||||||
|
for (const rows of archive.vehicles.values()) for (const v of authorizedVehicles(rows, user)) if (v.subjectOrg) subjects.add(v.subjectOrg);
|
||||||
|
const points = range.map(month => {
|
||||||
|
const batch = byMonth.get(month);
|
||||||
|
const rows = batch?.status === 'COMPLETE' ? archive.vehicles.get(batch.id)! : [];
|
||||||
|
const point = makePoint(month, now, batch, authorizedVehicles(rows, user, query.subject), authorizedVehicles(live, user, query.subject), query.mode === 'annual');
|
||||||
|
if (point.state === 'CAPTURING' && (!archive.settings || new Date(archive.settings.enabledAt) > monthCutoff(month))) point.state = 'MISSING';
|
||||||
|
if (point.state === 'MISSING' && reconstruction) return reconstructionPoint(reconstruction, month, query.mode === 'annual', now) || point;
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
return { mode: query.mode, year: query.year, points: points.slice(1).map((p, i) => comparePoint(p, points[i], query.mode === 'annual')),
|
||||||
|
years, subjects: [...subjects].sort((a, b) => a.localeCompare(b, 'zh-CN')), storageReady: archive.ready,
|
||||||
|
enabledAt: archive.settings?.enabledAt || null, nextCaptureAt: archive.settings ? nextCapture(now) : null,
|
||||||
|
warning: [!archive.ready ? '正式历史快照存储尚未部署。' : !archive.settings ? '自动采集尚未启用。' : null,
|
||||||
|
this.reconstruction ? reconstruction ? '历史空白已按核对表回填业务推算值;历史总数待核实,不属于月末正式快照。回溯数据仅支持全量权限及全部公司。'
|
||||||
|
: '回溯估算缺少可靠历史主体和权限归属,仅全量权限在全部公司下可见;当前筛选保留正式快照或空白。' : '未采集历史期间留空。'].filter(Boolean).join(' ') || null };
|
||||||
|
}
|
||||||
|
async details(user: AuthUser, params: Record<string, string>): Promise<HistoryDetails> {
|
||||||
|
const now = this.clock();
|
||||||
|
const month = params.month || '';
|
||||||
|
if (!/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) throw new HistoryInputError('月份无效');
|
||||||
|
parseHistoryQuery({ ...params, year: month.slice(0, 4) }, now);
|
||||||
|
const category = params.category || 'all';
|
||||||
|
const page = Number(params.page || '1'), pageSize = Number(params.pageSize || '50');
|
||||||
|
if (!['all', 'operating', 'inventory'].includes(category) || !Number.isSafeInteger(page) || page < 1 || page > 100000 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100 || (params.search || '').length > 100 || (params.batchId || '').length > 36) throw new HistoryInputError('明细筛选或分页参数无效');
|
||||||
|
const archive = await this.archives([month]);
|
||||||
|
const batch = archive.batches.find(b => b.month === month);
|
||||||
|
const isCurrent = month === shanghaiMonth(now) && now < monthCutoff(month);
|
||||||
|
if (!isCurrent && makePoint(month, now, batch).state === 'MISSING') {
|
||||||
|
const reconstruction = await this.reconstructionData(user, params.subject?.trim());
|
||||||
|
const point = reconstruction && reconstructionPoint(reconstruction, month, false, now);
|
||||||
|
if (point) {
|
||||||
|
if (params.batchId && params.batchId !== point.batchId) throw new HistoryPeriodUnavailable('回溯版本已变化,请刷新趋势');
|
||||||
|
const period = reconstruction!.periods.find(p => p.month === month)!;
|
||||||
|
const search = (params.search || '').trim().toLocaleLowerCase();
|
||||||
|
const items = reconstructionItems(period).filter(v => (category === 'all' || v.disposition === category) && (!search || `${v.plateNumber} ${v.vin}`.toLocaleLowerCase().includes(search)))
|
||||||
|
.sort((a, b) => a.plateNumber.localeCompare(b.plateNumber, 'zh-CN') || a.id.localeCompare(b.id));
|
||||||
|
return { point, counts: null, total: items.length, page, pageSize, items: items.slice((page - 1) * pageSize, page * pageSize) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.batchId && (!batch || params.batchId !== batch.id || batch.status !== 'COMPLETE')) throw new HistoryPeriodUnavailable('所选快照不可用,请刷新趋势');
|
||||||
|
const rows = isCurrent ? freezeLive(await this.live()) : batch?.status === 'COMPLETE' ? archive.vehicles.get(batch.id)! : [];
|
||||||
|
const scoped = authorizedVehicles(rows, user, params.subject?.trim());
|
||||||
|
const point = makePoint(month, now, batch, scoped, scoped);
|
||||||
|
if (!point.detailAvailable) throw new HistoryPeriodUnavailable(HISTORY_STATE_LABELS[point.state]);
|
||||||
|
const search = (params.search || '').trim().toLocaleLowerCase();
|
||||||
|
const filtered = scoped.filter(v => (category === 'all' || v.disposition === category as HistoryCategory) && (!search || `${v.plateNumber} ${v.vin}`.toLocaleLowerCase().includes(search)))
|
||||||
|
.sort((a, b) => a.plateNumber.localeCompare(b.plateNumber, 'zh-CN') || a.id.localeCompare(b.id));
|
||||||
|
return { point, counts: counts(scoped), total: filtered.length, page, pageSize,
|
||||||
|
items: filtered.slice((page - 1) * pageSize, page * pageSize).map(v => ({ id: v.id, plateNumber: v.plateNumber, vin: v.vin, type: v.type, model: v.model,
|
||||||
|
disposition: v.disposition, operationStatus: v.operationStatus, vehicleStatus: v.vehicleStatus, subjectOrg: v.subjectOrg,
|
||||||
|
departmentName: v.departmentName, customerManager: v.customerManager, customerName: v.customerName, contractNo: v.contractNo })) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { Pool } from 'mysql2/promise';
|
||||||
|
import { MysqlHistoryStore } from './store.js';
|
||||||
|
import { MAIN_SQL } from '../vehicles/repository.js';
|
||||||
|
import { HistoryStorageUnavailable } from './model.js';
|
||||||
|
import { testRow } from './fixtures.js';
|
||||||
|
|
||||||
|
/** Isolated transactional database double: failed transactions discard ALL staged writes. */
|
||||||
|
class Database {
|
||||||
|
commands:string[]=[];
|
||||||
|
rows=[testRow('9223372036854775000'),testRow('stock','3')];
|
||||||
|
settings='2026-10-19T13:00:00+08:00';
|
||||||
|
locked=1; existing: Array<{id:string;status:string}>=[];
|
||||||
|
committed:string[]=[]; staged:string[]=[];
|
||||||
|
failDetails=0; failReads=false; failCommitAck=false; beforeLock?:()=>void; afterSource?:()=>void; onReadTransaction?:()=>void;
|
||||||
|
released=false; clock=()=>new Date('2026-10-31T17:00:00.500+08:00');
|
||||||
|
async query(sql:string,_args?:unknown[]) {
|
||||||
|
this.commands.push(sql);
|
||||||
|
if(sql.includes('GET_LOCK')){this.beforeLock?.();return[[{acquired:this.locked}]];}
|
||||||
|
if(sql.includes('RELEASE_LOCK'))return[[{released:1}]];
|
||||||
|
if(sql.includes('FROM bi_asset_history_settings'))return[[{enabledAt:this.settings}]];
|
||||||
|
if(sql.includes('SELECT id,status'))return[this.existing];
|
||||||
|
if(sql.startsWith('START TRANSACTION'))this.onReadTransaction?.();
|
||||||
|
if(sql===MAIN_SQL){if(this.failReads)throw new Error('read failed');this.afterSource?.();return[this.rows];}
|
||||||
|
if(sql.startsWith('INSERT INTO bi_asset_history_vehicles')){
|
||||||
|
this.staged.push('details');if(this.failDetails-->0)throw new Error('write failed');
|
||||||
|
}
|
||||||
|
if(sql.startsWith('INSERT INTO bi_asset_history_batches'))this.staged.push('batch');
|
||||||
|
if(sql.startsWith('UPDATE bi_asset_history_batches'))this.staged.push('complete');
|
||||||
|
return[[]];
|
||||||
|
}
|
||||||
|
async getConnection(){return this;}
|
||||||
|
async beginTransaction(){this.commands.push('BEGIN');this.staged=[];}
|
||||||
|
async rollback(){this.commands.push('ROLLBACK');this.staged=[];}
|
||||||
|
async commit(){this.commands.push('COMMIT');this.committed.push(...this.staged);this.staged=[];if(this.failCommitAck){this.failCommitAck=false;this.existing=[{id:'committed',status:'COMPLETE'}];throw new Error('commit acknowledgement lost');}}
|
||||||
|
release(){this.released=true;}
|
||||||
|
store(){return new MysqlHistoryStore(this as unknown as Pool);}
|
||||||
|
}
|
||||||
|
test('capture uses uncached shared SQL and one consistent read; detail/header publish atomically',async()=>{
|
||||||
|
const db=new Database();assert.equal(await db.store().capture('2026-10',db.clock),'captured');
|
||||||
|
assert.deepEqual(db.committed,['batch','details','complete']);
|
||||||
|
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
||||||
|
assert.ok(db.commands.indexOf('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')<db.commands.indexOf(MAIN_SQL));
|
||||||
|
assert.ok(db.commands.indexOf(MAIN_SQL)<db.commands.indexOf('ROLLBACK'));
|
||||||
|
assert.equal(db.released,true);assert.ok(db.commands.at(-1)!.includes('RELEASE_LOCK'));
|
||||||
|
});
|
||||||
|
test('write retry preserves the exact captured rows and time, even after the minute expires',async()=>{
|
||||||
|
const db=new Database();db.failDetails=1;let now=new Date('2026-10-31T17:00:30+08:00');
|
||||||
|
db.afterSource=()=>{now=new Date('2026-10-31T18:00:00+08:00');};const clock=()=>now;
|
||||||
|
assert.equal(await db.store().capture('2026-10',clock),'captured');
|
||||||
|
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
||||||
|
assert.deepEqual(db.committed,['batch','details','complete']);
|
||||||
|
assert.equal(db.commands.filter(s=>s==='BEGIN').length,2);
|
||||||
|
});
|
||||||
|
test('lost commit acknowledgement does not overwrite or duplicate an already-published batch',async()=>{
|
||||||
|
const db=new Database();db.failCommitAck=true;
|
||||||
|
assert.equal(await db.store().capture('2026-10',db.clock),'already-complete');
|
||||||
|
assert.deepEqual(db.committed,['batch','details','complete']);assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
||||||
|
});
|
||||||
|
test('source is not read if transaction establishment crosses the capture deadline',async()=>{
|
||||||
|
const db=new Database();let now=new Date('2026-10-31T17:00:59.900+08:00');
|
||||||
|
db.onReadTransaction=()=>{now=new Date('2026-10-31T17:01:00+08:00');};
|
||||||
|
assert.equal(await db.store().capture('2026-10',()=>now),'outside-window');
|
||||||
|
assert.equal(db.commands.includes(MAIN_SQL),false);assert.deepEqual(db.committed,[]);
|
||||||
|
});
|
||||||
|
test('persistent detail-write failure publishes neither partial header nor rows',async()=>{
|
||||||
|
const db=new Database();db.failDetails=5;
|
||||||
|
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/write failed/);
|
||||||
|
assert.deepEqual(db.committed,[]);assert.deepEqual(db.staged,[]);assert.equal(db.released,true);
|
||||||
|
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
||||||
|
});
|
||||||
|
test('source failure and invalid rows cannot publish snapshots',async()=>{
|
||||||
|
const db=new Database();db.failReads=true;
|
||||||
|
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/read failed/);assert.deepEqual(db.committed,[]);
|
||||||
|
db.failReads=false;db.rows=[testRow('duplicate'),testRow('duplicate')];
|
||||||
|
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/重复/);assert.deepEqual(db.committed,[]);
|
||||||
|
});
|
||||||
|
test('completed period, concurrent lock and expired window cannot issue a source read',async()=>{
|
||||||
|
for(const scenario of ['complete','lock','expired','delayed-lock','late-activation']){
|
||||||
|
const db=new Database();let clock=db.clock;
|
||||||
|
if(scenario==='complete')db.existing=[{id:'existing',status:'COMPLETE'}];
|
||||||
|
if(scenario==='lock')db.locked=0;
|
||||||
|
if(scenario==='expired')clock=()=>new Date('2026-10-31T17:01:00+08:00');
|
||||||
|
if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T17:01:00+08:00');};
|
||||||
|
if(scenario==='late-activation')db.settings='2026-10-31T17:00:01+08:00';
|
||||||
|
const result=await db.store().capture('2026-10',()=>clock());
|
||||||
|
assert.ok(['already-complete','locked','outside-window'].includes(result));
|
||||||
|
assert.equal(db.commands.includes(MAIN_SQL),false,scenario);assert.deepEqual(db.committed,[]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('a legitimate empty source commits a zero-count complete batch without detail inserts',async()=>{
|
||||||
|
const db=new Database();db.rows=[];
|
||||||
|
assert.equal(await db.store().capture('2026-10',db.clock),'captured');assert.deepEqual(db.committed,['batch','complete']);
|
||||||
|
});
|
||||||
|
test('missing storage reads never issue schema writes and report storage-unavailable',async()=>{
|
||||||
|
const calls:string[]=[];
|
||||||
|
const db={query:async(sql:string)=>{calls.push(sql);throw{code:'ER_NO_SUCH_TABLE'};}};
|
||||||
|
const store=new MysqlHistoryStore(db as unknown as Pool);
|
||||||
|
await assert.rejects(()=>store.settings(),HistoryStorageUnavailable);
|
||||||
|
await assert.rejects(()=>store.batches(),HistoryStorageUnavailable);
|
||||||
|
assert.ok(calls.every(sql=>sql.startsWith('SELECT')));
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Pool, PoolConnection, RowDataPacket } from 'mysql2/promise';
|
||||||
|
import { CAPTURE_WINDOW_MS, HISTORY_VERSION, monthCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js';
|
||||||
|
import type { VehicleRow } from '../../types.js';
|
||||||
|
import { MAIN_SQL } from '../vehicles/repository.js';
|
||||||
|
import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js';
|
||||||
|
|
||||||
|
export interface HistoryReader {
|
||||||
|
settings(): Promise<HistorySettings | null>;
|
||||||
|
batches(): Promise<HistoryBatch[]>;
|
||||||
|
vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>>;
|
||||||
|
}
|
||||||
|
export type CaptureResult = 'captured' | 'already-complete' | 'locked' | 'outside-window';
|
||||||
|
const sqlTime = (date: Date) => shanghaiTimestamp(date).slice(0, 23).replace('T', ' ');
|
||||||
|
const dateColumn = (name: string) => `DATE_FORMAT(${name}, '%Y-%m-%dT%H:%i:%s.%f+08:00')`;
|
||||||
|
|
||||||
|
/** No schema writes in constructor or any read. All lifecycle writes are explicit. */
|
||||||
|
export class MysqlHistoryStore implements HistoryReader {
|
||||||
|
constructor(private readonly db: Pick<Pool, 'query' | 'getConnection'>) {}
|
||||||
|
|
||||||
|
private async read(sql: string, args: unknown[] = []): Promise<RowDataPacket[]> {
|
||||||
|
try { const [rows] = await this.db.query<RowDataPacket[]>(sql, args); return rows; }
|
||||||
|
catch (error) {
|
||||||
|
if ((error as { code?: string }).code === 'ER_NO_SUCH_TABLE') throw new HistoryStorageUnavailable('历史快照存储尚未部署');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async settings(): Promise<HistorySettings | null> {
|
||||||
|
const rows = await this.read(`SELECT ${dateColumn('enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
|
||||||
|
return rows[0] ? { enabledAt: rows[0].enabledAt } : null;
|
||||||
|
}
|
||||||
|
async batches(): Promise<HistoryBatch[]> {
|
||||||
|
const rows = await this.read(`SELECT id, period_month AS month, status, source,
|
||||||
|
total_count,operating_count,inventory_count,
|
||||||
|
${dateColumn('scheduled_for')} AS scheduledFor, ${dateColumn('captured_at')} AS capturedAt
|
||||||
|
FROM bi_asset_history_batches WHERE definition_version=? ORDER BY period_month`, [HISTORY_VERSION]);
|
||||||
|
return rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt,
|
||||||
|
counts: { total: row.total_count, operating: row.operating_count, inventory: row.inventory_count } }));
|
||||||
|
}
|
||||||
|
async vehicles(batchIds: string[]): Promise<Map<string, HistoryVehicle[]>> {
|
||||||
|
const result = new Map<string, HistoryVehicle[]>(batchIds.map(id => [id, []]));
|
||||||
|
if (!batchIds.length) return result;
|
||||||
|
const rows = await this.read(`SELECT batch_id, frozen_vehicle FROM bi_asset_history_vehicles WHERE batch_id IN (${batchIds.map(() => '?').join(',')}) ORDER BY vehicle_id`, batchIds);
|
||||||
|
for (const row of rows) {
|
||||||
|
const value = typeof row.frozen_vehicle === 'string' ? JSON.parse(row.frozen_vehicle) : row.frozen_vehicle;
|
||||||
|
if (!value || typeof value.id !== 'string') throw new Error('Invalid archived vehicle');
|
||||||
|
result.get(row.batch_id)!.push(value as HistoryVehicle);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async activate(now: Date): Promise<HistorySettings> {
|
||||||
|
await this.db.query('INSERT IGNORE INTO bi_asset_history_settings (id,enabled_at) VALUES (1,?)', [sqlTime(now)]);
|
||||||
|
return (await this.settings())!;
|
||||||
|
}
|
||||||
|
async recordOutcome(month: string, status: 'FAILED' | 'MISSED', code: string): Promise<void> {
|
||||||
|
// Never downgrade a completed snapshot, or replace a useful failure with MISSED.
|
||||||
|
await this.db.query(`INSERT INTO bi_asset_history_batches
|
||||||
|
(id,period_month,definition_version,scheduled_for,source,status,failure_code)
|
||||||
|
VALUES (?,?,?,?,'AUTO',?,?) ON DUPLICATE KEY UPDATE
|
||||||
|
failure_code=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),failure_code,VALUES(failure_code)),
|
||||||
|
status=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),status,VALUES(status))`,
|
||||||
|
[randomUUID(), month, HISTORY_VERSION, sqlTime(monthCutoff(month)), status, code]);
|
||||||
|
}
|
||||||
|
async capture(month: string, clock: () => Date = () => new Date()): Promise<CaptureResult> {
|
||||||
|
const cutoff = monthCutoff(month).getTime();
|
||||||
|
const inWindow = () => clock().getTime() >= cutoff && clock().getTime() < cutoff + CAPTURE_WINDOW_MS;
|
||||||
|
if (!inWindow()) return 'outside-window';
|
||||||
|
const connection = await this.db.getConnection();
|
||||||
|
const lock = `ln-bi:asset:${HISTORY_VERSION}:${month}`;
|
||||||
|
let locked = false;
|
||||||
|
try {
|
||||||
|
const [locks] = await connection.query<RowDataPacket[]>("SELECT GET_LOCK(CONCAT(DATABASE(),':',?),0) AS acquired", [lock]);
|
||||||
|
locked = Number(locks[0]?.acquired) === 1;
|
||||||
|
if (!locked) return 'locked';
|
||||||
|
const settings = await this.settings();
|
||||||
|
if (!settings || new Date(settings.enabledAt).getTime() > cutoff || !inWindow()) return 'outside-window';
|
||||||
|
const [existing] = await connection.query<RowDataPacket[]>(`SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=?`, [month, HISTORY_VERSION]);
|
||||||
|
if (existing[0]?.status === 'COMPLETE') return 'already-complete';
|
||||||
|
await connection.query("SET time_zone='+08:00'");
|
||||||
|
await connection.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
||||||
|
if (!inWindow()) return 'outside-window';
|
||||||
|
await connection.query('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY');
|
||||||
|
const capturedAt = clock();
|
||||||
|
let frozen: HistoryVehicle[];
|
||||||
|
try {
|
||||||
|
if (!inWindow()) return 'outside-window';
|
||||||
|
const [rows] = await connection.query<RowDataPacket[]>(MAIN_SQL);
|
||||||
|
frozen = freezeRows(rows as VehicleRow[]);
|
||||||
|
} finally { await connection.rollback(); }
|
||||||
|
// Retries below keep this exact frozen dataset. Never reread a later live ledger.
|
||||||
|
const id = existing[0]?.id || randomUUID();
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try { return await this.publish(connection, id, month, capturedAt, frozen, clock); }
|
||||||
|
catch (error) { await connection.rollback(); if (attempt >= 2) throw error; }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { if (locked) await connection.query("SELECT RELEASE_LOCK(CONCAT(DATABASE(),':',?))", [lock]); }
|
||||||
|
finally { connection.release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async publish(connection: PoolConnection, id: string, month: string, capturedAt: Date, vehicles: HistoryVehicle[], clock: () => Date) {
|
||||||
|
const totals = counts(vehicles);
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [existing] = await connection.query<RowDataPacket[]>('SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=? FOR UPDATE', [month, HISTORY_VERSION]);
|
||||||
|
// Also handles a committed transaction whose acknowledgement was lost.
|
||||||
|
if (existing[0]?.status === 'COMPLETE') { await connection.commit(); return 'already-complete' as const; }
|
||||||
|
if (existing[0]) id = existing[0].id;
|
||||||
|
// Under the advisory lock, only FAILED/MISSED placeholders may be promoted.
|
||||||
|
await connection.query(`INSERT INTO bi_asset_history_batches
|
||||||
|
(id,period_month,definition_version,scheduled_for,captured_at,source,status,total_count,operating_count,inventory_count)
|
||||||
|
VALUES (?,?,?,?,?,'AUTO','FAILED',?,?,?) ON DUPLICATE KEY UPDATE captured_at=VALUES(captured_at),
|
||||||
|
total_count=VALUES(total_count),operating_count=VALUES(operating_count),inventory_count=VALUES(inventory_count)`,
|
||||||
|
[id, month, HISTORY_VERSION, sqlTime(monthCutoff(month)), sqlTime(capturedAt), totals.total, totals.operating, totals.inventory]);
|
||||||
|
for (let offset = 0; offset < vehicles.length; offset += 100) {
|
||||||
|
const chunk = vehicles.slice(offset, offset + 100);
|
||||||
|
await connection.query(`INSERT INTO bi_asset_history_vehicles
|
||||||
|
(batch_id,vehicle_id,operation_status,vehicle_status,disposition,subject_org,department_name,manager_id,frozen_vehicle)
|
||||||
|
VALUES ${chunk.map(() => '(?,?,?,?,?,?,?,?,CAST(? AS JSON))').join(',')}`, chunk.flatMap(v =>
|
||||||
|
[id, v.id, v.operationStatus, v.vehicleStatus, v.disposition, v.subjectOrg, v.departmentName, v.managerId, JSON.stringify(v)]));
|
||||||
|
}
|
||||||
|
await connection.query("UPDATE bi_asset_history_batches SET status='COMPLETE',completed_at=?,failure_code=NULL WHERE id=?", [sqlTime(clock()), id]);
|
||||||
|
await connection.commit();
|
||||||
|
return 'captured' as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,16 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||||
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
|
import { fetchOneOsDailyMileage } from './oneos-api.js';
|
||||||
import type { OneOsProtocol } from './source-policy.js';
|
import { queryMonitoringRange } from './query-cache.js';
|
||||||
import { buildRangeMileage, type RangeMileageResult } from './range-model.js';
|
import { DEFAULT_ONEOS_PROTOCOL_PRIORITY, type OneOsProtocol } from './source-policy.js';
|
||||||
|
import { buildSummaryMileage } from './summary-model.js';
|
||||||
import {
|
import {
|
||||||
buildMonitoringFilters,
|
buildMonitoringFilters,
|
||||||
buildPlateTargetNamesMap,
|
buildPlateTargetNamesMap,
|
||||||
buildTargetPlatesMap,
|
buildTargetPlatesMap,
|
||||||
dailyMileageMap,
|
dailyMileageMap,
|
||||||
mergeMonitoringVehicles,
|
mergeMonitoringVehicles,
|
||||||
mileageDatesBetween,
|
|
||||||
previousMileageDate,
|
previousMileageDate,
|
||||||
toMileageRows,
|
toMileageRows,
|
||||||
type TargetRow,
|
type TargetRow,
|
||||||
@@ -117,17 +117,22 @@ export async function queryDateMileage(
|
|||||||
export async function queryRangeMileage(
|
export async function queryRangeMileage(
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string,
|
endDate: string,
|
||||||
protocolPriority?: OneOsProtocol[],
|
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
|
||||||
): Promise<RangeMileageResult> {
|
refresh = false,
|
||||||
const days = mileageDatesBetween(startDate, endDate);
|
) {
|
||||||
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
const [cached, infoMap, targetRows] = await Promise.all([
|
||||||
fetchOneOsMileageDates(days, undefined, protocolPriority),
|
queryMonitoringRange(startDate, endDate, protocolPriority, refresh),
|
||||||
fetchOneOsDailyMileage(endDate, undefined, protocolPriority),
|
|
||||||
fetchOneOsDailyMileage(previousMileageDate(startDate), undefined, protocolPriority),
|
|
||||||
fetchVehicleInfoMap(),
|
fetchVehicleInfoMap(),
|
||||||
fetchTargetRows(),
|
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 {
|
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Hono } from 'hono';
|
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 { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js';
|
||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
@@ -132,21 +137,29 @@ app.get('/', async (c) => {
|
|||||||
brands: c.req.queries('brand') || [],
|
brands: c.req.queries('brand') || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let rangeSummary: MileageSummary | undefined;
|
||||||
|
let queriedAt: string | undefined;
|
||||||
let allVehicles: CachedVehicle[];
|
let allVehicles: CachedVehicle[];
|
||||||
let filters: MonitoringFilters;
|
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;
|
let dateRange: { start: string; end: string } | undefined;
|
||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
try {
|
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;
|
allVehicles = result.vehicles;
|
||||||
rangeDailyTotals = result.dailyTotals;
|
rangeSummary = result.summary;
|
||||||
dateRange = { start: result.start, end: result.end };
|
dateRange = { start: result.start, end: result.end };
|
||||||
filters = buildDateFilters(allVehicles);
|
filters = buildDateFilters(allVehicles);
|
||||||
} catch (e: unknown) {
|
} 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);
|
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) {
|
} else if (date) {
|
||||||
try {
|
try {
|
||||||
@@ -177,17 +190,35 @@ app.get('/', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filtered = applyFilters(allVehicles, filterParams);
|
const filtered = applyFilters(allVehicles, filterParams);
|
||||||
if (rangeDailyTotals && filtered.length !== allVehicles.length) {
|
// The provider trend covers its entire selected fleet, not the BI user's scope.
|
||||||
rangeDailyTotals = rangeDailyTotals.map(item => ({
|
if (range && rangeSummary && c.req.query('includeDailyTrend') !== '0') {
|
||||||
...item,
|
const authorized = new Set(rangeSummary.vehicles.map(v => v.plateNumber));
|
||||||
totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)),
|
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 = {
|
const stats = {
|
||||||
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
|
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
|
||||||
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
|
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
|
||||||
vehicleCount: filtered.length,
|
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),
|
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,7 +246,19 @@ app.get('/', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
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<string, OneOsDailyMileage[]>();
|
||||||
|
const byDate = new Map(dates.map(date => [date, new Map<string, number | null>((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;
|
const total = filtered.length;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -229,6 +272,7 @@ app.get('/', async (c) => {
|
|||||||
totalPages: Math.ceil(total / limit),
|
totalPages: Math.ceil(total / limit),
|
||||||
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
|
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
|
||||||
sourcePriority,
|
sourcePriority,
|
||||||
|
queriedAt,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export async function fetchOneOsDailyMileage(
|
|||||||
date: string,
|
date: string,
|
||||||
plateNumbers?: string[],
|
plateNumbers?: string[],
|
||||||
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
|
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
|
||||||
|
refresh = false,
|
||||||
): Promise<OneOsDailyMileage[]> {
|
): Promise<OneOsDailyMileage[]> {
|
||||||
assertMileageDate(date);
|
assertMileageDate(date);
|
||||||
|
|
||||||
@@ -231,7 +232,7 @@ export async function fetchOneOsDailyMileage(
|
|||||||
const cacheKey = `${date}:${policyKey}`;
|
const cacheKey = `${date}:${policyKey}`;
|
||||||
const hit = cache.get(cacheKey);
|
const hit = cache.get(cacheKey);
|
||||||
let rows: OneOsDailyMileage[];
|
let rows: OneOsDailyMileage[];
|
||||||
if (hit && hit.expiresAt > Date.now()) {
|
if (!refresh && hit && hit.expiresAt > Date.now()) {
|
||||||
rows = hit.rows;
|
rows = hit.rows;
|
||||||
} else {
|
} else {
|
||||||
let pending = inflight.get(cacheKey);
|
let pending = inflight.get(cacheKey);
|
||||||
|
|||||||
@@ -98,3 +98,13 @@ test('异常日里程不伪造为有效值,保留仪表累计和异常原因',
|
|||||||
assert.equal(row.totalMileageKm, 66852.4);
|
assert.equal(row.totalMileageKm, 66852.4);
|
||||||
assert.equal(row.dataQuality, 'ODOMETER_SOURCE_CHANGED');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface OneOsDailyMileage {
|
|||||||
/** Missing or unusable daily mileage must not contribute to interval statistics. */
|
/** Missing or unusable daily mileage must not contribute to interval statistics. */
|
||||||
export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null {
|
export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null {
|
||||||
const km = row?.dailyMileageKm;
|
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}$/;
|
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),
|
dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status),
|
||||||
totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status),
|
totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status),
|
||||||
status,
|
status,
|
||||||
...(status === 'DATA_ANOMALY' ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}),
|
...((row.dataQuality || status === 'DATA_ANOMALY') ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}),
|
||||||
dataTime,
|
dataTime,
|
||||||
calculatedAt,
|
calculatedAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
|||||||
@@ -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<RangeMileageSource>(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');
|
||||||
|
});
|
||||||
@@ -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<RangeMileageSource> {
|
||||||
|
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<string, Entry>();
|
||||||
|
const inflight = new Map<string, Promise<Entry>>();
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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<typeof payload>) => { p.data.vehicles[0].validDays = 2; },
|
||||||
|
(p: ReturnType<typeof payload>) => { p.data.vehicles[0].status = 'NORMAL'; },
|
||||||
|
(p: ReturnType<typeof payload>) => { p.data.vehicles[0].periodMileageKm = NaN; },
|
||||||
|
(p: ReturnType<typeof payload>) => { 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<MileageSummary> {
|
||||||
|
// 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<string>();
|
||||||
|
let first: ReturnType<typeof parseMileageSummary> | 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.
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<string, VehicleInfoRow>, targets: TargetRow[], regions: Record<string, string>): 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,13 @@ export interface CachedVehicle {
|
|||||||
plate: string;
|
plate: string;
|
||||||
vin: string;
|
vin: string;
|
||||||
dailyKm: number;
|
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;
|
mileageAnomaly?: string | null;
|
||||||
dailyMileage?: Record<string, number | null>;
|
dailyMileage?: Record<string, number | null>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
@@ -64,6 +71,8 @@ export interface MonitoringStats {
|
|||||||
totalAll: number;
|
totalAll: number;
|
||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
yesterdayTotal: number;
|
yesterdayTotal: number;
|
||||||
|
validVehicleCount?: number;
|
||||||
|
totalMileageVehicleCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /monitoring 完整响应 */
|
/** /monitoring 完整响应 */
|
||||||
@@ -71,11 +80,12 @@ export interface MonitoringResponse {
|
|||||||
vehicles: CachedVehicle[];
|
vehicles: CachedVehicle[];
|
||||||
stats: MonitoringStats;
|
stats: MonitoringStats;
|
||||||
filters: MonitoringFilters;
|
filters: MonitoringFilters;
|
||||||
rangeDailyTotals?: { date: string; totalKm: number }[];
|
rangeDailyTotals?: { date: string; totalKm: number | null }[];
|
||||||
dateRange?: { start: string; end: string };
|
dateRange?: { start: string; end: string };
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
queriedAt?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
sourcePriority: ('instrument' | 'gps')[];
|
sourcePriority: ('instrument' | 'gps')[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
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 { validDailyMileage } from './oneos-model.js';
|
||||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||||
import {
|
import {
|
||||||
@@ -60,6 +63,12 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 dates: string[] = [];
|
||||||
const dateCursor = new Date(start);
|
const dateCursor = new Date(start);
|
||||||
while (dateCursor <= end) {
|
while (dateCursor <= end) {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface VehicleRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Vehicle {
|
export interface Vehicle {
|
||||||
|
vehicleStatus?: string | null;
|
||||||
opsManagers?: string | null;
|
opsManagers?: string | null;
|
||||||
pendingDelivery?: { contractNo: string | null; customerName: string | null } | null;
|
pendingDelivery?: { contractNo: string | null; customerName: string | null } | null;
|
||||||
abnormalMove?: AbnormalMoveDetail | null;
|
abnormalMove?: AbnormalMoveDetail | null;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export type HistoryMode = 'monthly' | 'annual';
|
||||||
|
export type HistoryState = 'LIVE' | 'ARCHIVED' | 'MISSING' | 'FUTURE' | 'CAPTURING' | 'FAILED' | 'RECONSTRUCTED';
|
||||||
|
export type HistoryCategory = 'all' | 'operating' | 'inventory';
|
||||||
|
export interface HistoryCounts { total: number; operating: number; inventory: number }
|
||||||
|
export interface HistoryPoint {
|
||||||
|
period: string;
|
||||||
|
label: string;
|
||||||
|
month: string;
|
||||||
|
scheduledFor: string;
|
||||||
|
asOf: string | null;
|
||||||
|
state: HistoryState;
|
||||||
|
source: 'AUTO' | 'IMPORT' | 'LIVE' | 'RECONSTRUCTED' | null;
|
||||||
|
batchId: string | null;
|
||||||
|
counts: HistoryCounts | null;
|
||||||
|
difference: HistoryCounts | null;
|
||||||
|
comparisonLabel: string;
|
||||||
|
detailAvailable: boolean;
|
||||||
|
reconstruction?: {
|
||||||
|
operating: number; inventory: number; unclassified: number;
|
||||||
|
currentlyExited: number; sourceAsOf: string; sourceReference: string;
|
||||||
|
workbookSha256: string;
|
||||||
|
difference: { operating: number; inventory: number } | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface HistoryTrend {
|
||||||
|
mode: HistoryMode;
|
||||||
|
year: number;
|
||||||
|
points: HistoryPoint[];
|
||||||
|
years: number[];
|
||||||
|
subjects: string[];
|
||||||
|
storageReady: boolean;
|
||||||
|
enabledAt: string | null;
|
||||||
|
nextCaptureAt: string | null;
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
export interface HistoryDetailItem {
|
||||||
|
id: string; plateNumber: string; vin: string; type: string; model: string;
|
||||||
|
disposition: 'operating' | 'inventory'; operationStatus: string | null;
|
||||||
|
vehicleStatus: string | null; subjectOrg: string | null; departmentName: string | null;
|
||||||
|
customerManager: string | null; customerName: string | null; contractNo: string | null;
|
||||||
|
reconstructionBasis?: string;
|
||||||
|
}
|
||||||
|
export interface HistoryDetails {
|
||||||
|
point: HistoryPoint;
|
||||||
|
counts: HistoryCounts | null;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
items: HistoryDetailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const OFFSET = 8 * 60 * 60 * 1000;
|
||||||
|
export const CAPTURE_WINDOW_MS = 60_000;
|
||||||
|
export const HISTORY_VERSION = 'asset-v1';
|
||||||
|
export function shanghaiMonth(now: Date): string { return new Date(now.getTime() + OFFSET).toISOString().slice(0, 7); }
|
||||||
|
export function shanghaiTimestamp(now: Date): string { return new Date(now.getTime() + OFFSET).toISOString().slice(0, -1) + '+08:00'; }
|
||||||
|
export function monthCutoff(month: string): Date {
|
||||||
|
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效');
|
||||||
|
const [year, m] = month.split('-').map(Number);
|
||||||
|
return new Date(Date.UTC(year, m, 0, 9)); // 17:00 Asia/Shanghai, on the last day.
|
||||||
|
}
|
||||||
|
export function shiftMonth(month: string, offset: number): string {
|
||||||
|
const [year, m] = month.split('-').map(Number);
|
||||||
|
return new Date(Date.UTC(year, m - 1 + offset, 1)).toISOString().slice(0, 7);
|
||||||
|
}
|
||||||
|
export function nextCapture(now: Date): string {
|
||||||
|
const month = shanghaiMonth(now);
|
||||||
|
return shanghaiTimestamp(monthCutoff(monthCutoff(month).getTime() > now.getTime() ? month : shiftMonth(month, 1)));
|
||||||
|
}
|
||||||
|
export function historyTime(value: string | null): string {
|
||||||
|
if (!value) return '暂无时间';
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
|
||||||
|
}
|
||||||
|
export const HISTORY_STATE_LABELS: Record<HistoryState, string> = {
|
||||||
|
LIVE: '未结期 · 实时', ARCHIVED: '已归档', MISSING: '暂无快照', FUTURE: '尚未到达', CAPTURING: '归档中', FAILED: '归档失败', RECONSTRUCTED: '历史回溯 · 估算',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user