Author SHA1 Message Date
shishengliang 187a72d6b0 chore(release): bump version to 1.1.20
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-22 11:06:43 +08:00
shishengliang 3657661fa7 Merge fault_1013_improve into feature/asset-statistics 2026-10-22 11:06:41 +08:00
shishengliang 00c09d7452 fix(mileage): skip missing daily mileage in interval totals
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-13 13:20:14 +08:00
shishengliang 5751d1af15 fix(assets): emphasize daily flow details and sort newest first
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-10 21:56:06 +08:00
shishengliang 24984fe033 fix(assets): show inventory operations managers and bump v1.1.19 2026-10-10 21:56:06 +08:00
shishengliang 7dd7e67602 fix(energy): connect Foshan external vehicle hydrogen orders
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-29 10:03:45 +08:00
77 changed files with 232 additions and 2880 deletions
-1
View File
@@ -1 +0,0 @@
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
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
-179
View File
@@ -1,179 +0,0 @@
## 当前展示口径(2026-10-24 更新)
按用户最新要求,停止展示交还记录推算的历史资产数据。正式路由只提供当前实时数据和真实归档快照,已取消 `ASSET_HISTORY_RECONSTRUCTION_FILE` 的接入;即使保留旧环境变量,也不能通过趋势或明细接口读取回溯文件。旧核对表和私有回溯文件保留用于离线核查,不删除、不写入正式快照。
2026年10月前的缺失期间返回空值,不补零、不提供估算明细。页面仅显示一句提示:“历史库存状态快照缺失,核对中,暂不展示。” 当前10月继续读取实时数据;今后只有采集成功的真实快照才能归档展示。服务端仍返回存储和采集状态供运维检查,页面不再重复展示部署及回溯说明。
本地验收服务同步停用回溯文件。生产迁移、部署和自动采集仍未执行;该展示调整不等于采集已经启用。下文的回溯回填章节保留为历史实施记录,其环境变量接入和页面展示说明已被本节取代。
# 资产历史趋势
资产概览顶部指标之后新增历史趋势。月度选择年份,年度每年一个年末点;运营和库存为堆叠柱,总数为折线。下方期间按钮支持键盘和触摸选点,月度明细可分类、搜索车牌/VIN及分页。
## 口径及时间
- 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。
- 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。
- 每天 `Asia/Shanghai` 23:55 为计划时点,归属当天;只允许在 23:55:00(含)到 23:56:00(不含)内开始读取。每天独立保存完整逐车状态;月度展示月末当天、年度展示12月31日快照,不累加每日存量。
- `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。
- 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。
- 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。
- 实时点每五秒走 `/live`,不重读全部历史;切换年份/公司/模式、手动刷新、跨月或归档边界时更新历史。
- 实时数据沿用现有资产仓储的五秒缓存,实时 `asOf` 为本次服务端查询时间,不代表源台账所有记录的最后修改时间;固定快照绕过该缓存。
- 缺失及未来数量为 `null`,合法零数量为 `0`,不插值、不跨缺失点连线。比较只取相邻期间;一月使用上年十二月作为基期。
- 默认年度显示最近五年;更早有归档或导入时自动扩展年份。月度允许查询 2000 年至当前年份。
## 权限及历史字段
本功能是资产存量统计。实时和归档都复用 `filterAssetVehiclesByPermission`:库存对账号公共可见,其余车辆沿用全量、部门或个人权限。公共库存按所查询时点的运营状态判断;历史公司、部门和负责人取快照,角色及个人身份别名取当前认证系统。公司筛选在权限过滤范围内取 `subjectOrg`。
不连接今日车辆列表补字段,不因车辆退出、删除或转部门丢失历史。当前来源的部门权限使用部门名称,快照如实保存该名称,不虚构来源没有的部门 ID。未来源系统若增加稳定部门 ID,需版本化升级口径。
归档汇总从授权后的同一批逐车数据计算,不返回批次全量计数。明细客户名称复用既有脱敏。历史公司仅出现在有权限的相关历史数据中;顶部公司列表中的历史独有公司标注“历史主体”,不改变现有实时公司接口含义。权限视角切换会由现有认证容器重建页面并取消旧请求。
## 存储
`bi_asset_history_settings.enabled_at` 保留旧采集首次启用时间;`daily_enabled_at` 保存每日采集首次实际启用时间。重复启用保留首次时间,升级前的日期不会被标记为漏采。
`bi_asset_history_batches` 保存期间键、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。`period_key` 与口径版本唯一;新快照使用 YYYY-MM-DD,旧月度 YYYY-MM 键和明细原样保留。月度查询仅选月末日及旧月度记录;同月优先新日快照,但失败或缺失不会遮蔽旧 COMPLETE 归档。正式归档为 `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`。新建空表不会伪造以前历史。
每日采集升级(2026-10-30):先停止旧版采集服务,执行基础建表迁移(已有表不变),再审阅并执行一次增量迁移:
```sh
node --import tsx scripts/migrate-asset-history.ts --daily
node --import tsx scripts/migrate-asset-history.ts --daily --apply
```
增量迁移把 `period_month CHAR(7)` 改为 `period_key CHAR(10)`,原唯一索引继续约束期间键和版本;新增可空 `daily_enabled_at`。这是一次性 ALTER,不能重复执行。应在目标库完成迁移后启动新版服务并设置采集开关;旧版程序不能直接使用新字段名。迁移不启动采集,也不修改旧归档的计划/实际时间。当前页面仍提供月度和年度浏览,普通日期快照保存在数据库中。
授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `daily_enabled_at`,启用前的时点不会追补。如果在当天 23:55 之后才首次启用,即使仍处于该分钟,也从次日开始采集。
按每日采集需求确认日期 2026-10-30,如果在当天 23:55 前成功启用,首个计划时点为北京时间 **2026-10-30 23:55**;实际计划以每日采集首次启用时间和服务时间计算,不硬编码。
保持服务在计划时点在线。停止期间错过的 eligible 日期会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含日期及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。
建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`daily_enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。
管理员可用以下只读 SQL 检查,不通过普通用户接口暴露全量计数:
```sql
SELECT enabled_at FROM bi_asset_history_settings WHERE id=1;
SELECT period_key, 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_key DESC LIMIT 31;
SELECT b.period_key, 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_key, 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,采集及后台任务均关闭。
-56
View File
@@ -1,56 +0,0 @@
# 里程监控结果复用(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)。
@@ -1,56 +0,0 @@
# 里程监控查询交互与性能分析(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/`。模拟测试用于验证请求触发条件,不用于性能结论。
@@ -1,59 +0,0 @@
# 区间里程汇总接入与验证(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`。原始响应含业务数据,不应直接发布到公共仓库或转发。
@@ -1,103 +0,0 @@
# 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、快照当前实现与复用能力、聚合接口可行性,以及可交付的第一阶段范围与排期。
-43
View File
@@ -1,43 +0,0 @@
-- 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;
@@ -1,4 +0,0 @@
-- Run once after 20261019_asset_history.sql; stop old capture workers before migration.
-- Existing YYYY-MM keys and archived vehicle rows remain unchanged.
ALTER TABLE bi_asset_history_batches CHANGE COLUMN period_month period_key CHAR(10) NOT NULL;
ALTER TABLE bi_asset_history_settings ADD COLUMN daily_enabled_at DATETIME(3) NULL;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.19", "version": "1.1.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.19", "version": "1.1.20",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.19", "version": "1.1.20",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
-20
View File
@@ -1,20 +0,0 @@
// 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));
}
-13
View File
@@ -1,13 +0,0 @@
import { readFile } from 'node:fs/promises';
const migration = process.argv.includes('--daily') ? '20261030_asset_history_daily.sql' : '20261019_asset_history.sql';
const sql = await readFile(new URL(`../migrations/${migration}`, 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(); }
}
+1 -7
View File
@@ -102,10 +102,6 @@ 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);
@@ -489,7 +485,7 @@ export default function AssetsModule() {
loading={loading} loading={loading}
selectedSubject={selectedSubject} selectedSubject={selectedSubject}
setSelectedSubject={setSelectedSubject} setSelectedSubject={setSelectedSubject}
subjects={subjectOptions} subjects={subjects}
subjectDropdownOpen={subjectDropdownOpen} subjectDropdownOpen={subjectDropdownOpen}
setSubjectDropdownOpen={setSubjectDropdownOpen} setSubjectDropdownOpen={setSubjectDropdownOpen}
subjectSearch={subjectSearch} subjectSearch={subjectSearch}
@@ -508,8 +504,6 @@ 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}
-1
View File
@@ -12,7 +12,6 @@ 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.historicalOnly ? '历史主体' : <>{s.total} 台 {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,7 +2,6 @@ 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) {
@@ -16,8 +15,6 @@ 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-3 py-2 shadow-sm"> <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 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="flex flex-wrap items-baseline justify-center gap-x-2 px-2"> <div className="px-2">
<div className="text-[10px] font-black text-slate-400">运营率</div> <div className="text-[10px] font-black text-slate-400">运营率</div>
<div className="text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div> <div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
</div> </div>
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2"> <div className="px-2">
<div className="text-[10px] font-black text-slate-400">库存率</div> <div className="text-[10px] font-black text-slate-400">库存率</div>
<div className="text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div> <div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
</div> </div>
</div> </div>
@@ -67,8 +67,6 @@ export type OverviewViewProps = SummaryMetricsProps
& AssetSummarySectionProps & AssetSummarySectionProps
& InventoryStatisticsSectionProps & InventoryStatisticsSectionProps
& { & {
selectedSubject: string | null;
onHistorySubjects: (names: string[]) => void;
allTypesExpanded: boolean; allTypesExpanded: boolean;
toggleAllAssetTypes: () => void; toggleAllAssetTypes: () => void;
}; };
@@ -1,109 +0,0 @@
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="每天北京时间23:55采集;月度取月末、年度取年末快照">每日 23:55</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>;
}
@@ -1,66 +0,0 @@
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);
}
@@ -1,31 +0,0 @@
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>;
}
-20
View File
@@ -1,20 +0,0 @@
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);
}
-131
View File
@@ -1,131 +0,0 @@
.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; }
}
@@ -1,37 +0,0 @@
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);
});
@@ -1,29 +0,0 @@
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;
}
+7 -29
View File
@@ -28,6 +28,7 @@ export default function HydrogenDaily() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
setRows(null);
const query = pick === 'custom' const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end } ? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick }; : { range: pick };
@@ -81,34 +82,13 @@ export default function HydrogenDaily() {
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} /> <MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" /> <MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" /> <MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" /> <MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div> </div>
{/* 外部车辆:新系统数据还没准备好 */} {/* 时段加氢量柱图 */}
{customer === 'external' && rows !== null && totalKg === 0 && ( {!error && trendData.length > 0 && totalKg > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
新系统的外部车辆加氢数据还在准备中
<br />
上线后此处将展示完整明细
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard> <SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2"> <div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700">每日加氢量</span> <span className="text-sm font-bold text-slate-700">每日加氢量</span>
@@ -184,8 +164,7 @@ export default function HydrogenDaily() {
</SurfaceCard> </SurfaceCard>
)} )}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} {/* 日明细:加载失败、无订单与正常数据分别展示。 */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */} {/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500"> <div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
@@ -206,8 +185,8 @@ export default function HydrogenDaily() {
<div className="p-3"><ErrorState message={error} /></div> <div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? ( ) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div> <div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? ( ) : rows.length === 0 || totalKg === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div> <div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
) : rows.map(r => { ) : rows.map(r => {
const open = expanded.has(r.date); const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3; const isAbnormal = Math.abs(r.chainPct) >= 0.3;
@@ -270,7 +249,6 @@ export default function HydrogenDaily() {
); );
})} })}
</div> </div>
)}
</div> </div>
); );
+71 -72
View File
@@ -48,6 +48,7 @@ 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
@@ -61,6 +62,7 @@ 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);
@@ -70,7 +72,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 | null }[]>([]); const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]);
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);
@@ -83,7 +85,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 draftQuery = useMemo<MonitoringQueryModel>(() => ({ const monitoringQuery = useMemo<MonitoringQueryModel>(() => ({
sortBy, sortBy,
sortOrder, sortOrder,
searchTerm, searchTerm,
@@ -96,24 +98,35 @@ export default function MonitoringView() {
targetNames: filterTargetNames, targetNames: filterTargetNames,
region: filterRegion, region: filterRegion,
plates: filterPlates, plates: filterPlates,
mileageRange: filterMileageRange, mileageRange: appliedMileageRange,
rangeStart, rangeStart,
rangeEnd, rangeEnd,
brands: filterBrands, brands: filterBrands,
sourcePriority, sourcePriority,
}), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, filterMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]); }), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
const [monitoringQuery, setMonitoringQuery] = useState(draftQuery); // Keep this model and dependency list separate: fullscreen historically omits
const [queryError, setQueryError] = useState(''); // project, entity and mileage-range, so adding those states would change both
const hasPendingChanges = JSON.stringify(draftQuery) !== JSON.stringify(monitoringQuery); // its request parameters and refresh triggers.
const queryDisabled = pageLoading || manualRefreshing || fullscreenLoading || !rangeStart || !rangeEnd || rangeStart > rangeEnd; const fullscreenQuery = useMemo<MonitoringQueryModel>(() => ({
sortBy,
const handleQuery = () => { sortOrder,
if (queryDisabled) return; searchTerm,
setQueryError(''); department: filterDept,
setMonitoringQuery({ ...draftQuery }); customer: filterCustomer,
setIsFilterOpen(false); project: 'All',
}; entity: 'All',
rentStatus: filterRentStatus,
platePrefix: filterPlatePrefix,
targetNames: filterTargetNames,
region: filterRegion,
plates: filterPlates,
mileageRange: { min: '', max: '' },
rangeStart,
rangeEnd,
brands: filterBrands,
sourcePriority,
}), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, filterBrands, sourcePriority]);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setRelativeNow(Date.now()), 5000); const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
@@ -132,48 +145,45 @@ 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))
|| monitoringQuery.targetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name)); || filterTargetNames.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;
}, [monitoringQuery.targetNames]); }, [filterTargetNames]);
const queryRequestRef = useRef(0); const queryRequestRef = useRef(0);
// 加载首页数据 // 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true, refresh = false) => { const loadFirstPage = useCallback((showPageLoading = true) => {
const requestId = ++queryRequestRef.current; const requestId = ++queryRequestRef.current;
setQueryError('');
if (showPageLoading) setPageLoading(true); if (showPageLoading) setPageLoading(true);
return fetchMonitoring({ ...buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE), refresh }).then(d => { return fetchMonitoring(buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE)).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: monitoringQuery.rangeStart, end: monitoringQuery.rangeEnd }); setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd });
setTotal(d.total); setTotal(d.total);
setPage(1); setPage(1);
setHasMore(d.page < d.totalPages); setHasMore(d.page < d.totalPages);
setLastRefreshedAt(d.queriedAt ? new Date(d.queriedAt) : new Date()); setLastRefreshedAt(new Date());
}).catch(error => { }).catch(() => {}).finally(() => {
if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '里程查询失败,请点击查询重试');
}).finally(() => {
if (requestId === queryRequestRef.current) setPageLoading(false); if (requestId === queryRequestRef.current) setPageLoading(false);
}); });
}, [monitoringQuery]); }, [monitoringQuery, rangeStart, rangeEnd]);
const handleManualRefresh = useCallback(async () => { const handleManualRefresh = useCallback(async () => {
if (manualRefreshing || pageLoading || hasPendingChanges) return; if (manualRefreshing || pageLoading) return;
setManualRefreshing(true); setManualRefreshing(true);
try { try {
await loadFirstPage(false, true); await loadFirstPage(false);
} finally { } finally {
setManualRefreshing(false); setManualRefreshing(false);
} }
}, [loadFirstPage, manualRefreshing, pageLoading, hasPendingChanges]); }, [loadFirstPage, manualRefreshing, pageLoading]);
// 加载更多 // 加载更多
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (pageLoading || manualRefreshing || loadingMore || !hasMore || hasPendingChanges || queryError) return; if (pageLoading || loadingMore || !hasMore) return;
const requestId = queryRequestRef.current; const requestId = queryRequestRef.current;
const nextPage = page + 1; const nextPage = page + 1;
setLoadingMore(true); setLoadingMore(true);
@@ -182,17 +192,15 @@ 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(error => { }).catch(() => {}).finally(() => setLoadingMore(false));
if (requestId === queryRequestRef.current) setQueryError(error instanceof Error ? error.message : '加载更多失败,请重试'); }, [monitoringQuery, page, pageLoading, loadingMore, hasMore]);
}).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);
@@ -201,18 +209,24 @@ export default function MonitoringView() {
}, [filterOptions.plates, filterPlates]); }, [filterOptions.plates, filterPlates]);
// 下载当前筛选结果为 xlsx // 下载当前筛选结果为 xlsx
const handleDownload = useCallback(async (dailyDetails = false) => { const handleDownload = useCallback(async () => {
if (exporting || hasPendingChanges || pageLoading || queryError) return; if (exporting) return;
setExporting(true); setExporting(true);
try { try {
const d = await fetchMonitoring({ ...buildExportMonitoringQuery(monitoringQuery), dailyDetails }); const d = await fetchMonitoring(buildExportMonitoringQuery(monitoringQuery));
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || monitoringQuery.rangeStart, endDate: d.dateRange?.end || monitoringQuery.rangeEnd, sortBy: monitoringQuery.sortBy }); exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
} catch (err) { } catch (err) {
setQueryError(err instanceof Error ? err.message : '导出失败,请重试'); console.error('export failed', err);
} finally { } finally {
setExporting(false); setExporting(false);
} }
}, [exporting, monitoringQuery, hasPendingChanges, pageLoading, queryError]); }, [exporting, monitoringQuery, rangeStart, rangeEnd, sortBy]);
// 每分钟自动刷新
useEffect(() => {
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
return () => clearInterval(timer);
}, [loadFirstPage]);
// 触底检测:用 IntersectionObserver 监听哨兵元素 // 触底检测:用 IntersectionObserver 监听哨兵元素
const loadMoreRef = useRef(loadMore); const loadMoreRef = useRef(loadMore);
@@ -258,20 +272,16 @@ export default function MonitoringView() {
setIsFullscreen(!isFullscreen); setIsFullscreen(!isFullscreen);
}; };
// 全屏使用已确认条件,编辑筛选后仍需点击查询。 // 全屏时加载全部数据(无分页),筛选变化时重新加载
const loadFullscreen = useCallback((refresh = false) => { useEffect(() => {
setQueryError(''); if (!isFullscreen) return;
setFullscreenLoading(true); setFullscreenLoading(true);
return fetchMonitoring({ ...buildFullscreenMonitoringQuery(monitoringQuery), refresh }).then(d => { fetchMonitoring(buildFullscreenMonitoringQuery(fullscreenQuery)).then(d => {
setFullscreenVehicles(d.vehicles); setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats); setFullscreenStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
}).catch(error => setQueryError(error instanceof Error ? error.message : '全屏查询失败')).finally(() => setFullscreenLoading(false)); }).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [monitoringQuery]); }, [isFullscreen, fullscreenQuery, fullscreenRefresh]);
useEffect(() => {
if (isFullscreen) void loadFullscreen();
}, [isFullscreen, loadFullscreen]);
// 全屏时禁止背景滚动 // 全屏时禁止背景滚动
useEffect(() => { useEffect(() => {
@@ -312,13 +322,9 @@ export default function MonitoringView() {
forceLandscape={forceLandscape} forceLandscape={forceLandscape}
fullscreenStats={fullscreenStats} fullscreenStats={fullscreenStats}
sortBy={sortBy} sortBy={sortBy}
sourcePriority={monitoringQuery.sourcePriority} sourcePriority={sourcePriority}
onQuery={handleQuery} onRefresh={() => { setFullscreenRefresh(n => n + 1); }}
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}
@@ -345,13 +351,8 @@ export default function MonitoringView() {
<MonitoringToolbar <MonitoringToolbar
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
onDownload={() => { void handleDownload(); }} onDownload={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}
@@ -414,8 +415,8 @@ export default function MonitoringView() {
onFilterPlatesChange={setFilterPlates} onFilterPlatesChange={setFilterPlates}
onFilterTargetNamesChange={setFilterTargetNames} onFilterTargetNamesChange={setFilterTargetNames}
onFilterRegionChange={setFilterRegion} onFilterRegionChange={setFilterRegion}
onQuery={handleQuery} onAppliedMileageRangeChange={setAppliedMileageRange}
queryDisabled={queryDisabled} onFilterOpenChange={setIsFilterOpen}
/> />
)} )}
</AnimatePresence> </AnimatePresence>
@@ -444,7 +445,8 @@ export default function MonitoringView() {
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchTermChange={setSearchTerm} onSearchTermChange={setSearchTerm}
onFilterMileageRangeChange={setFilterMileageRange} onFilterMileageRangeChange={setFilterMileageRange}
mileageRange={filterMileageRange} appliedMileageRange={appliedMileageRange}
onAppliedMileageRangeChange={setAppliedMileageRange}
filterPlatePrefix={filterPlatePrefix} filterPlatePrefix={filterPlatePrefix}
onFilterPlatePrefixChange={setFilterPlatePrefix} onFilterPlatePrefixChange={setFilterPlatePrefix}
rangeStart={rangeStart} rangeStart={rangeStart}
@@ -453,12 +455,10 @@ 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={monitoringQuery.sortBy} sortBy={sortBy}
isRangeMode={isRangeMode} isRangeMode={isRangeMode}
stats={stats} stats={stats}
rangeLabel={rangeLabel} rangeLabel={rangeLabel}
@@ -482,9 +482,8 @@ export default function MonitoringView() {
<VehicleDetailModal <VehicleDetailModal
vehicle={detailVehicle} vehicle={detailVehicle}
selectedRange={effectiveRange}
onClose={() => setDetailVehicle(null)} onClose={() => setDetailVehicle(null)}
sourcePriority={monitoringQuery.sourcePriority} sourcePriority={sourcePriority}
/> />
{/* 回到顶部按钮 */} {/* 回到顶部按钮 */}
+16 -17
View File
@@ -13,13 +13,11 @@ interface Props {
vehicle: MonitoringVehicle | null; vehicle: MonitoringVehicle | null;
onClose: () => void; onClose: () => void;
sourcePriority: MileageSourceGroup[]; sourcePriority: MileageSourceGroup[];
selectedRange: { start: string; end: string };
} }
type RangeKey = 'selected' | 'last15' | 'month' | 'quarter'; type RangeKey = '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: '本季度' },
@@ -50,6 +48,10 @@ 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;
@@ -62,32 +64,30 @@ function daySourceLabel(day: VehicleRecentDay): string {
return day.isDataSynced ? '来源待接口' : '无数据'; return day.isDataSynced ? '来源待接口' : '无数据';
} }
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority, selectedRange }: Props) { export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
const [days, setDays] = useState<VehicleRecentDay[]>([]); const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [range, setRange] = useState<RangeKey>('last15');
const [range, setRange] = useState<RangeKey>('selected');
const dragControls = useDragControls(); const dragControls = useDragControls();
// 切换车辆时重置区间为默认 // 切换车辆时重置区间为默认
useEffect(() => { useEffect(() => {
if (vehicle) setRange('selected'); if (vehicle) setRange('last15');
}, [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 } = range === 'selected' ? selectedRange : rangeFor(range); const { start, end } = 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) setError('单车里程加载失败,请重新打开详情重试'); }) .catch(() => { if (!cancelled) setDays([]); })
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [vehicle?.plate, range, sourcePriority, selectedRange.start, selectedRange.end]); // eslint-disable-line react-hooks/exhaustive-deps }, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动 // 锁滚动
useEffect(() => { useEffect(() => {
@@ -96,8 +96,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority, s
return () => { document.body.style.overflow = ''; }; return () => { document.body.style.overflow = ''; };
}, [vehicle]); }, [vehicle]);
// 与主列表区间一致;缺失日保留缺口,真实零保留为零。 // 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo(() => days, [days]); const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [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, s
// 骨架天数:根据区间预估 // 骨架天数:根据区间预估
const skeletonCount = useMemo(() => { const skeletonCount = useMemo(() => {
if (range === 'last15') return 15; if (range === 'last15') return 15;
const { start, end } = range === 'selected' ? selectedRange : rangeFor(range); const { start, end } = 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, selectedRange.start, selectedRange.end]); }, [range]);
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -171,12 +171,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority, s
</div> </div>
</div> </div>
</div> </div>
<button aria-label="关闭车辆详情" onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0"> <button 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">
+2 -11
View File
@@ -7,7 +7,7 @@ import type {
TargetVehicle, TargetVehicle,
TrendPoint, TrendPoint,
} from './types'; } from './types';
import { fetchJson, fetchAuthenticated } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import type { import type {
DailyMileageReport, DailyMileageReport,
MileageReportHistoryResponse, MileageReportHistoryResponse,
@@ -26,9 +26,6 @@ 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;
@@ -53,9 +50,6 @@ 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));
@@ -87,10 +81,7 @@ 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();
const response = await fetchAuthenticated(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); return fetchJson<MonitoringData>(`${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,10 +21,3 @@ 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}),'—');
});
-6
View File
@@ -2,7 +2,6 @@ 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);
@@ -23,8 +22,3 @@ 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,7 +27,8 @@ export default function ActiveFilterTags({
searchTerm, searchTerm,
onSearchTermChange, onSearchTermChange,
onFilterMileageRangeChange, onFilterMileageRangeChange,
mileageRange, appliedMileageRange,
onAppliedMileageRangeChange,
filterPlatePrefix, filterPlatePrefix,
onFilterPlatePrefixChange, onFilterPlatePrefixChange,
rangeStart, rangeStart,
@@ -57,7 +58,8 @@ export default function ActiveFilterTags({
searchTerm: string; searchTerm: string;
onSearchTermChange: (value: string) => void; onSearchTermChange: (value: string) => void;
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>; onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
mileageRange: MileageRange; appliedMileageRange: MileageRange;
onAppliedMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
filterPlatePrefix: string; filterPlatePrefix: string;
onFilterPlatePrefixChange: (value: string) => void; onFilterPlatePrefixChange: (value: string) => void;
rangeStart: string; rangeStart: string;
@@ -82,8 +84,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 (mileageRange.min) tags.push({ label: `里程≥${mileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); } }); if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, min: '' })); } });
if (mileageRange.max) tags.push({ label: `里程≤${mileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); } }); if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); onAppliedMileageRangeChange(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)}`,
@@ -97,7 +99,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: '' }); onFilterMileageRangeChange({ min: '', max: '' }); onAppliedMileageRangeChange({ 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,
onQuery, onAppliedMileageRangeChange,
queryDisabled, onFilterOpenChange,
}: { }: {
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;
onQuery: () => void; onAppliedMileageRangeChange: (value: MileageRange) => void;
queryDisabled: boolean; onFilterOpenChange: (value: boolean) => void;
}) { }) {
return ( return (
<motion.div <motion.div
@@ -82,7 +82,6 @@ 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)}
@@ -90,13 +89,11 @@ 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', '今天'],
@@ -243,13 +240,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);
@@ -260,11 +257,13 @@ export default function FilterPanel({
重置所有 重置所有
</button> </button>
<button <button
onClick={onQuery} onClick={() => {
disabled={queryDisabled} onAppliedMileageRangeChange({ ...filterMileageRange });
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100 disabled:opacity-50" onFilterOpenChange(false);
}}
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, mileageQualityLabel } from '../../mileage-display'; import { monitoringMileageValue } 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,12 +15,8 @@ export default function FullscreenMonitoring({
fullscreenStats, fullscreenStats,
sortBy, sortBy,
sourcePriority, sourcePriority,
onQuery,
queryDisabled,
hasPendingChanges,
onRefresh, onRefresh,
fullscreenLoading, fullscreenLoading,
error,
onToggleFullscreen, onToggleFullscreen,
filterTargetNames, filterTargetNames,
onFilterTargetNamesChange, onFilterTargetNamesChange,
@@ -46,12 +42,8 @@ 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[]>>;
@@ -101,13 +93,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">{fullscreenStats.validVehicleCount === 0 ? '—' : 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">{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">{fullscreenStats.totalMileageVehicleCount === 0 ? '—' : 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">{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">{(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-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-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">
@@ -117,11 +109,7 @@ 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' : ''}`}
> >
@@ -133,7 +121,6 @@ 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">
@@ -311,7 +298,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, mileageQualityLabel(v), v.qualityReasons?.join(', ')].filter(Boolean).join(' · ')} title={sourceDisplay.title}
> >
{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 | null }[]; rangeDailyTotals: { date: string; totalKm: number }[];
total: number; total: number;
}) { }) {
const hasMileage = (sortBy === 'total' ? stats.totalMileageVehicleCount : stats.validVehicleCount) !== 0; const averageDailyKm = rangeDailyTotals.length > 0
const validDays = rangeDailyTotals.filter(item => item.totalKm != null); ? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
const averageDailyKm = validDays.length ? validDays.reduce((sum, item) => sum + item.totalKm!, 0) / validDays.length : null; : 0;
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> : <>{hasMileage ? 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> : <>{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> : hasMileage ? (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> : (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 || validDays.length === 0 ? ( {rangeDailyTotals.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) => value == null ? ['无有效数据', '当日里程'] : [`${Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']} formatter={(value) => [`${Number(value ?? 0).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 != null && averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />} {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">{averageDailyKm == null ? '—' : Math.round(averageDailyKm).toLocaleString()}</div> <div className="text-xs font-black text-slate-800">{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">{stats.validVehicleCount === 0 ? '—' : Math.round(stats.totalToday).toLocaleString()}</div> <div className="mt-1 text-xs font-black tabular-nums text-blue-700">{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.validVehicleCount === 0 ? '—' : 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.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
</div> </div>
</div> </div>
)} )}
@@ -11,13 +11,8 @@ 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,
@@ -49,13 +44,8 @@ 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;
@@ -98,13 +88,9 @@ 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>
<details className="relative"> <button onClick={onDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
<summary aria-label="导出" className="cursor-pointer list-none p-1 text-slate-400">{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}</summary> {exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
<div className="absolute left-0 top-full z-40 w-36 rounded-lg border border-slate-200 bg-white p-1 shadow-lg"> </button>
<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" />
@@ -114,15 +100,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 title="查询结果获取时间" className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline"> <span 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 || resultsDisabled} disabled={manualRefreshing}
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>
@@ -159,8 +145,7 @@ export default function MonitoringToolbar({
</button> </button>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto no-scrollbar">
{([ {([
['today', '今天'], ['today', '今天'],
['yesterday', '昨天'], ['yesterday', '昨天'],
@@ -181,10 +166,7 @@ export default function MonitoringToolbar({
</button> </button>
); );
})} })}
</div> <span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
<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, mileageQualityLabel } from '../../mileage-display'; import { monitoringMileageValue } 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,8 +87,7 @@ export default function VehicleList({
{sourceDisplay.label} {sourceDisplay.label}
</span> </span>
</div> </div>
{v.qualityStatus && <div className="text-[9px] text-slate-500" title={v.qualityReasons?.join(', ')}>{mileageQualityLabel(v)}</div>} {v.mileageAnomaly && <div className="text-[9px] font-bold text-amber-600" title={v.mileageAnomaly}>{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}</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, includeDailyTrend: false }); assert.deepEqual(nextPage, { ...initial, page: 3 });
const exported = buildExportMonitoringQuery(queryModel); const exported = buildExportMonitoringQuery(queryModel);
assert.deepEqual(exported, { ...initial, limit: 9999, includeDailyTrend: false }); assert.deepEqual(exported, { ...initial, limit: 9999 });
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), includeDailyTrend: false }; return buildPagedQuery(model, pageSize, page);
} }
export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams { export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
return { ...buildPagedQuery(model, 9999, 1), includeDailyTrend: false }; return buildPagedQuery(model, 9999, 1);
} }
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), includeDailyTrend: false }; return buildSharedQuery(model, 9999, 1);
} }
+1 -11
View File
@@ -6,13 +6,6 @@ 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;
@@ -42,8 +35,6 @@ 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 {
@@ -63,12 +54,11 @@ export interface MonitoringData {
vehicles: MonitoringVehicle[]; vehicles: MonitoringVehicle[];
stats: MonitoringStats; stats: MonitoringStats;
filters: MonitoringFilters; filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number | null }[]; rangeDailyTotals?: { date: string; totalKm: number }[];
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[];
} }
+4 -6
View File
@@ -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,7 +22,6 @@ 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 ? '在线' : '离线';
} }
@@ -31,7 +30,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 {
@@ -63,7 +62,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.department || '', v.department || '',
v.project || '', v.project || '',
v.rentStatus || '', v.rentStatus || '',
v.region || '', v.validDays ?? '', v.missingDays ?? '', v.anomalyDays ?? '', v.qualityReasons?.join(', ') || '', v.region || '',
]; ];
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—'); const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
return [ return [
@@ -87,7 +86,6 @@ 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 }); // 区间合计
@@ -179,6 +177,6 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
: ctx.sortBy === 'statisticTime' : ctx.sortBy === 'statisticTime'
? '统计时间' ? '统计时间'
: isRange ? '区间' : '今日'; : isRange ? '区间' : '今日';
const filename = `${dayKeys.length ? '里程逐日明细' : '里程区间汇总'}_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`; const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
XLSX.writeFile(wb, filename); XLSX.writeFile(wb, filename);
} }
-2
View File
@@ -14,7 +14,6 @@ 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 应用,但不监听端口、不访问数据库,也不启动后台任务。
@@ -32,7 +31,6 @@ 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);
+1 -1
View File
@@ -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<T extends Vehicle>(items: T[], user: AuthUser): T[] { export function filterAssetVehiclesByPermission(items: Vehicle[], user: AuthUser): Vehicle[] {
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));
-2
View File
@@ -1,6 +1,5 @@
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 {
@@ -8,5 +7,4 @@ export function startBackgroundServices(): void {
console.error('scheduling bootstrap error:', error); console.error('scheduling bootstrap error:', error);
}); });
startMileageBackgroundJobs(); startMileageBackgroundJobs();
startAssetHistoryScheduler();
} }
@@ -1,32 +0,0 @@
// 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;
}
}
-30
View File
@@ -1,30 +0,0 @@
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();
@@ -1,56 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { dayCutoff, shiftDay, 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}T23:55:00.000+08:00`);
assert.equal(monthCutoff(month).toISOString(), `${expected}T15:55:00.000Z`);
}
assert.equal(shanghaiMonth(new Date('2026-12-31T16:00:00Z')), '2027-01');
assert.equal(nextCapture(new Date('2026-12-31T15:55:00Z')), '2027-01-01T23:55: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-31T15:54:59Z'), undefined, [], live).state, 'LIVE');
assert.equal(makePoint('2026-10', new Date('2026-10-31T15:55:00Z'), undefined, [], live).state, 'CAPTURING');
const missing = makePoint('2026-10', new Date('2026-10-31T15:56:00Z'), undefined, [], live);
assert.equal(missing.state, 'MISSING'); assert.equal(missing.counts, null);
const archived = makePoint('2026-10', new Date('2026-10-31T15:55: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');
});
test('daily calendar handles leap days, year changes and next capture within a month', () => {
assert.equal(shiftDay('2024-02-28',1),'2024-02-29');
assert.equal(shiftDay('2026-02-28',1),'2026-03-01');
assert.equal(shiftDay('2026-12-31',1),'2027-01-01');
assert.equal(nextCapture(new Date('2026-10-30T23:54:59+08:00')),'2026-10-30T23:55:00.000+08:00');
assert.equal(nextCapture(new Date('2026-10-30T23:55:00+08:00')),'2026-10-31T23:55:00.000+08:00');
assert.throws(()=>dayCutoff('2026-02-29'));
});
test('legacy monthly archive keeps its original scheduled and captured times', () => {
const batch={...testBatch('2026-09'),scheduledFor:'2026-09-30T17:00:00+08:00',capturedAt:'2026-09-30T17:00:02+08:00'};
const point=makePoint('2026-09',new Date('2026-10-30T00:00:00+08:00'),batch,[]);
assert.equal(point.state,'ARCHIVED');assert.equal(point.scheduledFor,batch.scheduledFor);assert.equal(point.asOf,batch.capturedAt);
});
-84
View File
@@ -1,84 +0,0 @@
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 = batch ? new Date(batch.scheduledFor) : 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: batch?.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}` : '暂无可比数据' };
}
@@ -1,87 +0,0 @@
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});}
});
@@ -1,86 +0,0 @@
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 }));
}
@@ -1,9 +0,0 @@
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();
}
@@ -1,51 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { HistoryScheduler, type HistoryCaptureStore } from './scheduler.js';
function fixture(enabledAt='2026-10-30T13:00:00+08:00') {
const captures:string[]=[],missed:string[]=[],failed:string[]=[];
let activates=0;
const store:HistoryCaptureStore={
activate:async()=>{activates++;return{enabledAt};},dailyBatches: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 daily 23:55 minute and activation is persisted once',async()=>{
const f=fixture();let date=new Date('2026-10-31T23:54:59+08:00');
const scheduler=new HistoryScheduler(f.store,()=>date,()=>{});
await scheduler.tick();assert.deepEqual(f.captures,[]);
date=new Date('2026-10-31T23:55:00+08:00');await scheduler.tick();assert.deepEqual(f.captures,['2026-10-31']);assert.equal(f.activates(),1);
});
test('restart records missed eligible days without backfilling old live data or pre-activation periods',async()=>{
const f=fixture();const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-11-02T08:00:00+08:00'),()=>{});
await scheduler.tick();assert.deepEqual(f.captures,[]);assert.deepEqual(f.missed,['2026-10-30','2026-10-31','2026-11-01']);
const late=fixture('2026-10-31T23:55:10+08:00');await new HistoryScheduler(late.store,()=>new Date('2026-10-31T23:55:20+08:00'),()=>{}).tick();
assert.deepEqual(late.captures,[]);assert.deepEqual(late.missed,[]);
});
test('completed days skip capture and existing failures are preserved after the window',async()=>{
const f=fixture();f.store.dailyBatches=async()=>[{day:'2026-10-30',status:'COMPLETE'}];
f.store.capture=async()=>{throw new Error('source unavailable');};
await new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:55:20+08:00'),()=>{}).tick();
assert.deepEqual(f.missed,[]);assert.deepEqual(f.failed,['2026-10-31']);
f.store.dailyBatches=async()=>[{day:'2026-10-30',status:'COMPLETE'},{day:'2026-10-31',status:'FAILED'}];
await new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:56:00+08:00'),()=>{}).tick();
assert.deepEqual(f.failed,['2026-10-31']);assert.deepEqual(f.missed,[]);
});
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-31');await new Promise<void>(r=>{release=r;});return'captured';};
const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-10-31T23:55: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));
});
test('consecutive dates in one month and across month boundary capture independently',async()=>{
const f=fixture();
for(const day of ['2026-10-30','2026-10-31','2026-11-01']) {
await new HistoryScheduler(f.store,()=>new Date(`${day}T23:55:00+08:00`),()=>{}).tick();
}
assert.deepEqual(f.captures,['2026-10-30','2026-10-31','2026-11-01']);
});
@@ -1,61 +0,0 @@
import { CAPTURE_WINDOW_MS, dayCutoff, shanghaiDay, shiftDay } 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>;
dailyBatches(): Promise<Array<{ day: string; status: HistoryBatch['status'] }>>;
capture(day: string, clock: () => Date): Promise<CaptureResult>;
recordOutcome(day: 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.dailyBatches()).map(b => [b.day, b]));
for (let day = shanghaiDay(enabled); day <= shanghaiDay(now); day = shiftDay(day, 1)) {
const cutoff = dayCutoff(day);
if (cutoff < enabled || cutoff > now || existing.get(day)?.status === 'COMPLETE') continue;
if (now.getTime() >= cutoff.getTime() + CAPTURE_WINDOW_MS) {
if (!existing.has(day)) { await this.store.recordOutcome(day, 'MISSED', 'CAPTURE_WINDOW_MISSED'); this.log(`[asset-history] missed ${day}`); }
continue;
}
try { const result = await this.store.capture(day, this.clock); this.log(`[asset-history] ${day}: ${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(day, 'FAILED', code);
this.log(`[asset-history] failed ${day} (${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 = dayCutoff(shanghaiDay(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); }
}
@@ -1,119 +0,0 @@
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-31T23:55: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']);
});
-126
View File
@@ -1,126 +0,0 @@
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 })) };
}
}
@@ -1,112 +0,0 @@
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-31T23:55: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-31',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-31T23:55:30+08:00');
db.afterSource=()=>{now=new Date('2026-10-31T23:59:00+08:00');};const clock=()=>now;
assert.equal(await db.store().capture('2026-10-31',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-31',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-31T23:55:59.900+08:00');
db.onReadTransaction=()=>{now=new Date('2026-10-31T23:56:00+08:00');};
assert.equal(await db.store().capture('2026-10-31',()=>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-31',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-31',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-31',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-31T23:56:00+08:00');
if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T23:56:00+08:00');};
if(scenario==='late-activation')db.settings='2026-10-31T23:55:01+08:00';
const result=await db.store().capture('2026-10-31',()=>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-31',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')));
});
test('monthly reader selects month-end keys and retains completed legacy archives when a daily capture failed',async()=>{
let query='';
const row=(id:string,month:string,status:string)=>({id,month,status,source:'AUTO',total_count:0,operating_count:0,inventory_count:0});
const db={query:async(sql:string)=>{query=sql;return[[row('old-sep','2026-09','COMPLETE'),row('new-sep','2026-09','FAILED'),row('old-oct','2026-10','COMPLETE'),row('new-oct','2026-10','COMPLETE')]];}};
const batches=await new MysqlHistoryStore(db as unknown as Pool).batches();
assert.deepEqual(batches.map(b=>b.id),['old-sep','new-oct']);
assert.match(query,/LAST_DAY/);assert.match(query,/CHAR_LENGTH\(period_key\)=7/);
});
-135
View File
@@ -1,135 +0,0 @@
import { randomUUID } from 'node:crypto';
import type { Pool, PoolConnection, RowDataPacket } from 'mysql2/promise';
import { CAPTURE_WINDOW_MS, HISTORY_VERSION, dayCutoff, 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('daily_enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`);
return rows[0]?.enabledAt ? { enabledAt: rows[0].enabledAt } : null;
}
async batches(): Promise<HistoryBatch[]> {
const rows = await this.read(`SELECT id, LEFT(period_key,7) 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=? AND (CHAR_LENGTH(period_key)=7 OR period_key=DATE_FORMAT(LAST_DAY(CONCAT(LEFT(period_key,7),'-01')), '%Y-%m-%d')) ORDER BY period_key`, [HISTORY_VERSION]);
const batches = 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 } }));
const byMonth = new Map<string, HistoryBatch>();
for (const batch of batches) {
if (batch.status === 'COMPLETE' || byMonth.get(batch.month)?.status !== 'COMPLETE') byMonth.set(batch.month, batch);
}
return [...byMonth.values()];
}
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 INTO bi_asset_history_settings (id,enabled_at,daily_enabled_at) VALUES (1,?,?)
ON DUPLICATE KEY UPDATE daily_enabled_at=COALESCE(daily_enabled_at,VALUES(daily_enabled_at))`, [sqlTime(now), sqlTime(now)]);
return (await this.settings())!;
}
async dailyBatches(): Promise<Array<{ day: string; status: HistoryBatch['status'] }>> {
const rows = await this.read('SELECT period_key AS day,status FROM bi_asset_history_batches WHERE definition_version=? AND CHAR_LENGTH(period_key)=10', [HISTORY_VERSION]);
return rows.map(row => ({ day: row.day, status: row.status }));
}
async recordOutcome(day: 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_key,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(), day, HISTORY_VERSION, sqlTime(dayCutoff(day)), status, code]);
}
async capture(day: string, clock: () => Date = () => new Date()): Promise<CaptureResult> {
const cutoff = dayCutoff(day).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}:${day}`;
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_key=? AND definition_version=?`, [day, 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, day, 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, day: 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_key=? AND definition_version=? FOR UPDATE', [day, 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_key,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, day, HISTORY_VERSION, sqlTime(dayCutoff(day)), 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;
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function registerHydrogenDailyRoute(
HYDROGEN_BASE_WHERE_B, HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`, `b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`), dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'), customerClause(customer),
].join(' AND '); ].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations // 站点级聚合(每日 × 每站)。前端组装成 day → stations
+26 -4
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { import {
customerClause, customerClause,
dateRangeClause, dateRangeClause,
@@ -7,10 +8,31 @@ import {
resolveDateRange, resolveDateRange,
} from './query-model.js'; } from './query-model.js';
test('客户范围继续让外部车辆返回空数据', () => { test('外部车辆只取佛山羚牛站未关联车辆的订单,车牌文字和收费不影响分类', () => {
assert.equal(customerClause('external'), '1=0'); const db = new DatabaseSync(':memory:');
assert.equal(customerClause('lingniu'), '1=1'); try {
assert.equal(customerClause('all'), '1=1'); db.exec(`CREATE TABLE orders (
id INTEGER, station_id INTEGER, vehicle_id INTEGER,
license_plate TEXT, customer_price REAL, del_flag TEXT
);
INSERT INTO orders VALUES
(1, 341, NULL, '粤A外部', 38, '0'),
(2, 341, NULL, '', 0, '0'),
(3, 341, 123, '粤A羚牛', 38, '0'),
(4, 52, NULL, '粤A其他站', 38, '0'),
(5, NULL, NULL, '', 0, '0'),
(6, NULL, 456, '粤A未关联站点', 0, '0'),
(7, 341, NULL, '粤A已删除', 38, '1');`);
const ids = (kind: 'external' | 'lingniu' | 'all') => db
.prepare(`SELECT b.id FROM orders b WHERE b.del_flag = '0' AND ${customerClause(kind)} ORDER BY b.id`)
.all().map(row => row.id);
assert.deepEqual(ids('external'), [1, 2]);
assert.deepEqual(ids('lingniu'), [3, 4, 5, 6]);
assert.deepEqual(ids('all'), [1, 2, 3, 4, 5, 6]);
assert.deepEqual([...ids('external'), ...ids('lingniu')].sort(), ids('all'));
} finally {
db.close();
}
}); });
test('能源日期快捷区间继续按本地自然日计算', () => { test('能源日期快捷区间继续按本地自然日计算', () => {
+7 -3
View File
@@ -8,10 +8,14 @@ export interface EnergyDateRange {
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/; const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。 // 主数据 ID 341:佛山南海羚牛加氢站。车牌文字存在不代表已关联车辆。
const FOSHAN_LINGNIU_STATION_ID = 341;
// 日明细账本使用别名 b;两类互斥,其他订单继续保留在原羚牛范围内。
export function customerClause(customer: CustomerKind): string { export function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0'; const external = `(COALESCE(b.station_id, 0) = ${FOSHAN_LINGNIU_STATION_ID} AND b.vehicle_id IS NULL)`;
if (customer === 'lingniu') return '1=1'; if (customer === 'external') return external;
if (customer === 'lingniu') return `NOT ${external}`;
return '1=1'; return '1=1';
} }
+7 -2
View File
@@ -105,6 +105,11 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1', '/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
); );
assert.equal(dailyResponse.status, 200); assert.equal(dailyResponse.status, 200);
assert.deepEqual(await dailyResponse.json(), [
{ date: '2025-01-02', totalKg: 10, chainPct: 0, customerType: 'external',
stations: [{ name: '测试站', pricePerKg: 35, kg: 10, chainPct: 0 }] },
{ date: '2025-01-01', totalKg: 0, chainPct: 0, customerType: 'external', stations: [] },
]);
const electricApp = new Hono(); const electricApp = new Hono();
const electricDependencies = { const electricDependencies = {
@@ -127,7 +132,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
{ key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true }, { key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true },
]); ]);
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。 // 保留查询契约指纹;日明细已更新为佛山羚牛站未关联车辆的外部订单筛选。
assert.deepEqual( assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)), [...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)),
[ [
@@ -138,7 +143,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'256ac8309d5a', '256ac8309d5a',
'dac9e86fc527', 'dac9e86fc527',
'a71da8f05765', 'a71da8f05765',
'24b23780a259', 'd1620280f520',
'226fd8f950a1', '226fd8f950a1',
'6e4031b10926', '6e4031b10926',
'4fc77463294c', '4fc77463294c',
+12 -17
View File
@@ -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 } from './oneos-api.js'; import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
import { queryMonitoringRange } from './query-cache.js'; import type { OneOsProtocol } from './source-policy.js';
import { DEFAULT_ONEOS_PROTOCOL_PRIORITY, type OneOsProtocol } from './source-policy.js'; import { buildRangeMileage, type RangeMileageResult } from './range-model.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,22 +117,17 @@ export async function queryDateMileage(
export async function queryRangeMileage( export async function queryRangeMileage(
startDate: string, startDate: string,
endDate: string, endDate: string,
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY, protocolPriority?: OneOsProtocol[],
refresh = false, ): Promise<RangeMileageResult> {
) { const days = mileageDatesBetween(startDate, endDate);
const [cached, infoMap, targetRows] = await Promise.all([ const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
queryMonitoringRange(startDate, endDate, protocolPriority, refresh), fetchOneOsMileageDates(days, undefined, protocolPriority),
fetchOneOsDailyMileage(endDate, undefined, protocolPriority),
fetchOneOsDailyMileage(previousMileageDate(startDate), undefined, protocolPriority),
fetchVehicleInfoMap(), fetchVehicleInfoMap(),
fetchTargetRows(), fetchTargetRows(),
]); ]);
const { summary, yesterdayRows } = cached.result; return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap);
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 {
@@ -1,82 +0,0 @@
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');
});
+10 -54
View File
@@ -1,9 +1,4 @@
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';
@@ -137,29 +132,21 @@ 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 | null }[] | undefined; let rangeDailyTotals: { date: string; totalKm: number }[] | 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, c.req.query('refresh') === '1'); const result = await queryRangeMileage(range.start, range.end, protocolPriority);
queriedAt = result.queriedAt;
c.header('X-Mileage-Cache', result.cacheStatus);
allVehicles = result.vehicles; allVehicles = result.vehicles;
rangeSummary = result.summary; rangeDailyTotals = result.dailyTotals;
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({ error: e instanceof Error ? e.message : '里程汇总查询失败' }, 502); return c.json(EMPTY_RESPONSE, 500);
} }
} else if (date) { } else if (date) {
try { try {
@@ -190,35 +177,17 @@ app.get('/', async (c) => {
} }
const filtered = applyFilters(allVehicles, filterParams); const filtered = applyFilters(allVehicles, filterParams);
// The provider trend covers its entire selected fleet, not the BI user's scope. if (rangeDailyTotals && filtered.length !== allVehicles.length) {
if (range && rangeSummary && c.req.query('includeDailyTrend') !== '0') { rangeDailyTotals = rangeDailyTotals.map(item => ({
const authorized = new Set(rangeSummary.vehicles.map(v => v.plateNumber)); ...item,
const plates = [...new Set(filtered.map(v => v.plate.trim().toUpperCase()).filter(p => authorized.has(p)))].sort(); totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)),
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),
}; };
@@ -246,19 +215,7 @@ app.get('/', async (c) => {
}); });
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
let paged = sorted.slice(offset, offset + limit); const 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({
@@ -272,7 +229,6 @@ 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,
}); });
}); });
+1 -2
View File
@@ -224,7 +224,6 @@ 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);
@@ -232,7 +231,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 (!refresh && hit && hit.expiresAt > Date.now()) { if (hit && hit.expiresAt > Date.now()) {
rows = hit.rows; rows = hit.rows;
} else { } else {
let pending = inflight.get(cacheKey); let pending = inflight.get(cacheKey);
@@ -98,13 +98,3 @@ 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);
});
+2 -2
View File
@@ -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' && row.dataQuality !== 'CARRIED_FORWARD' && km != null && Number.isFinite(km) && km >= 0 ? km : null; return row?.status === 'NORMAL' && 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,
...((row.dataQuality || status === 'DATA_ANOMALY') ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}), ...(status === 'DATA_ANOMALY' ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}),
dataTime, dataTime,
calculatedAt, calculatedAt,
updatedAt, updatedAt,
@@ -1,89 +0,0 @@
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');
});
-58
View File
@@ -1,58 +0,0 @@
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();
@@ -1,74 +0,0 @@
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();
}
});
-116
View File
@@ -1,116 +0,0 @@
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.
}
@@ -1,19 +0,0 @@
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);
});
@@ -1,26 +0,0 @@
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,
};
});
}
+1 -11
View File
@@ -3,13 +3,6 @@ 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;
@@ -71,8 +64,6 @@ export interface MonitoringStats {
totalAll: number; totalAll: number;
vehicleCount: number; vehicleCount: number;
yesterdayTotal: number; yesterdayTotal: number;
validVehicleCount?: number;
totalMileageVehicleCount?: number;
} }
/** /monitoring 完整响应 */ /** /monitoring 完整响应 */
@@ -80,12 +71,11 @@ export interface MonitoringResponse {
vehicles: CachedVehicle[]; vehicles: CachedVehicle[];
stats: MonitoringStats; stats: MonitoringStats;
filters: MonitoringFilters; filters: MonitoringFilters;
rangeDailyTotals?: { date: string; totalKm: number | null }[]; rangeDailyTotals?: { date: string; totalKm: number }[];
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,7 +1,4 @@
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 {
@@ -63,12 +60,6 @@ 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) {
-1
View File
@@ -43,7 +43,6 @@ 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;
-85
View File
@@ -1,85 +0,0 @@
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, 15, 55)); // 23:55 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 shanghaiDay(now: Date): string { return shanghaiTimestamp(now).slice(0, 10); }
export function dayCutoff(day: string): Date {
const date = new Date(`${day}T23:55:00+08:00`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(date.getTime()) || shanghaiDay(date) !== day) throw new Error('日期格式无效');
return date;
}
export function shiftDay(day: string, offset: number): string {
return shanghaiDay(new Date(dayCutoff(day).getTime() + offset * 86_400_000));
}
export function nextCapture(now: Date): string {
const day = shanghaiDay(now);
return shanghaiTimestamp(dayCutoff(dayCutoff(day) > now ? day : shiftDay(day, 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: '历史回溯 · 估算',
};