From 8afe13c9a3e40fc5b2ae61013681ddfff587795a Mon Sep 17 00:00:00 2001 From: shishengliang Date: Sat, 24 Oct 2026 13:16:48 +0800 Subject: [PATCH] feat(assets): add verified asset history trends and compact overview --- docs/asset-history-import-template.csv | 1 + docs/asset-history.md | 169 ++++++++++++++++++ migrations/20261019_asset_history.sql | 43 +++++ scripts/backfill-asset-reconstruction.ts | 20 +++ scripts/migrate-asset-history.ts | 12 ++ src/modules/assets/AssetsModule.tsx | 8 +- src/modules/assets/api.ts | 1 + .../assets/components/AssetsHeader.tsx | 4 +- .../assets/components/OverviewView.tsx | 3 + .../components/overview/SummaryMetrics.tsx | 10 +- .../assets/components/overview/types.ts | 2 + .../assets/history/AssetHistorySection.tsx | 109 +++++++++++ .../assets/history/HistoryDetailsDialog.tsx | 66 +++++++ src/modules/assets/history/HistorySelect.tsx | 31 ++++ src/modules/assets/history/api.ts | 20 +++ src/modules/assets/history/history.css | 131 ++++++++++++++ .../assets/history/presentation.test.ts | 37 ++++ src/modules/assets/history/presentation.ts | 29 +++ src/server/app.ts | 2 + src/server/auth/permissions.ts | 2 +- src/server/bootstrap.ts | 2 + src/server/routes/asset-history/fixtures.ts | 32 ++++ src/server/routes/asset-history/index.ts | 30 ++++ src/server/routes/asset-history/model.test.ts | 42 +++++ src/server/routes/asset-history/model.ts | 84 +++++++++ .../asset-history/reconstruction.test.ts | 87 +++++++++ .../routes/asset-history/reconstruction.ts | 86 +++++++++ src/server/routes/asset-history/runtime.ts | 9 + .../routes/asset-history/scheduler.test.ts | 42 +++++ src/server/routes/asset-history/scheduler.ts | 61 +++++++ .../routes/asset-history/service.test.ts | 119 ++++++++++++ src/server/routes/asset-history/service.ts | 126 +++++++++++++ src/server/routes/asset-history/store.test.ts | 103 +++++++++++ src/server/routes/asset-history/store.ts | 125 +++++++++++++ src/server/types.ts | 1 + src/shared/assets/history.ts | 76 ++++++++ 36 files changed, 1716 insertions(+), 9 deletions(-) create mode 100644 docs/asset-history-import-template.csv create mode 100644 docs/asset-history.md create mode 100644 migrations/20261019_asset_history.sql create mode 100644 scripts/backfill-asset-reconstruction.ts create mode 100644 scripts/migrate-asset-history.ts create mode 100644 src/modules/assets/history/AssetHistorySection.tsx create mode 100644 src/modules/assets/history/HistoryDetailsDialog.tsx create mode 100644 src/modules/assets/history/HistorySelect.tsx create mode 100644 src/modules/assets/history/api.ts create mode 100644 src/modules/assets/history/history.css create mode 100644 src/modules/assets/history/presentation.test.ts create mode 100644 src/modules/assets/history/presentation.ts create mode 100644 src/server/routes/asset-history/fixtures.ts create mode 100644 src/server/routes/asset-history/index.ts create mode 100644 src/server/routes/asset-history/model.test.ts create mode 100644 src/server/routes/asset-history/model.ts create mode 100644 src/server/routes/asset-history/reconstruction.test.ts create mode 100644 src/server/routes/asset-history/reconstruction.ts create mode 100644 src/server/routes/asset-history/runtime.ts create mode 100644 src/server/routes/asset-history/scheduler.test.ts create mode 100644 src/server/routes/asset-history/scheduler.ts create mode 100644 src/server/routes/asset-history/service.test.ts create mode 100644 src/server/routes/asset-history/service.ts create mode 100644 src/server/routes/asset-history/store.test.ts create mode 100644 src/server/routes/asset-history/store.ts create mode 100644 src/shared/assets/history.ts diff --git a/docs/asset-history-import-template.csv b/docs/asset-history-import-template.csv new file mode 100644 index 0000000..7367f38 --- /dev/null +++ b/docs/asset-history-import-template.csv @@ -0,0 +1 @@ +period_month,scheduled_for,captured_at,definition_version,source_reference,vehicle_id,plate_number,vin,operation_status,vehicle_status,subject_org,department_name,manager_id,manager_name,vehicle_type,model,brand,color,ownership,rent_company,province,city,contract_no,customer_name,contract_org,project_name diff --git a/docs/asset-history.md b/docs/asset-history.md new file mode 100644 index 0000000..89c3fcf --- /dev/null +++ b/docs/asset-history.md @@ -0,0 +1,169 @@ +## 当前展示口径(2026-10-24 更新) + +按用户最新要求,停止展示交还记录推算的历史资产数据。正式路由只提供当前实时数据和真实归档快照,已取消 `ASSET_HISTORY_RECONSTRUCTION_FILE` 的接入;即使保留旧环境变量,也不能通过趋势或明细接口读取回溯文件。旧核对表和私有回溯文件保留用于离线核查,不删除、不写入正式快照。 + +2026年10月前的缺失期间返回空值,不补零、不提供估算明细。页面仅显示一句提示:“历史库存状态快照缺失,核对中,暂不展示。” 当前10月继续读取实时数据;今后只有采集成功的真实快照才能归档展示。服务端仍返回存储和采集状态供运维检查,页面不再重复展示部署及回溯说明。 + +本地验收服务同步停用回溯文件。生产迁移、部署和自动采集仍未执行;该展示调整不等于采集已经启用。下文的回溯回填章节保留为历史实施记录,其环境变量接入和页面展示说明已被本节取代。 + +# 资产历史趋势 + +资产概览顶部指标之后新增历史趋势。月度选择年份,年度每年一个年末点;运营和库存为堆叠柱,总数为折线。下方期间按钮支持键盘和触摸选点,月度明细可分类、搜索车牌/VIN及分页。 + +## 口径及时间 + +- 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。 +- 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。 +- 月末最后一天 `Asia/Shanghai` 17:00 为计划时点;只允许在 17:00:00(含)到 17:01:00(不含)内开始读取。 +- `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。 +- 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。 +- 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。 +- 实时点每五秒走 `/live`,不重读全部历史;切换年份/公司/模式、手动刷新、跨月或归档边界时更新历史。 +- 实时数据沿用现有资产仓储的五秒缓存,实时 `asOf` 为本次服务端查询时间,不代表源台账所有记录的最后修改时间;固定快照绕过该缓存。 +- 缺失及未来数量为 `null`,合法零数量为 `0`,不插值、不跨缺失点连线。比较只取相邻期间;一月使用上年十二月作为基期。 +- 默认年度显示最近五年;更早有归档或导入时自动扩展年份。月度允许查询 2000 年至当前年份。 + +## 权限及历史字段 + +本功能是资产存量统计。实时和归档都复用 `filterAssetVehiclesByPermission`:库存对账号公共可见,其余车辆沿用全量、部门或个人权限。公共库存按所查询时点的运营状态判断;历史公司、部门和负责人取快照,角色及个人身份别名取当前认证系统。公司筛选在权限过滤范围内取 `subjectOrg`。 + +不连接今日车辆列表补字段,不因车辆退出、删除或转部门丢失历史。当前来源的部门权限使用部门名称,快照如实保存该名称,不虚构来源没有的部门 ID。未来源系统若增加稳定部门 ID,需版本化升级口径。 + +归档汇总从授权后的同一批逐车数据计算,不返回批次全量计数。明细客户名称复用既有脱敏。历史公司仅出现在有权限的相关历史数据中;顶部公司列表中的历史独有公司标注“历史主体”,不改变现有实时公司接口含义。权限视角切换会由现有认证容器重建页面并取消旧请求。 + +## 存储 + +`bi_asset_history_settings` 保存首次实际启用时间。重复启用保留首次时间。 + +`bi_asset_history_batches` 保存年月、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。年月与口径版本唯一。正式归档为 `COMPLETE`,失败为 `FAILED`,错过为 `MISSED`。 + +`bi_asset_history_vehicles` 保存车辆字符串 ID、原始运营/车辆状态、归档分类、公司、部门、负责人 ID 和冻结车辆 JSON。批次内车辆唯一;不以当前车辆表为外键。JSON 保留车牌、VIN、型号、品牌、业务归属等明细信息,排除无关的异动工作详情和待交车附件信息。 + +采集用 MySQL 同一连接的命名锁防止多实例并发,以只读一致性事务执行原始 SQL,再以独立事务原子发布头表、全部明细和 COMPLETE 状态。窗口外不读源数据。读接口、普通模块导入和只读预览不建表、不采集。读到不存在的表时显示部署提示并保留当前实时点;真实数据库故障返回错误,不伪装成空数据。 + +归档读取会校验车辆唯一性、分类、明细计数和批次计数,不把损坏的明细显示为合法零快照。已提交但提交确认丢失时,重试先检查 COMPLETE,不覆盖已发布数据。 + +## API + +全部接口经现有认证和权限预览中间件,响应 `Cache-Control: private, no-store`。 + +| GET | 参数 | 用途 | +| --- | --- | --- | +| `/api/asset-history/trend` | `mode=monthly\|annual`、`year`、`subject` | 趋势点、相邻差值、可选年份及相关历史公司 | +| `/api/asset-history/live` | `mode`、`subject` | 仅更新当前实时点,不查历史明细 | +| `/api/asset-history/details` | `month=YYYY-MM`、`batchId`、`subject`、`category=all\|operating\|inventory`、`search`、`page`、`pageSize` | 月度明细,默认 50 台,最大 100 台;页面每页 30 台 | + +归档明细入口附带批次 ID,服务端同时核对月份。没有可用快照返回 409,不回退到实时台账。400 为参数错误,401 为未认证,503 为读取或数据质量异常。没有 HTTP 采集、写入和导入接口。 + +## 迁移与启用 + +默认关闭:`ASSET_HISTORY_CAPTURE_ENABLED` 只有精确设为 `1` 才启动采集。迁移不等于启用,迁移本身不会写入首次启用时间。 + +在项目根目录审阅迁移(不连接数据库): + +```sh +node --import tsx scripts/migrate-asset-history.ts +``` + +取得目标数据库迁移授权后,使用受控环境变量配置正确数据库,再显式执行: + +```sh +node --import tsx scripts/migrate-asset-history.ts --apply +``` + +如部分建表失败,修正原因后可重复执行;脚本使用 `CREATE TABLE IF NOT EXISTS`。新建空表不会伪造以前历史。 + +授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `enabled_at`,启用前的时点不会追补。如果在月末 17:00 之后才首次启用,即使仍处于该分钟,也从下一个月末开始采集。 + +按本需求确认日期 2026-10-19,如果在 2026-10-31 17:00 前成功启用,首个计划时点为北京时间 **2026-10-31 17:00**;实际计划以数据库首次启用时间和服务时间计算,不硬编码。 + +保持服务在计划时点在线。停止期间错过的 eligible 月份会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含年月及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。 + +建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。 + +管理员可用以下只读 SQL 检查,不通过普通用户接口暴露全量计数: + +```sql +SELECT enabled_at FROM bi_asset_history_settings WHERE id=1; +SELECT period_month, scheduled_for, captured_at, completed_at, + definition_version, source, status, failure_code, + total_count, operating_count, inventory_count +FROM bi_asset_history_batches ORDER BY period_month DESC LIMIT 12; +SELECT b.period_month, b.total_count, COUNT(v.vehicle_id) AS detail_count +FROM bi_asset_history_batches b +LEFT JOIN bi_asset_history_vehicles v ON v.batch_id=b.id +WHERE b.status='COMPLETE' +GROUP BY b.id, b.period_month, b.total_count; +``` + +## 回退 + +1. 设置 `ASSET_HISTORY_CAPTURE_ENABLED=0` 并重启服务,停止后续采集。 +2. 回退应用代码;已有归档表可原样保留,不影响旧代码。 +3. 若确需撤销表,先备份并取得数据库删除授权;依次删除明细表、批次表、设置表。默认不删除任何归档数据。 + +读接口不自动建表,因此回退后也不会被请求重新创建。再次上线可继续读取既有归档;首次启用时间不自动重置。 + +## 后续逐车导入契约(本次不提供导入写接口) + +提供仅表头模板 `docs/asset-history-import-template.csv`。使用 UTF-8 CSV,每行一辆车,同一文件只允许一个年月、一个口径版本和一个统计时点。 + +必填字段:`period_month`、`scheduled_for`、`captured_at`、`definition_version`、`source_reference`、`vehicle_id`、`operation_status`。空缺车牌/VIN使用空值,不造车辆 ID。ID 均按文本读取。`scheduled_for` 必须是指定年月最后一天北京时间 17:00,`captured_at` 必须来自可信历史来源,不能把导入执行时间当作历史读取时间。 + +其余字段见模板。部门、负责人、主体缺失须显式留空,不能用今日台账补齐;部门/个人权限因此可能看不到未归属的运营车辆,这是数据缺失而非自动扩大授权的理由。展示型号、品牌、原始车辆状态按历史来源保存;历史状态映射复用现有分类函数,不接受退出运营车辆或未知运营状态作为有效存量。归档分类由导入校验程序计算,不直接信任文件中的分类列。 + +状态映射:`1`/`租赁`、`2`/`自营`属于运营;`3`/`可运营`/`在库`、`4`/`待运营`属于库存;`5`/退出运营及未知值拒绝计入存量。`vehicle_status` 为历史原值,待交车或异动不改变上述归档分类。展示用 `status` 应按现有 `mapStatus` 生成。 + +后续导入器必须先提供 dry-run:校验日期和时区、来源、未来时间、车辆唯一性、状态、权限所需字段、计数对平及同年月冲突,再原子发布来源为 IMPORT 的批次和逐车数据。`import_provenance` 至少保存文件校验和、来源引用、导入人、导入时间、校验报告与修订原因;不要在日志保存原始敏感明细。 + +现有 COMPLETE 批次不自动覆盖;相同口径同月冲突应拒绝。修订需新增可追溯版本并明确有效版本切换及跨版本读取策略,不直接改已归档车辆。版本切换功能属于后续导入工作,本次只提供存储空间及契约。 + +仅三个汇总数字的文件不符合逐车导入规范,不能提供个人/部门历史明细。若将来支持汇总导入,需另设权限和“无明细”模型。 + +## 交还业务回溯的本地回填 + +2026-10-19 按用户确认的回溯核对表,将 **2022-06 至 2026-09 共52个月、22,319条车辆月度记录** 发布到本地私有 JSON 文件。原始核查资料截至北京时间 `2026-10-19 14:38:33`。2026-10 未结期不回填,继续走当前实时查询。未向真实数据库建表、插入或更新。 + +这批数据保留 `HANDOVER_REVIEW_V1` 方法、核对表文件名与 SHA-256、原始核查时间、逐车交还单号、业务时间、分类依据及未判状态数量。车辆 ID 使用字符串,候选车辆的车牌/VIN来自交还单原值;未用当前台账补公司、部门、负责人、客户或未知运营状态。 + +它是独立的 **RECONSTRUCTED(历史回溯·估算)** 来源,不发布为 `AUTO` / `IMPORT` 正式快照,也不填写月末实际采集时间。`counts` 为 `null`;运营/库存推算数、未分类数、当前已退出子集和来源信息在 `point.reconstruction` 中返回。总数折线保持空白,页面明确显示“总数待核实”。正式归档始终优先,不覆盖已有归档或归档失败诊断。 + +权限单独收紧:只有当前全量权限且未选择公司时可读取这批估算。它缺少可靠历史主体和个人/部门归属,不能按今天的车辆权限、公司或库存状态补足历史授权。部门、个人及公司筛选继续显示原有正式快照或空白;直接访问回溯明细、篡改批次或公司参数也不能放宽权限。正式归档和实时数据原有权限规则不变。 + +年度仅复用各年12月的回溯记录。相邻两个回溯点可比较推算运营/库存,不计算总数差值;回溯点与实时或正式快照不作差,防止把不同覆盖范围的差异显示为业务增长。回溯明细支持分类、搜索、分页,汇总与逐车记录在加载前对平;未知归属明确标为“历史未提供”。 + +显式本地发布命令(没有数据库依赖,默认只校验): + +```sh +node --import tsx scripts/backfill-asset-reconstruction.ts --input /private/reviewed-reconstruction.json +node --import tsx scripts/backfill-asset-reconstruction.ts --input /private/reviewed-reconstruction.json --output /private/asset-history/reconstruction-v1.json --apply +``` + +数据文件格式为 `ReconstructionData`(`src/server/routes/asset-history/reconstruction.ts`)。必须提供来源及文件校验和,每月车辆唯一、逐车分类与计数对齐、参考月份早于原始核查时间。合法的0与未知总数区分。发布使用私有文件权限和原子创建;相同内容幂等,不同内容拒绝覆盖。文件不能放入公开静态目录或源码目录。 + +启动时显式设置 `ASSET_HISTORY_RECONSTRUCTION_FILE` 为已发布文件的绝对路径;没有该变量时不读取估算数据。不会启用自动采集、初始化快照表或运行其他后台任务。读取结果在进程内缓存;修订时保留原文件和核对表,发布到新文件后更新变量并重启。回退时移除该变量并重启,正式归档及实时数据不受影响。本地验收启动脚本单独注入该变量,回退需同步移除该脚本的配置。 + +本地验收地址仍为 `http://127.0.0.1:3000/asset#assets`。当前仅本地回填,生产未部署、采集未启用;正式存储迁移及生产回填仍需明确指定目标和生产授权。 + +## 验证入口 + +```sh +node --import tsx --test src/server/routes/asset-history/*.test.ts src/modules/assets/history/*.test.ts +npm run lint +npm test +npm run build +``` + +单元、接口和事务故障测试只用模拟仓储与事务数据库替身,不访问真实数据库。开发启动若加载现有 `src/server/index.ts`,会执行其他模块的后台初始化;验收应使用隔离服务、不导入 bootstrap,并阻断所有数据库连接,避免顺带运行现有后台任务。 + +## 本次交付验证(2026-10-19) + +- 新增 32 项针对性测试,全量 452 项通过;`npm run lint`、`npm test`、`npm run build`、`git diff --check` 均通过。 +- 构建仍报告部分现有重依赖分包超过 500 kB 的体积提示;不影响构建,本次不扩展为全站拆包重构。 +- 使用已有 Playwright Chromium(当前会话没有 Browser 插件),在 `http://127.0.0.1:3146/asset#assets` 隔离服务验收;测试服务未导入 bootstrap,数据库入口被阻断。该地址为临时验收环境,非生产部署。 +- 1440×1200、390×844 验证交互,补充 390×1400 截图。13 组浏览器检查通过:页面身份/非空/无框架错误、月度归档及明细、分页搜索、年度、缺失/未来/零值、公司筛选、请求竞态、实时刷新不重读历史、部门及个人权限、存储未部署、503 重试、原有资产分析、手机触摸及无横向溢出。 +- 浏览器无未解释的控制台错误或警告;模拟 503 的错误属于已验证的故障注入。最终验收记录中数据库访问尝试为 0。 +- 参考图的卡片、堆叠柱/折线和选中期间指标保留;历史曲线按真实归档缺失留空,不复制示例数值。顶部现有资产样式保留;手机明细使用卡片而非横向表格。 +- 迁移审阅命令已运行,未执行 `--apply`。事务、锁、故障和提交确认丢失在隔离数据库替身中验证,未向真实 MySQL 实例执行 DDL 或采集。生产迁移和启用需按上述授权步骤执行。 +- 当前交付状态:本地实现完成,未发布,自动采集默认关闭;正式逐车快照导入仅提供契约和模板。用户确认的交还业务回溯另用上述显式本地文件发布入口,不混入正式快照。 +- 回溯回填补充验证:新增8项测试,全量460项通过;类型检查与构建通过。52个月运营/库存与核对表逐月对齐,API明细分类数及分页核对通过。1440×1200、390×844完成月度、年度、明细、搜索分页、触摸、焦点关闭、公司范围及缺失月份验收;无未解释控制台错误、无横向溢出。本地只读保护记录的写入尝试为0,采集及后台任务均关闭。 diff --git a/migrations/20261019_asset_history.sql b/migrations/20261019_asset_history.sql new file mode 100644 index 0000000..dae40be --- /dev/null +++ b/migrations/20261019_asset_history.sql @@ -0,0 +1,43 @@ +-- Explicit migration only; API reads and module imports never create storage. +CREATE TABLE IF NOT EXISTS bi_asset_history_settings ( + id TINYINT NOT NULL PRIMARY KEY, + enabled_at DATETIME(3) NOT NULL, + CONSTRAINT chk_asset_history_singleton CHECK (id = 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS bi_asset_history_batches ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + period_month CHAR(7) NOT NULL, + definition_version VARCHAR(32) NOT NULL, + scheduled_for DATETIME(3) NOT NULL, + captured_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + timezone VARCHAR(32) NOT NULL DEFAULT 'Asia/Shanghai', + source ENUM('AUTO','IMPORT') NOT NULL, + status ENUM('COMPLETE','FAILED','MISSED') NOT NULL, + failure_code VARCHAR(64) NULL, + total_count INT UNSIGNED NOT NULL DEFAULT 0, + operating_count INT UNSIGNED NOT NULL DEFAULT 0, + inventory_count INT UNSIGNED NOT NULL DEFAULT 0, + import_provenance JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_asset_history_period (period_month, definition_version), + KEY idx_asset_history_status (status, period_month), + CONSTRAINT chk_asset_history_counts CHECK (total_count = operating_count + inventory_count) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS bi_asset_history_vehicles ( + batch_id VARCHAR(36) NOT NULL, + vehicle_id VARCHAR(64) NOT NULL, + operation_status VARCHAR(50) NOT NULL, + vehicle_status VARCHAR(50) NULL, + disposition ENUM('operating','inventory') NOT NULL, + subject_org VARCHAR(500) NULL, + department_name VARCHAR(500) NULL, + manager_id VARCHAR(64) NULL, + frozen_vehicle JSON NOT NULL, + PRIMARY KEY (batch_id, vehicle_id), + KEY idx_asset_history_subject (batch_id, subject_org(128)), + KEY idx_asset_history_manager (batch_id, manager_id), + CONSTRAINT fk_asset_history_batch FOREIGN KEY (batch_id) REFERENCES bi_asset_history_batches (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/backfill-asset-reconstruction.ts b/scripts/backfill-asset-reconstruction.ts new file mode 100644 index 0000000..c525b77 --- /dev/null +++ b/scripts/backfill-asset-reconstruction.ts @@ -0,0 +1,20 @@ +// Does not load dotenv, database pools, application bootstrap or background jobs. +import { readFile } from 'node:fs/promises'; +import { resolve, relative, sep } from 'node:path'; +import { publishReconstruction, validateReconstruction } from '../src/server/routes/asset-history/reconstruction.js'; +const args = process.argv.slice(2); +const inputIndex = args.indexOf('--input'), outputIndex = args.indexOf('--output'); +if (inputIndex < 0 || !args[inputIndex + 1]) throw new Error('需要 --input 指定已确认的回溯文件'); +const data = validateReconstruction(JSON.parse(await readFile(resolve(args[inputIndex + 1]), 'utf8'))); +console.log(JSON.stringify({ action: args.includes('--apply') ? 'local-file-publication' : 'dry-run', months: data.periods.length, + firstMonth: data.periods[0]?.month, lastMonth: data.periods.at(-1)?.month, sourceAsOf: data.sourceAsOf, + workbookSha256: data.workbookSha256, historicalTotal: null, databaseWrites: 0 })); +if (args.includes('--apply')) { + if (outputIndex < 0 || !args[outputIndex + 1]) throw new Error('需要 --output 指定本地私有存储位置'); + const output = resolve(args[outputIndex + 1]); + for (const folder of ['public', 'dist', 'src']) { + const within = relative(resolve(folder), output); + if (!within || (!within.startsWith(`..${sep}`) && within !== '..' && !within.startsWith(sep))) throw new Error('回溯文件必须保存于源码及公开静态目录之外'); + } + console.log(await publishReconstruction(output, data)); +} diff --git a/scripts/migrate-asset-history.ts b/scripts/migrate-asset-history.ts new file mode 100644 index 0000000..22850f5 --- /dev/null +++ b/scripts/migrate-asset-history.ts @@ -0,0 +1,12 @@ +import { readFile } from 'node:fs/promises'; +const sql = await readFile(new URL('../migrations/20261019_asset_history.sql', import.meta.url), 'utf8'); +if (!process.argv.includes('--apply')) { + console.log(sql); + console.log('Review only. Pass --apply only after approving the configured database migration.'); +} else { + const { default: pool } = await import('../src/server/db.js'); + try { + for (const statement of sql.split(/;\s*(?:\n|$)/).filter(s => s.trim())) await pool.query(statement); + console.log('Asset history schema ready. Capture is still disabled unless ASSET_HISTORY_CAPTURE_ENABLED=1.'); + } finally { await pool.end(); } +} diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 36fbdb9..374c097 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -102,6 +102,10 @@ export default function AssetsModule() { // 所属公司(归属主体)筛选 —— 影响全页聚合 const [selectedSubject, setSelectedSubject] = useState(null); const [subjects, setSubjects] = useState([]); + const [historySubjects, setHistorySubjects] = useState([]); + 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 [subjectSearch, setSubjectSearch] = useState(''); const subjectDropdownRef = useRef(null); @@ -485,7 +489,7 @@ export default function AssetsModule() { loading={loading} selectedSubject={selectedSubject} setSelectedSubject={setSelectedSubject} - subjects={subjects} + subjects={subjectOptions} subjectDropdownOpen={subjectDropdownOpen} setSubjectDropdownOpen={setSubjectDropdownOpen} subjectSearch={subjectSearch} @@ -504,6 +508,8 @@ export default function AssetsModule() { {tabReady && activeTab === 'overview' && ( {s.name} - {s.total} 台 + {s.historicalOnly ? '历史主体' : <>{s.total} 台 · - 运营 {s.operating} + 运营 {s.operating}} ); diff --git a/src/modules/assets/components/OverviewView.tsx b/src/modules/assets/components/OverviewView.tsx index a9f2189..276bc67 100644 --- a/src/modules/assets/components/OverviewView.tsx +++ b/src/modules/assets/components/OverviewView.tsx @@ -2,6 +2,7 @@ import { AssetSummarySection } from './overview/AssetSummarySection'; import { FlowStatisticsCard } from './overview/FlowStatisticsCard'; import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection'; import { SummaryMetrics } from './overview/SummaryMetrics'; +import { AssetHistorySection } from '../history/AssetHistorySection'; import type { OverviewViewProps } from './overview/types'; export function OverviewView(props: OverviewViewProps) { @@ -15,6 +16,8 @@ export function OverviewView(props: OverviewViewProps) { setVehicleSelection={props.setVehicleSelection} /> + + -
+
运营概览
-
+
运营率
-
{operatingRate.toFixed(1)}%
+
{operatingRate.toFixed(1)}%
-
+
库存率
-
{inventoryRate.toFixed(1)}%
+
{inventoryRate.toFixed(1)}%
diff --git a/src/modules/assets/components/overview/types.ts b/src/modules/assets/components/overview/types.ts index a5d4f7b..e1ff9ba 100644 --- a/src/modules/assets/components/overview/types.ts +++ b/src/modules/assets/components/overview/types.ts @@ -67,6 +67,8 @@ export type OverviewViewProps = SummaryMetricsProps & AssetSummarySectionProps & InventoryStatisticsSectionProps & { + selectedSubject: string | null; + onHistorySubjects: (names: string[]) => void; allTypesExpanded: boolean; toggleAllAssetTypes: () => void; }; diff --git a/src/modules/assets/history/AssetHistorySection.tsx b/src/modules/assets/history/AssetHistorySection.tsx new file mode 100644 index 0000000..7f45019 --- /dev/null +++ b/src/modules/assets/history/AssetHistorySection.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState, type MouseEvent } from 'react'; +import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts'; +import { ChevronRight, RefreshCw } from 'lucide-react'; +import { HISTORY_STATE_LABELS, historyTime, shanghaiMonth, type HistoryMode, type HistoryPoint, type HistoryTrend } from '../../../shared/assets/history'; +import { fetchHistoryLive, fetchHistoryTrend } from './api'; +import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation'; +import { HistoryDetailsDialog } from './HistoryDetailsDialog'; +import { HistorySelect } from './HistorySelect'; +import './history.css'; + +export function AssetHistorySection({ subject, onSubjects }: { subject: string | null; onSubjects?: (names: string[]) => void }) { + const [mode, setMode] = useState('monthly'); + const [year, setYear] = useState(() => Number(shanghaiMonth(new Date()).slice(0, 4))); + const [data, setData] = useState(null); + const [selected, setSelected] = useState(null); + const [details, setDetails] = useState(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) => { + event.stopPropagation(); + const item = chart[entry.originalDataIndex]; + if (item) setSelected(item.period); + }; + return
+

资产历史趋势

+
{(['annual', 'monthly'] as const).map(value => )}
+
+
+ {mode === 'monthly' && ({ value: String(y), label: `${y}年` }))} onChange={value => { setYear(Number(value)); setDetails(null); }} />} + {data && { 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] }))} />} + 月末 17:00 + +
+
+ {loading && !data ?

正在加载资产历史趋势…

: !data ?

{error || '暂无可用数据'}

: <> + {error &&

{error} · 保留上次查询结果

} +

数量(台)

+
+ + { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}> + + + { + const item = payload?.[0]?.payload as HistoryPoint | undefined; + const values = item && displayHistoryCounts(item); + return active && item ?
{item.label} · {HISTORY_STATE_LABELS[item.state]}{values ?

总数 {values.total ?? '待核实'} · 运营 {values.operating} · 库存 {values.inventory}

:

暂无数量

}
: null; + }} /> + + + +
+
+
+ + {point &&
+
{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}{HISTORY_STATE_LABELS[point.state]}{point.asOf && }
+
{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) =>
{label}(台){displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}
)}
+ {hasComparison ?
{point.comparisonLabel}
{(['total', 'inventory', 'operating'] as const).map(key => { + const difference = displayHistoryDifference(point, key); + return 0 ? 'asset-history-increase' : difference != null && difference < 0 ? 'asset-history-decrease' : ''}>{difference != null ? signedCount(difference) : '—'}; + })}
:

{point.comparisonLabel}

} +
} + {point && mode === 'monthly' && } +

历史库存状态快照缺失,核对中,暂不展示。

+ } + {details && } +
; +} diff --git a/src/modules/assets/history/HistoryDetailsDialog.tsx b/src/modules/assets/history/HistoryDetailsDialog.tsx new file mode 100644 index 0000000..6ffb14a --- /dev/null +++ b/src/modules/assets/history/HistoryDetailsDialog.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X, Search, ChevronLeft, ChevronRight } from 'lucide-react'; +import { useDialogFocus } from '../components/useDialogFocus'; +import { HISTORY_STATE_LABELS, historyTime, type HistoryCategory, type HistoryDetails, type HistoryPoint } from '../../../shared/assets/history'; +import { fetchHistoryDetails } from './api'; +import { displayHistoryCounts } from './presentation'; + +export function HistoryDetailsDialog({ point, subject, close }: { point: HistoryPoint; subject: string | null; close: () => void }) { + const ref = useRef(null); + const [category, setCategory] = useState('all'); + const [search, setSearch] = useState(''); + const [query, setQuery] = useState(''); + const [page, setPage] = useState(1); + const [data, setData] = useState(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(
{ if (e.target === e.currentTarget) close(); }}> +
+

{point.month.replace('-', '年')}月资产明细

+

{subject || '全部公司'} · {HISTORY_STATE_LABELS[point.state]} · {point.reconstruction ? '参考时点' : point.state === 'ARCHIVED' ? '快照' : '截至'} {historyTime(point.reconstruction ? point.scheduledFor : data?.point.asOf || point.asOf)}

+
+
+ + +
+
+ {loading ?

正在加载月度明细…

: error ?

{error}

: data && <> +

{data.point.reconstruction ? '以下为核对表中的交还记录推算车辆,归属未知项留空。历史总数待核实,不能将两类相加当作历史资产总数。' : data.point.state === 'ARCHIVED' ? '以下为当时归档的车辆及归属信息。' : '以下为最新实时明细,数量可能随业务更新。'} 当前范围:总数 {displayHistoryCounts(data.point)?.total ?? '待核实'} · 运营 {displayHistoryCounts(data.point)?.operating} · 库存 {displayHistoryCounts(data.point)?.inventory}

+ {data.items.length ? <>
+ {data.items.map(item => + + )}
车辆分类公司 / 部门负责人 / 客户
{item.plateNumber || '未登记车牌'}{item.model}VIN:{item.vin || '未提供'}{item.reconstructionBasis && 推算依据:{item.reconstructionBasis}}{item.disposition === 'operating' ? '运营' : '库存'}{item.subjectOrg || '未提供'}{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}{item.contractNo || ''}
+
{data.items.map(item =>
+
{item.plateNumber || '未登记车牌'}{item.disposition === 'operating' ? '运营' : '库存'}
+

{item.model}

VIN:{item.vin || '未提供'} + {item.reconstructionBasis && 推算依据:{item.reconstructionBasis}} +
公司 / 部门
{item.subjectOrg || '未提供'}{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}
+
负责人 / 客户
{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}
+ {item.contractNo && 合同:{item.contractNo}} +
)}
:

当前条件下没有车辆

} + } +
+
{data ? `${point.reconstruction ? '推算' : '筛选'}结果 ${data.total} 台 · 第 ${page} / ${pages} 页` : '每页 30 台'}
+ +
+
+
, document.body); +} diff --git a/src/modules/assets/history/HistorySelect.tsx b/src/modules/assets/history/HistorySelect.tsx new file mode 100644 index 0000000..040fb9b --- /dev/null +++ b/src/modules/assets/history/HistorySelect.tsx @@ -0,0 +1,31 @@ +import { Select } from 'radix-ui'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; + +interface HistorySelectProps { + label: string; + value: string; + options: { value: string; label: string; detail?: string }[]; + onChange: (value: string) => void; +} + +export function HistorySelect({ label, value, options, onChange }: HistorySelectProps) { + return + + + + + + + + + {options.map(option => + {option.label} + {option.detail && {option.detail}} + + )} + + + + + ; +} diff --git a/src/modules/assets/history/api.ts b/src/modules/assets/history/api.ts new file mode 100644 index 0000000..8c1766e --- /dev/null +++ b/src/modules/assets/history/api.ts @@ -0,0 +1,20 @@ +import { fetchAuthenticated } from '../../../auth/api-client'; +import type { HistoryMode, HistoryTrend, HistoryPoint, HistoryDetails, HistoryCategory } from '../../../shared/assets/history'; + +async function get(path: string, params: Record, signal: AbortSignal): Promise { + 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('trend', { mode, year, subject }, signal); +} +export function fetchHistoryLive(mode: HistoryMode, subject: string | null, signal: AbortSignal) { + return get('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('details', { month, batchId, subject, category, search, page, pageSize: 30 }, signal); +} diff --git a/src/modules/assets/history/history.css b/src/modules/assets/history/history.css new file mode 100644 index 0000000..7bc74fe --- /dev/null +++ b/src/modules/assets/history/history.css @@ -0,0 +1,131 @@ +.asset-history { --history-blue:#1559e8; margin:8px 0 16px; padding:16px; border:1px solid #e6edf5; border-radius:20px; background:#fff; color:#142b4e; box-shadow:0 5px 24px rgb(22 53 90 / 3%); } +.asset-history button,.asset-history-dialog button { cursor:pointer; } +.asset-history button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; } +.asset-history button:focus-visible,.asset-history select:focus-visible,.asset-history-dialog :is(button,input,select):focus-visible { outline:2px solid #2563eb; outline-offset:3px; } +.asset-history-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; } +.asset-history-header h2 { margin:0; font-size:20px; font-weight:750; letter-spacing:-.5px; } +.asset-history-modes { display:flex; gap:2px; background:#eef3fa; border:1px solid #e7edf6; border-radius:11px; padding:3px; } +.asset-history-modes button { padding:7px 20px; min-height:34px; border-radius:8px; font-size:13px; font-weight:600; transition:background .15s,color .15s,box-shadow .15s; color:#64748b; } +.asset-history-modes button[aria-pressed=true] { background:#fff; color:#1559e8; box-shadow:0 1px 4px #193c6920, inset 0 0 0 1px #dce7f5; } +.asset-history-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0 4px; font-size:12px; color:#64748b; } +.asset-history-toolbar>span { margin-left:auto; } +.asset-history-toolbar .asset-history-annual-note { margin-left:0; margin-right:auto; } +.asset-history-refresh { display:grid; place-items:center; padding:8px; border-radius:8px; color:#7c8da5; transition:background .15s,color .15s; } +.asset-history-warning { margin:8px 0; font-size:12px; line-height:1.7; color:#74613a; background:#fff9ec; padding:9px 12px; border-radius:9px; } +.asset-history-warning button,.asset-history-empty button { color:#1559e8; text-decoration:underline; padding:6px; } +.asset-history-unit { font-size:12px; color:#64748b; margin:6px 0 0; } +.asset-history-chart { height:200px; width:100%; min-width:0; } +.asset-history-tooltip { background:#fff; border:1px solid #dce5ef; border-radius:10px; padding:10px 12px; box-shadow:0 4px 16px #142b4e15; font-size:12px; } +.asset-history-tooltip p { margin-top:4px; } +.asset-history-legend { display:flex; justify-content:center; flex-wrap:wrap; gap:22px; color:#64748b; font-size:12px; margin:6px 0; } +.asset-history-legend span { display:flex; align-items:center; gap:7px; } +.asset-history-legend i { display:inline-block; height:11px; width:11px; border-radius:2px; } +.asset-history-legend .operating { background:#347df5; }.asset-history-legend .inventory { background:#9ed1fa; } +.asset-history-legend .total { width:20px; height:2px; background:#132e57; } +.asset-history-selected { margin-top:8px; display:grid; grid-template-columns:90px minmax(0,1fr); align-items:center; gap:0 10px; padding:10px 14px; background:linear-gradient(115deg,#f3f7fe,#edf5ff); border:1px solid #e7effb; border-radius:12px; } +.asset-history-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; } +.asset-history-selected-heading>strong { color:#1559e8; font-size:17px; font-weight:750; } +.asset-history-selected-heading>span,.asset-history-selected-heading>time { color:#64748b; font-size:11px; } +.asset-history-counts { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); } +.asset-history-counts>div { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; min-width:0; border-right:1px solid #d4e2f4; } +.asset-history-counts>div:last-child { border:0; } +.asset-history-counts>div>span { color:#64748b; font-size:12px; } +.asset-history-counts strong { font-size:23px; font-weight:750; line-height:1.2; font-variant-numeric:tabular-nums; } +.asset-history-increase { color:#bf2f30; }.asset-history-decrease { color:#14864a; } +.asset-history-selected>p { grid-column:1 / -1; color:#64748b; font-size:11px; text-align:center; padding-top:6px; } +.asset-history-detail-button { display:flex; width:100%; align-items:center; justify-content:space-between; min-height:44px; padding:8px 12px; margin-top:8px; border:1px solid #dce8f8; border-radius:10px; background:#f7faff; color:#18385d; font-size:14px; font-weight:600; } +.asset-history-footnote { display:flex; align-items:center; flex-wrap:wrap; gap:6px; color:#64748b; font-size:11px; margin-top:8px; } +.asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:#64748b; } +.asset-history-overlay { position:fixed; inset:0; z-index:120; background:#0c1e3b66; display:flex; justify-content:center; align-items:center; padding:24px; } +.asset-history-dialog { display:flex; flex-direction:column; width:min(980px,100%); max-height:88dvh; border-radius:18px; background:white; color:#183454; box-shadow:0 24px 64px #142b4e30; overflow:hidden; outline:none; } +.asset-history-dialog>header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:20px 22px; border-bottom:1px solid #e6edf5; } +.asset-history-dialog h2 { font-size:18px; font-weight:700; } +.asset-history-dialog header p { font-size:12px; line-height:1.7; margin-top:5px; color:#64748b; } +.asset-history-dialog header button { padding:8px; flex-shrink:0; } +.asset-history-detail-tools { display:flex; flex-wrap:wrap; gap:12px; padding:14px 22px; border-bottom:1px solid #e6edf5; } +.asset-history-detail-tools label { display:flex; align-items:center; gap:8px; font-size:12px; color:#64748b; } +.asset-history-detail-tools select { border:1px solid #dce5f0; border-radius:7px; padding:8px; color:#183454; background:white; } +.asset-history-search { border:1px solid #dce5f0; border-radius:7px; padding:6px 10px; flex:1; min-width:170px; } +.asset-history-search input { width:100%; min-width:0; padding:3px 0; outline:none; color:#183454; } +.asset-history-detail-body { overflow:auto; flex:1; min-height:100px; } +.asset-history-detail-note { font-size:12px; line-height:1.8; padding:12px 22px; color:#64748b; background:#f8fbff; } +.asset-history-detail-table { overflow:auto; } +.asset-history-detail-table table { width:100%; border-collapse:collapse; min-width:650px; font-size:12px; } +.asset-history-detail-table th { text-align:left; background:#f7faff; color:#64748b; font-weight:500; padding:12px 20px; } +.asset-history-detail-table td { vertical-align:top; padding:14px 20px; border-bottom:1px solid #edf2f8; max-width:280px; overflow-wrap:anywhere; } +.asset-history-detail-table td strong { font-size:13px; font-weight:650; }.asset-history-detail-table small { display:block; font-size:11px; color:#64748b; margin-top:5px; } +.asset-history-mobile-vehicles { display:none; } +.asset-history-dialog footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 22px; border-top:1px solid #e6edf5; color:#64748b; font-size:12px; } +.asset-history-dialog footer>div { display:flex; gap:8px; }.asset-history-dialog footer button { padding:9px; border:1px solid #dce5f0; border-radius:7px; } +@media(max-width:640px) { + .asset-history { padding:12px; border-radius:16px; } + .asset-history-header h2 { font-size:18px; }.asset-history-modes button { padding:7px 15px; min-height:38px; } + .asset-history-toolbar { gap:6px; }.asset-history-toolbar>span { font-size:10px; } + .asset-history-chart { height:170px; } + .asset-history-selected { grid-template-columns:72px minmax(0,1fr); gap:0 6px; padding:8px 10px; }.asset-history-selected-heading { gap:2px; }.asset-history-selected-heading>span,.asset-history-selected-heading>time { font-size:10px; } + .asset-history-counts strong { font-size:21px; }.asset-history-counts>div>span { font-size:11px; } + .asset-history-overlay { padding:10px; }.asset-history-dialog { max-height:94dvh; border-radius:14px; } + .asset-history-dialog>header { padding:14px; }.asset-history-dialog h2 { font-size:16px; }.asset-history-detail-tools { padding:12px 14px; } + .asset-history-detail-note { padding:10px 14px; }.asset-history-dialog :is(input,select) { font-size:16px; }.asset-history-dialog footer { padding:12px 14px; } + .asset-history-dialog :is(header,footer) button { min-height:44px; min-width:44px; } + .asset-history-detail-table { display:none; } + .asset-history-mobile-vehicles { display:block; padding:0 14px; } + .asset-history-mobile-vehicles article { padding:16px 0; border-bottom:1px solid #e6edf5; overflow-wrap:anywhere; } + .asset-history-mobile-vehicle-heading { display:flex; justify-content:space-between; align-items:center; gap:10px; } + .asset-history-mobile-vehicle-heading strong { font-size:14px; font-weight:700; }.asset-history-mobile-vehicle-heading span { font-size:11px; color:#1559e8; background:#edf5ff; border-radius:5px; padding:3px 8px; } + .asset-history-mobile-vehicles p { margin-top:6px; font-size:12px; color:#475569; }.asset-history-mobile-vehicles small { display:block; font-size:11px; color:#64748b; margin-top:5px; } + .asset-history-mobile-vehicles dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:12px 0; font-size:12px; } + .asset-history-mobile-vehicles dt { font-size:11px; color:#64748b; margin-bottom:5px; }.asset-history-mobile-vehicles dd { margin:0; } +} + +@media(min-width:900px) { + .asset-history-controls { display:flex; align-items:center; flex-wrap:wrap; gap:8px 24px; } + .asset-history-header { flex:1; gap:16px; } + .asset-history-toolbar { margin:0; } + .asset-history-selected { display:grid; grid-template-columns:minmax(150px,1fr) 3fr; align-items:center; gap:0 18px; } + .asset-history-selected-heading { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:2px 12px; margin:0; } + .asset-history-selected-heading>strong { grid-row:span 2; } + .asset-history-selected-heading>span,.asset-history-selected-heading>time { grid-column:2; } + .asset-history-selected>p { grid-column:1 / -1; } +} +@media(max-width:640px) { + .asset-history-modes button,.asset-history-refresh { min-height:44px; } + .asset-history-refresh { min-width:44px; } + .asset-history-toolbar { gap:4px; } +} + +.asset-history-comparison { grid-column:1 / -1; display:grid; grid-template-columns:90px minmax(0,1fr); gap:10px; align-items:center; margin-top:6px; padding-top:6px; border-top:1px solid #d4e2f4; font-size:11px; color:#64748b; } +.asset-history-comparison>div { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); text-align:center; font-size:13px; font-weight:600; } +@media(min-width:900px) { + .asset-history-comparison { grid-template-columns:minmax(150px,1fr) 3fr; gap:18px; } +} +@media(max-width:640px) { + .asset-history-comparison { grid-template-columns:72px minmax(0,1fr); gap:6px; } +} + +.asset-history-select { display:inline-flex; align-items:center; justify-content:space-between; gap:12px; min-width:76px; min-height:36px; padding:7px 11px; border:1px solid #dce5f1; border-radius:9px; background:#fff; color:#254365; font-size:13px; font-weight:550; box-shadow:0 1px 2px #173c6310; transition:border-color .15s,box-shadow .15s; } +.asset-history-select[data-state=open] { border-color:#8fb4f2; box-shadow:0 0 0 3px #dbeaff80; } +.asset-history-select-icon { color:#8b9db5; display:flex; transition:transform .15s; } +.asset-history-select[data-state=open] .asset-history-select-icon { transform:rotate(180deg); } +.asset-history-select-menu { z-index:160; min-width:max(132px,var(--radix-select-trigger-width)); max-height:min(320px,var(--radix-select-content-available-height)); max-width:calc(100vw - 20px); overflow:hidden; padding:4px; border:1px solid #e0e8f4; border-radius:12px; background:#fff; color:#254365; box-shadow:0 12px 32px #18365420,0 2px 6px #18365408; } +.asset-history-select-options { padding:2px; } +.asset-history-select-option { position:relative; display:flex; align-items:center; gap:16px; min-height:36px; padding:8px 30px 8px 10px; border-radius:7px; outline:none; font-size:13px; cursor:pointer; user-select:none; } +.asset-history-select-option[data-state=checked] { color:#1559e8; background:#f0f6ff; font-weight:600; } +.asset-history-select-option[data-highlighted] { background:#eaf2ff; color:#1559e8; } +.asset-history-option-detail { margin-left:auto; color:#7b8da5; font-size:11px; font-weight:400; } +.asset-history-option-check { position:absolute; right:9px; display:flex; color:#2563eb; } +.asset-history-select-scroll { display:flex; align-items:center; justify-content:center; height:24px; color:#7b8da5; } +.asset-history-detail-button>svg { padding:3px; width:24px; height:24px; border-radius:7px; background:#eaf2ff; color:#527eb7; } +@media(hover:hover) { + .asset-history-modes button:not([aria-pressed=true]):hover { color:#1559e8; background:#e5edf9; } + .asset-history-select:hover { border-color:#adc5e8; } + .asset-history-refresh:hover { color:#2563eb; background:#eef5ff; } + .asset-history-detail-button:hover { background:#eef5ff; border-color:#bcd3f3; } +} +@media(max-width:640px) { + .asset-history-select { min-height:44px; padding:8px 10px; font-size:12px; gap:10px; } + .asset-history-select-option { min-height:44px; } +} +@media(prefers-reduced-motion:reduce) { + .asset-history-modes button,.asset-history-select,.asset-history-select-icon,.asset-history-refresh { transition:none; } +} diff --git a/src/modules/assets/history/presentation.test.ts b/src/modules/assets/history/presentation.test.ts new file mode 100644 index 0000000..0f69603 --- /dev/null +++ b/src/modules/assets/history/presentation.test.ts @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation.js'; +import { makePoint } from '../../../server/routes/asset-history/model.js'; +import { testBatch } from '../../../server/routes/asset-history/fixtures.js'; + +test('selection prefers live then latest archive; missing is never a numerical zero',()=>{ + const now=new Date('2026-10-19T00:00Z'); + const archived=makePoint('2026-09',now,testBatch('2026-09'),[]); + const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now); + assert.equal(defaultHistoryPeriod([missing,archived,live]),'2026-10'); + assert.equal(defaultHistoryPeriod([missing,archived]),'2026-09'); + assert.equal(archived.counts!.total,0);assert.equal(missing.counts,null); + assert.equal(signedCount(3),'+3');assert.equal(signedCount(-3),'-3'); +}); +test('estimated counts keep total unknown and live refresh cannot compare against a reconstructed cohort',()=>{ + const now=new Date('2026-10-19T00:00Z'); + const estimate={...makePoint('2026-09',now),state:'RECONSTRUCTED' as const,source:'RECONSTRUCTED' as const, + reconstruction:{operating:3,inventory:0,unclassified:2,currentlyExited:1,sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'核对表',workbookSha256:'a'.repeat(64),difference:{operating:1,inventory:-1}}}; + assert.deepEqual(displayHistoryCounts(estimate),{total:null,operating:3,inventory:0});assert.equal(displayHistoryDifference(estimate,'total'),null); + assert.equal(displayHistoryDifference(estimate,'inventory'),-1);assert.equal(defaultHistoryPeriod([estimate]),'2026-09'); + const live=makePoint('2026-10',now);live.counts={total:5,operating:2,inventory:3}; + assert.equal(updateLivePoint([estimate,live],live)[1].difference,null); +}); +test('live refresh preserves January comparison with December outside visible year',()=>{ + const now=new Date('2026-01-19T00:00Z');const initial=makePoint('2026-01',now); + initial.counts={total:10,operating:6,inventory:4};initial.difference={total:2,operating:1,inventory:1}; + const refreshed={...initial,counts:{total:12,operating:7,inventory:5},difference:null}; + const result=updateLivePoint([initial],refreshed)[0]; + assert.deepEqual(result.difference,{total:4,operating:2,inventory:2});assert.equal(result.comparisonLabel,'截至当前较上月末'); +}); +test('live refresh cannot mutate an archive, bridge a hole or overwrite a different period',()=>{ + const now=new Date('2026-10-19T00:00Z');const archive=makePoint('2026-09',now,testBatch('2026-09'),[]); + const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now); + assert.equal(updateLivePoint([archive],live)[0],archive); + assert.equal(updateLivePoint([missing,live],{...live,counts:{total:9,operating:4,inventory:5}})[1].difference,null); +}); diff --git a/src/modules/assets/history/presentation.ts b/src/modules/assets/history/presentation.ts new file mode 100644 index 0000000..626a26b --- /dev/null +++ b/src/modules/assets/history/presentation.ts @@ -0,0 +1,29 @@ +import type { HistoryPoint } from '../../../shared/assets/history'; + +/** Accessible period buttons and chart clicks share one selection model. */ +export function defaultHistoryPeriod(points: HistoryPoint[]): string | null { + return points.find(p => p.state === 'LIVE')?.period || [...points].reverse().find(p => p.state === 'ARCHIVED' || p.state === 'RECONSTRUCTED')?.period || points.at(-1)?.period || null; +} +export function updateLivePoint(points: HistoryPoint[], live: HistoryPoint): HistoryPoint[] { + return points.map((point, index) => { + if (point.state !== 'LIVE' || point.period !== live.period) return point; + if (points[index - 1]?.state === 'RECONSTRUCTED') return { ...live, difference: null, comparisonLabel: '推算与实时或正式快照口径不同,暂无可比数据' }; + const baseline = points[index - 1]?.counts || (point.counts && point.difference ? { + total: point.counts.total - point.difference.total, + operating: point.counts.operating - point.difference.operating, + inventory: point.counts.inventory - point.difference.inventory, + } : null); + return { ...live, difference: baseline && live.counts ? { + total: live.counts.total - baseline.total, + operating: live.counts.operating - baseline.operating, + inventory: live.counts.inventory - baseline.inventory, + } : null, comparisonLabel: baseline ? `截至当前较${live.period.length === 4 ? '上年末' : '上月末'}` : '暂无可比数据' }; + }); +} +export const signedCount = (value: number) => value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString(); +export function displayHistoryCounts(point: HistoryPoint) { + return point.reconstruction ? { total: null, operating: point.reconstruction.operating, inventory: point.reconstruction.inventory } : point.counts; +} +export function displayHistoryDifference(point: HistoryPoint, key: 'total' | 'operating' | 'inventory'): number | null { + return point.reconstruction ? key === 'total' ? null : point.reconstruction.difference?.[key] ?? null : point.difference?.[key] ?? null; +} diff --git a/src/server/app.ts b/src/server/app.ts index ba801d5..cb208cd 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -14,6 +14,7 @@ import vehicleHeatmapRouter from './routes/vehicle-heatmap.js'; import vehiclesRouter from './routes/vehicles.js'; import assetAssistantRouter from './routes/asset-assistant/index.js'; import assetFaultRouter from './routes/asset-faults/index.js'; +import assetHistoryRouter from './routes/asset-history/index.js'; /** * 创建 HTTP 应用,但不监听端口、不访问数据库,也不启动后台任务。 @@ -31,6 +32,7 @@ export function createApp(): Hono { app.use('/api/*', createPreviewMiddleware()); app.route('/api/vehicles', vehiclesRouter); + app.route('/api/asset-history', assetHistoryRouter); app.route('/api/asset-faults', assetFaultRouter); app.route('/api/asset-assistant', assetAssistantRouter); app.route('/api/mileage', mileageRouter); diff --git a/src/server/auth/permissions.ts b/src/server/auth/permissions.ts index 695779e..b3f61a3 100644 --- a/src/server/auth/permissions.ts +++ b/src/server/auth/permissions.ts @@ -3,7 +3,7 @@ import type { Vehicle } from '../types.js'; import { isInventoryVehicle } from '../routes/vehicles/model.js'; /** 当前库存为公共资产;非库存车辆继续遵守本人/部门权限。历史业务使用原权限过滤。 */ -export function filterAssetVehiclesByPermission(items: Vehicle[], user: AuthUser): Vehicle[] { +export function filterAssetVehiclesByPermission(items: T[], user: AuthUser): T[] { if (user.permissionLevel === 'full') return items; const authorized = new Set(filterByPermission(items, user)); return items.filter(vehicle => isInventoryVehicle(vehicle) || authorized.has(vehicle)); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 1293e6c..b92bff8 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -1,5 +1,6 @@ import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import { startMileageBackgroundJobs } from './routes/mileage/index.js'; +import { startAssetHistoryScheduler } from './routes/asset-history/runtime.js'; /** 启动只应在服务进程中运行的数据库准备和定时任务。 */ export function startBackgroundServices(): void { @@ -7,4 +8,5 @@ export function startBackgroundServices(): void { console.error('scheduling bootstrap error:', error); }); startMileageBackgroundJobs(); + startAssetHistoryScheduler(); } diff --git a/src/server/routes/asset-history/fixtures.ts b/src/server/routes/asset-history/fixtures.ts new file mode 100644 index 0000000..16118d8 --- /dev/null +++ b/src/server/routes/asset-history/fixtures.ts @@ -0,0 +1,32 @@ +// Synthetic fixtures imported only by tests and the explicitly isolated QA harness. +import type { Vehicle, VehicleRow } from '../../types.js'; +import { counts, freezeLive, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js'; +import { monthCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js'; +import type { HistoryReader } from './store.js'; + +export function testVehicle(id: string, overrides: Partial = {}): Vehicle { + 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 { + 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(); + 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 = {}) { + const batch = testBatch(month, vehicles, overrides); this.catalog.push(batch); this.rows.set(batch.id, freezeLive(vehicles)); return batch; + } +} diff --git a/src/server/routes/asset-history/index.ts b/src/server/routes/asset-history/index.ts new file mode 100644 index 0000000..bae11cf --- /dev/null +++ b/src/server/routes/asset-history/index.ts @@ -0,0 +1,30 @@ +import { Hono } from 'hono'; +import type { AuthUser } from '../../auth/types.js'; +import pool from '../../db.js'; +import { vehicleRepository } from '../vehicles/repository.js'; +import { MysqlHistoryStore } from './store.js'; +import { HistoryService, HistoryInputError, HistoryPeriodUnavailable } from './service.js'; + +export const historyStore = new MysqlHistoryStore(pool); +// Published routes expose live data and verified snapshots only. +// The retired handover reconstruction remains an offline audit artifact. +export const historyService = new HistoryService(historyStore, () => vehicleRepository.getVehicles()); +export function createHistoryRouter(service: Pick = historyService) { + 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(); diff --git a/src/server/routes/asset-history/model.test.ts b/src/server/routes/asset-history/model.test.ts new file mode 100644 index 0000000..8c0b0f8 --- /dev/null +++ b/src/server/routes/asset-history/model.test.ts @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { monthCutoff, nextCapture, shanghaiMonth, shanghaiTimestamp } from '../../../shared/assets/history.js'; +import { counts, freezeRows, makePoint, freezeLive } from './model.js'; +import { testBatch, testRow, testVehicle } from './fixtures.js'; + +test('Shanghai month-end cutoff covers leap years, short/long months and cross-year boundaries', () => { + for (const [month, expected] of [['2024-02','2024-02-29'], ['2026-02','2026-02-28'], ['2026-04','2026-04-30'], ['2026-10','2026-10-31'], ['2026-12','2026-12-31']]) { + assert.equal(shanghaiTimestamp(monthCutoff(month)), `${expected}T17:00:00.000+08:00`); + assert.equal(monthCutoff(month).toISOString(), `${expected}T09:00:00.000Z`); + } + assert.equal(shanghaiMonth(new Date('2026-12-31T16:00:00Z')), '2027-01'); + assert.equal(nextCapture(new Date('2026-12-31T09:00:00Z')), '2027-01-31T17:00:00.000+08:00'); + assert.throws(() => monthCutoff('2026-13')); +}); +test('host TZ cannot change cutoff or Shanghai calendar', () => { + const script = "import {monthCutoff,shanghaiMonth} from './src/shared/assets/history.ts'; console.log(monthCutoff('2024-02').toISOString(),shanghaiMonth(new Date('2026-12-31T16:01:00Z')))"; + const run = (TZ: string) => execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', script], { env: { ...process.env, TZ }, encoding: 'utf8' }).trim(); + assert.equal(run('UTC'), run('America/Los_Angeles')); +}); +test('source rows preserve precise IDs and raw states; pending and abnormal do not add to inventory', () => { + const rows = [testRow('9223372036854775000'), testRow('self','2'), { ...testRow('pending','3'), 车辆租赁状态: '4' }, { ...testRow('abnormal','4'), 车辆租赁状态: '14' }]; + const frozen = freezeRows(rows); + assert.deepEqual(counts(frozen), { total:4, operating:2, inventory:2 }); + assert.equal(frozen[0].id, '9223372036854775000'); + assert.equal(frozen[2].vehicleStatus, '4'); + assert.equal(frozen[3].vehicleStatus, '14'); + assert.equal('abnormalMove' in frozen[0], false); + assert.throws(() => freezeRows([testRow('1'),testRow('1')]), /重复/); + for (const state of ['5','99','']) assert.throws(() => freezeRows([testRow('bad',state)]), /未分类/); +}); +test('cutoff switches live to capturing/missing without fallback; a zero snapshot is archived', () => { + const live = freezeLive([testVehicle('1')]); + assert.equal(makePoint('2026-10', new Date('2026-10-31T08:59:59Z'), undefined, [], live).state, 'LIVE'); + assert.equal(makePoint('2026-10', new Date('2026-10-31T09:00:00Z'), undefined, [], live).state, 'CAPTURING'); + const missing = makePoint('2026-10', new Date('2026-10-31T09:01:00Z'), undefined, [], live); + assert.equal(missing.state, 'MISSING'); assert.equal(missing.counts, null); + const archived = makePoint('2026-10', new Date('2026-10-31T09:00:20Z'), testBatch('2026-10'), [], live); + assert.equal(archived.state, 'ARCHIVED'); assert.deepEqual(archived.counts, { total:0,operating:0,inventory:0 }); + assert.equal(makePoint('2026-11', new Date('2026-10-19T00:00Z')).state, 'FUTURE'); +}); diff --git a/src/server/routes/asset-history/model.ts b/src/server/routes/asset-history/model.ts new file mode 100644 index 0000000..2840501 --- /dev/null +++ b/src/server/routes/asset-history/model.ts @@ -0,0 +1,84 @@ +import { filterAssetVehiclesByPermission, maskCustomerNames } from '../../auth/permissions.js'; +import type { AuthUser } from '../../auth/types.js'; +import type { Vehicle, VehicleRow } from '../../types.js'; +import { isOperatingVehicle, isInventoryVehicle, transformRow } from '../vehicles/model.js'; +import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiTimestamp, type HistoryCounts, type HistoryPoint } from '../../../shared/assets/history.js'; + +export interface HistoryVehicle extends Vehicle { + vehicleStatus: string | null; + disposition: 'operating' | 'inventory'; +} +export interface HistoryBatch { + id: string; month: string; status: 'COMPLETE' | 'FAILED' | 'MISSED'; + source: 'AUTO' | 'IMPORT'; scheduledFor: string; capturedAt: string | null; + counts: HistoryCounts; +} +export interface HistorySettings { enabledAt: string } +export class HistoryDataError extends Error {} +export class HistoryStorageUnavailable extends Error {} + +export function validateVehicles(vehicles: Vehicle[]): void { + const ids = new Set(); + for (const vehicle of vehicles) { + if (typeof vehicle.id !== 'string' || !vehicle.id || ids.has(vehicle.id)) throw new HistoryDataError('车辆 ID 缺失或重复,无法生成可信快照'); + ids.add(vehicle.id); + if (!isOperatingVehicle(vehicle) && !isInventoryVehicle(vehicle)) throw new HistoryDataError('存在未分类运营状态,无法生成可信快照'); + } +} +export function freezeRows(rows: VehicleRow[]): HistoryVehicle[] { + const vehicles = rows.map(row => { + const vehicle = transformRow(row); + return { ...vehicle, vehicleStatus: row.车辆租赁状态, + disposition: isOperatingVehicle(vehicle) ? 'operating' as const : 'inventory' as const }; + }); + validateVehicles(vehicles); + // Only freeze fields used by asset history. Movement work/customer attachments are unnecessary. + return vehicles.map(({ abnormalMove: _movement, pendingDelivery: _pending, ...vehicle }) => vehicle); +} +export function freezeLive(vehicles: Vehicle[]): HistoryVehicle[] { + validateVehicles(vehicles); + return vehicles.map(({ abnormalMove: _movement, pendingDelivery: _pending, ...v }) => ({ ...v, + vehicleStatus: v.vehicleStatus || null, disposition: isOperatingVehicle(v) ? 'operating' : 'inventory' })); +} +export function authorizedVehicles(vehicles: HistoryVehicle[], user: AuthUser, subject?: string): HistoryVehicle[] { + return maskCustomerNames(filterAssetVehiclesByPermission(vehicles, user).filter(v => !subject || v.subjectOrg === subject)); +} +export function counts(vehicles: Vehicle[]): HistoryCounts { + return { total: vehicles.length, operating: vehicles.filter(isOperatingVehicle).length, inventory: vehicles.filter(isInventoryVehicle).length }; +} +export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?: HistoryVehicle[], live?: HistoryVehicle[], annual = false): HistoryPoint { + const cutoff = monthCutoff(month); + let state: HistoryPoint['state'] = 'MISSING'; + if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, 7)) ? 'LIVE' : 'FUTURE'; + else if (batch?.status === 'COMPLETE') state = 'ARCHIVED'; + else if (batch?.status === 'FAILED') state = 'FAILED'; + else if (now.getTime() < cutoff.getTime() + CAPTURE_WINDOW_MS && batch?.status !== 'MISSED') state = 'CAPTURING'; + const available = state === 'ARCHIVED' || state === 'LIVE'; + const pointCounts = state === 'ARCHIVED' ? counts(data || []) : state === 'LIVE' ? counts(live || []) : null; + return { + period: annual ? month.slice(0, 4) : month, + label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month, + scheduledFor: shanghaiTimestamp(cutoff), + asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? batch!.capturedAt : null, + state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null, + batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts, + difference: null, comparisonLabel: '暂无可比数据', detailAvailable: available, + }; +} +export function comparePoint(point: HistoryPoint, previous: HistoryPoint | undefined, annual: boolean): HistoryPoint { + if (point.reconstruction || previous?.reconstruction) { + const comparable = point.reconstruction && previous?.reconstruction && point.reconstruction.workbookSha256 === previous.reconstruction.workbookSha256; + return { ...point, difference: null, + ...(point.reconstruction ? { reconstruction: { ...point.reconstruction, difference: comparable ? { + operating: point.reconstruction.operating - previous!.reconstruction!.operating, + inventory: point.reconstruction.inventory - previous!.reconstruction!.inventory, + } : null } } : {}), + comparisonLabel: comparable ? `推算值较${annual ? '上年年末' : '上月末'}` : '推算与实时或正式快照口径不同,暂无可比数据' }; + } + const label = annual ? '上年末' : '上月末'; + return { ...point, difference: point.counts && previous?.counts ? { + total: point.counts.total - previous.counts.total, + inventory: point.counts.inventory - previous.counts.inventory, + operating: point.counts.operating - previous.counts.operating, + } : null, comparisonLabel: point.counts && previous?.counts ? `${point.state === 'LIVE' ? '截至当前' : ''}较${label}` : '暂无可比数据' }; +} diff --git a/src/server/routes/asset-history/reconstruction.test.ts b/src/server/routes/asset-history/reconstruction.test.ts new file mode 100644 index 0000000..b2e6656 --- /dev/null +++ b/src/server/routes/asset-history/reconstruction.test.ts @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AuthUser } from '../../auth/types.js'; +import { MemoryHistoryReader, testVehicle } from './fixtures.js'; +import { HistoryService, HistoryPeriodUnavailable } from './service.js'; +import { FileReconstructionReader, publishReconstruction, reconstructionPoint, validateReconstruction, type ReconstructionData } from './reconstruction.js'; + +const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'1',userName:'测试',loginName:'test',depName:'甲',depCode:'a',permissionLevel,roles:[] }); +const now = () => new Date('2026-10-19T14:38:33+08:00'); +const data = (): ReconstructionData => ({ schemaVersion:1,method:'HANDOVER_REVIEW_V1',workbookSha256:'a'.repeat(64),sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'测试核对表.xlsx', + periods:[{month:'2026-08',operating:1,inventory:0,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'operating',basis:'交车 D:1,2026-08-01 10:00:00'}]}, + {month:'2026-09',operating:0,inventory:1,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'inventory',basis:'还车 R:2,2026-09-01 10:00:00'}]}] }); +function setup(value = data()) { + const store = new MemoryHistoryReader(); let reads = 0; + const service = new HistoryService(store,async()=>[testVehicle('today')],now,{read:async()=>{reads++;return value;}}); + return { store,service,reads:()=>reads }; +} +test('reconstruction preserves unknown total, source date and string IDs without claiming a captured snapshot', async()=>{ + const {service}=setup(); const trend=await service.trend(user(),{year:'2026'}); + const p=trend.points[8]; + assert.equal(p.state,'RECONSTRUCTED');assert.equal(p.counts,null);assert.equal(p.asOf,null); + assert.equal(p.reconstruction!.operating,0);assert.equal(p.reconstruction!.inventory,1); + assert.deepEqual(p.reconstruction!.difference,{operating:-1,inventory:1}); + assert.equal(trend.points[7].reconstruction!.difference,null); + assert.equal(trend.points[9].state,'LIVE');assert.equal(trend.points[9].difference,null); + assert.equal(trend.points[10].state,'FUTURE'); + const detail=await service.details(user(),{month:'2026-09',batchId:p.batchId!}); + assert.equal(detail.counts,null);assert.equal(detail.items[0].id,'90071992547409931'); + assert.equal(detail.items[0].plateNumber,'历史车牌');assert.equal(detail.items[0].subjectOrg,null); + assert.equal(detail.items[0].operationStatus,null);assert.equal(detail.items[0].customerName,null); + assert.match(detail.items[0].reconstructionBasis!,/R:2/); +}); +test('company/department/personal scopes cannot load or query unowned reconstruction rows', async()=>{ + for(const [level,params] of [['department',{}],['personal',{}],['full',{subject:'任意公司'}]] as const){ + const {service,reads}=setup();const identity=user(level); + const trend=await service.trend(identity,{year:'2026',...params});assert.equal(trend.points[8].state,'MISSING'); + await assert.rejects(()=>service.details(identity,{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`,...params}),HistoryPeriodUnavailable); + assert.equal(reads(),0); + } +}); +test('formal archives take precedence and forged or stale reconstruction batch IDs are refused',async()=>{ + const {service,store}=setup();store.add('2026-09',[testVehicle('formal')]); + const trend=await service.trend(user(),{});assert.equal(trend.points[8].state,'ARCHIVED');assert.equal(trend.points[8].reconstruction,undefined); + await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`}),HistoryPeriodUnavailable); + await assert.rejects(()=>service.details(user(),{month:'2026-08',batchId:'rec-forged'}),HistoryPeriodUnavailable); + assert.equal((await service.details(user(),{month:'2026-09'})).items[0].id,'formal'); +}); +test('reconstructed category/search/pagination counts agree with the selected period; missing months stay absent',async()=>{ + const value=data();value.periods[1].vehicles=Array.from({length:61},(_,i)=>({id:String(i+1),plateNumber:`历史${i}`,vin:`OLD${i}`,disposition:'inventory',basis:'还车 R:1'}));value.periods[1].inventory=61; + const {service}=setup(value); + const all=await service.details(user(),{month:'2026-09',page:'2',pageSize:'30'});assert.equal(all.total,61);assert.equal(all.items.length,30); + assert.equal((await service.details(user(),{month:'2026-09',category:'operating'})).total,0); + assert.equal((await service.details(user(),{month:'2026-09',search:'OLD60'})).total,1); + assert.equal((await service.trend(user(),{})).points[6].state,'MISSING'); +}); +test('annual estimates reuse December only; no sum, current-month replacement, or cross-source difference',async()=>{ + const value=data();value.periods.push({...value.periods[0],month:'2025-12'}); + const {service}=setup(value);const result=await service.trend(user(),{mode:'annual'}); + assert.equal(result.points.find(p=>p.period==='2025')!.reconstruction!.operating,1); + assert.equal(result.points.find(p=>p.period==='2026')!.state,'LIVE');assert.equal(result.points.at(-1)!.difference,null); + assert.equal(reconstructionPoint(value,'2026-10',false,now()),null); +}); +test('schema rejects duplicates, unsafe IDs, invented counts, current/future months and missing provenance',()=>{ + const mutate=[(d:ReconstructionData)=>{d.periods.push(d.periods[0]);},(d:ReconstructionData)=>{d.periods[0].vehicles[0].id=1 as never;}, + (d:ReconstructionData)=>{d.periods[0].operating=99;},(d:ReconstructionData)=>{d.periods[0].month='2026-10';}, + (d:ReconstructionData)=>{d.workbookSha256='';},(d:ReconstructionData)=>{d.periods[0].vehicles.push(d.periods[0].vehicles[0]);d.periods[0].operating=2;}, + (d:ReconstructionData)=>{d.periods[0].unclassified=-1;}]; + for(const change of mutate){const d=data();change(d);assert.throws(()=>validateReconstruction(d),/无效/);} + const d=data();Object.assign(d.periods[0].vehicles[0],{subjectOrg:'今日主体',customerName:'不应导入的客户'}); + assert.equal(JSON.stringify(validateReconstruction(d)).includes('今日主体'),false); + const zero=data();zero.periods[0]={month:'2026-08',operating:0,inventory:0,unclassified:3,currentlyExited:0,vehicles:[]}; + assert.equal(reconstructionPoint(validateReconstruction(zero),'2026-08',false,now())!.reconstruction!.operating,0); +}); +test('explicit local publication is atomic, idempotent and immutable; reader has no database dependencies',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'asset-reconstruction-')); + try{ + const path=join(dir,'history.json');const value=data(); + const results=await Promise.all([publishReconstruction(path,value),publishReconstruction(path,value)]); + assert.deepEqual(results.sort(),['created','unchanged']); + const before=await readFile(path,'utf8');const different=data();different.workbookSha256='b'.repeat(64); + await assert.rejects(()=>publishReconstruction(path,different),/禁止自动覆盖/);assert.equal(await readFile(path,'utf8'),before); + assert.deepEqual(await new FileReconstructionReader(path).read(),validateReconstruction(value)); + }finally{await rm(dir,{recursive:true,force:true});} +}); diff --git a/src/server/routes/asset-history/reconstruction.ts b/src/server/routes/asset-history/reconstruction.ts new file mode 100644 index 0000000..23f3099 --- /dev/null +++ b/src/server/routes/asset-history/reconstruction.ts @@ -0,0 +1,86 @@ +import { readFile, writeFile, link, unlink, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { monthCutoff, shanghaiTimestamp, type HistoryDetailItem, type HistoryPoint } from '../../../shared/assets/history.js'; + +export interface ReconstructionPeriod { + month: string; operating: number; inventory: number; unclassified: number; currentlyExited: number; + vehicles: { id: string; plateNumber: string; vin: string; disposition: 'operating' | 'inventory'; basis: string }[]; +} +export interface ReconstructionData { + schemaVersion: 1; method: 'HANDOVER_REVIEW_V1'; workbookSha256: string; + sourceAsOf: string; sourceReference: string; periods: ReconstructionPeriod[]; +} +export function validateReconstruction(input: unknown): ReconstructionData { + const fail = (): never => { throw new Error('历史回溯文件无效,未加载任何回填数据'); }; + if (!input || typeof input !== 'object') return fail(); + const data = input as ReconstructionData; + if (data.schemaVersion !== 1 || data.method !== 'HANDOVER_REVIEW_V1' || !/^[a-f0-9]{64}$/.test(data.workbookSha256) || + typeof data.sourceReference !== 'string' || !data.sourceReference.trim() || data.sourceReference.length > 200 || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?\+08:00$/.test(data.sourceAsOf) || !Number.isFinite(Date.parse(data.sourceAsOf)) || + !Array.isArray(data.periods) || data.periods.length > 1200) return fail(); + const months = new Set(); + 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(); 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 } +/** Opt-in, immutable local file. Reading never creates storage or writes a database. */ +export class FileReconstructionReader implements ReconstructionReader { + private loaded?: Promise; + 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 })); +} diff --git a/src/server/routes/asset-history/runtime.ts b/src/server/routes/asset-history/runtime.ts new file mode 100644 index 0000000..c498e97 --- /dev/null +++ b/src/server/routes/asset-history/runtime.ts @@ -0,0 +1,9 @@ +import { historyStore } from './index.js'; +import { HistoryScheduler } from './scheduler.js'; + +let scheduler: HistoryScheduler | undefined; +export function startAssetHistoryScheduler(): void { + if (process.env.ASSET_HISTORY_CAPTURE_ENABLED !== '1' || scheduler) return; + scheduler = new HistoryScheduler(historyStore); + scheduler.start(); +} diff --git a/src/server/routes/asset-history/scheduler.test.ts b/src/server/routes/asset-history/scheduler.test.ts new file mode 100644 index 0000000..723d20f --- /dev/null +++ b/src/server/routes/asset-history/scheduler.test.ts @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { HistoryScheduler, type HistoryCaptureStore } from './scheduler.js'; +import { testBatch } from './fixtures.js'; +function fixture(enabledAt='2026-10-19T13:00:00+08:00') { + const captures:string[]=[],missed:string[]=[],failed:string[]=[]; + let activates=0; + const store:HistoryCaptureStore={ + activate:async()=>{activates++;return{enabledAt};},batches:async()=>[], + capture:async(month)=>{captures.push(month);return'captured';}, + recordOutcome:async(month,status)=>{(status==='FAILED'?failed:missed).push(month);}, + }; + return {store,captures,missed,failed,activates:()=>activates}; +} +test('scheduler captures only in month-end 17:00 minute and activation is persisted once',async()=>{ + const f=fixture();let date=new Date('2026-10-31T16:59:59+08:00'); + const scheduler=new HistoryScheduler(f.store,()=>date,()=>{}); + await scheduler.tick();assert.deepEqual(f.captures,[]); + date=new Date('2026-10-31T17:00:00+08:00');await scheduler.tick();assert.deepEqual(f.captures,['2026-10']);assert.equal(f.activates(),1); +}); +test('restart records missed eligible months without backfilling old live data or pre-activation periods',async()=>{ + const f=fixture();const scheduler=new HistoryScheduler(f.store,()=>new Date('2027-01-15T08:00:00+08:00'),()=>{}); + await scheduler.tick();assert.deepEqual(f.captures,[]);assert.deepEqual(f.missed,['2026-10','2026-11','2026-12']); + const late=fixture('2026-10-31T17:00:10+08:00');await new HistoryScheduler(late.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{}).tick(); + assert.deepEqual(late.captures,[]);assert.deepEqual(late.missed,[]); +}); +test('completed December skips duplicate annual capture; failures stay marked without late retries',async()=>{ + const f=fixture();f.store.batches=async()=>[testBatch('2026-10')]; + f.store.capture=async()=>{throw new Error('source unavailable');}; + await new HistoryScheduler(f.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick(); + assert.deepEqual(f.missed,['2026-11']);assert.deepEqual(f.failed,['2026-12']); + const done=fixture('2026-12-01T00:00+08:00');done.store.batches=async()=>[testBatch('2026-12')]; + await new HistoryScheduler(done.store,()=>new Date('2026-12-31T17:00:20+08:00'),()=>{}).tick();assert.deepEqual(done.captures,[]); +}); +test('concurrent ticks and repeated start do not register duplicate jobs',async()=>{ + const f=fixture();let release!:()=>void; + f.store.capture=async()=>{f.captures.push('2026-10');await new Promise(r=>{release=r;});return'captured';}; + const scheduler=new HistoryScheduler(f.store,()=>new Date('2026-10-31T17:00:20+08:00'),()=>{}); + const stop=scheduler.start();scheduler.start(); + await new Promise(r=>setImmediate(r));await scheduler.tick();assert.equal(f.captures.length,1); + stop();release();await new Promise(r=>setImmediate(r)); +}); diff --git a/src/server/routes/asset-history/scheduler.ts b/src/server/routes/asset-history/scheduler.ts new file mode 100644 index 0000000..dc4319c --- /dev/null +++ b/src/server/routes/asset-history/scheduler.ts @@ -0,0 +1,61 @@ +import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiMonth, shiftMonth } from '../../../shared/assets/history.js'; +import type { CaptureResult } from './store.js'; +import { HistoryDataError, type HistoryBatch, type HistorySettings } from './model.js'; + +export interface HistoryCaptureStore { + activate(now: Date): Promise; + batches(): Promise; + capture(month: string, clock: () => Date): Promise; + recordOutcome(month: string, status: 'MISSED' | 'FAILED', code: string): Promise; +} +export class HistoryScheduler { + private timer: ReturnType | 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 { + if (this.ticking) return; + this.ticking = true; + try { + const now = this.clock(); + this.settings ||= await this.store.activate(now); + const enabled = new Date(this.settings.enabledAt); + const existing = new Map((await this.store.batches()).map(b => [b.month, b])); + for (let month = shanghaiMonth(enabled); month <= shanghaiMonth(now); month = shiftMonth(month, 1)) { + const cutoff = monthCutoff(month); + if (cutoff < enabled || cutoff > now || existing.get(month)?.status === 'COMPLETE') continue; + if (now.getTime() >= cutoff.getTime() + CAPTURE_WINDOW_MS) { + if (!existing.has(month)) { await this.store.recordOutcome(month, 'MISSED', 'CAPTURE_WINDOW_MISSED'); this.log(`[asset-history] missed ${month}`); } + continue; + } + try { const result = await this.store.capture(month, this.clock); this.log(`[asset-history] ${month}: ${result}`); } + catch (error) { + const databaseCode = (error as { code?: unknown })?.code; + const code = error instanceof HistoryDataError ? 'INVALID_VEHICLE_DATA' : typeof databaseCode === 'string' && /^[A-Z0-9_]{1,48}$/.test(databaseCode) ? databaseCode : 'CAPTURE_FAILED'; + await this.store.recordOutcome(month, 'FAILED', code); + this.log(`[asset-history] failed ${month} (${code}); no later live data will be substituted`); + } + } + } finally { this.ticking = false; } + } + start(): () => void { + if (this.running) return () => this.stop(); + this.running = true; + const run = async () => { + if (!this.running) return; + try { await this.tick(); } catch { this.log('[asset-history] scheduler unavailable; check migration and database access'); } + if (!this.running) return; + const now = this.clock(); + const untilCutoff = monthCutoff(shanghaiMonth(now)).getTime() - now.getTime(); + this.timer = setTimeout(run, untilCutoff > 0 ? Math.min(untilCutoff, 60_000) : 15_000); + this.timer.unref?.(); + }; + void run(); + return () => this.stop(); + } + stop(): void { this.running = false; clearTimeout(this.timer); } +} diff --git a/src/server/routes/asset-history/service.test.ts b/src/server/routes/asset-history/service.test.ts new file mode 100644 index 0000000..011747d --- /dev/null +++ b/src/server/routes/asset-history/service.test.ts @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Hono } from 'hono'; +import type { AuthUser } from '../../auth/types.js'; +import { createPreviewMiddleware } from '../../auth/preview/routes.js'; +import { createHistoryRouter } from './index.js'; +import { HistoryService, HistoryInputError, HistoryPeriodUnavailable } from './service.js'; +import { HistoryStorageUnavailable } from './model.js'; +import { MemoryHistoryReader, testVehicle } from './fixtures.js'; + +const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'manager-a',loginName:'a',userName:'甲',depCode:'a',depName:'部门甲',permissionLevel,roles:[] }); +const now = () => new Date('2026-10-19T13:00:00+08:00'); +const inventory = testVehicle('stock',{ operationStatus:'3',status:'Abnormal',departmentName:'部门乙',managerId:'manager-b',subjectOrg:'历史公司' }); +const leased = testVehicle('own'); +const foreign = testVehicle('foreign',{ managerId:'manager-b',departmentName:'部门乙' }); +function setup() { + const store = new MemoryHistoryReader(); store.add('2026-09',[leased,foreign,inventory]); + const live = [testVehicle('today'), testVehicle('today-stock',{ operationStatus:'4',status:'Pending' })]; + return { store, live, service:new HistoryService(store,async()=>live,now) }; +} +test('monthly holes/future remain null; current values are real; comparisons never skip missing periods', async () => { + const {store,service}=setup(); store.add('2026-07',[leased]); + const result=await service.trend(user(),{year:'2026'}); + assert.equal(result.points.length,12); + assert.deepEqual(result.points[8].counts,{total:3,operating:2,inventory:1}); + assert.equal(result.points[8].difference,null); // August is missing, July cannot substitute. + assert.deepEqual(result.points[9].difference,{total:-1,operating:-1,inventory:0}); + assert.equal(result.points[9].state,'LIVE'); assert.equal(result.points[0].counts,null); + assert.equal(result.points[10].state,'FUTURE'); assert.equal(result.points[10].counts,null); + assert.ok(result.subjects.includes('历史公司')); +}); +test('annual counts use only December snapshots, not a sum of months', async () => { + const {store,service}=setup(); store.add('2025-12',[leased]); store.add('2025-11',[leased,foreign,inventory]); + const result=await service.trend(user(),{mode:'annual'}); + assert.deepEqual(result.points.find(p=>p.period==='2025')!.counts,{total:1,operating:1,inventory:0}); + assert.equal(result.points.at(-1)!.state,'LIVE'); + assert.deepEqual(result.points.at(-1)!.difference,{total:1,operating:0,inventory:1}); +}); +test('full/department/personal and manager aliases share snapshot/live public inventory policy', async () => { + const {service}=setup(); + for (const level of ['full','department','personal'] as const) { + const result=await service.trend(user(level),{}); + const september=result.points[8]; + assert.equal(september.counts!.total,level==='full'?3:2); + const detail=await service.details(user(level),{month:'2026-09'}); + assert.equal(detail.total,september.counts!.total); assert.deepEqual(detail.counts,september.counts); + assert.ok(detail.items.some(v=>v.id==='stock')); + assert.equal(detail.items.some(v=>v.id==='foreign'),level==='full'); + assert.ok(!JSON.stringify(detail).includes('测试客户公司')); + } + const alias={...user('personal'),userId:'new-id',managerIds:['manager-a']}; + assert.equal((await service.details(alias,{month:'2026-09'})).total,2); +}); +test('company filter intersects permission; foreign batch IDs cannot bypass month or identity', async () => { + const {service}=setup(); + const trend=await service.trend(user('personal'),{subject:'历史公司'}); + assert.deepEqual(trend.points[8].counts,{total:1,operating:0,inventory:1}); + assert.equal((await service.details(user('personal'),{month:'2026-09',subject:'公司甲'})).total,1); + assert.equal((await service.details(user('personal'),{month:'2026-09',subject:'任意公司'})).total,0); + await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:'other-batch'}),HistoryPeriodUnavailable); +}); +test('historical vehicles survive current deletion, reassignment and exit without any live join', async () => { + const {store}=setup(); let calls=0; + const service=new HistoryService(store,async()=>{calls++;throw new Error('live must not be read');},now); + const result=await service.details(user('personal'),{month:'2026-09'}); + assert.deepEqual(result.items.map(v=>v.id).sort(),['own','stock']); + assert.equal(calls,0); + assert.equal((await service.trend(user(),{year:'2025'})).points.every(p=>p.state==='MISSING'),true); + assert.equal(calls,0); +}); +test('pagination/search/category use authorized snapshot only, keeping unsearched counts intact', async () => { + const {store,service}=setup();store.add('2026-08',Array.from({length:65},(_,i)=>testVehicle(String(i)))); + const detail=await service.details(user(),{month:'2026-08',page:'2',pageSize:'30'}); + assert.equal(detail.total,65);assert.equal(detail.items.length,30);assert.equal(detail.counts!.total,65); + const filtered=await service.details(user(),{month:'2026-09',category:'inventory',search:'TESTVINstock'}); + assert.equal(filtered.total,1);assert.equal(filtered.counts!.total,3); +}); +test('missing storage degrades to live without any DDL; genuine database errors are not hidden', async () => { + const {store,live}=setup();store.settings=async()=>{throw new HistoryStorageUnavailable();};store.batches=async()=>{throw new HistoryStorageUnavailable();}; + const service=new HistoryService(store,async()=>live,now); + const result=await service.trend(user(),{});assert.equal(result.storageReady,false);assert.equal(result.points[9].counts!.total,2);assert.equal(result.points[8].counts,null); + await assert.rejects(()=>service.details(user(),{month:'2026-09'}),HistoryPeriodUnavailable); + store.batches=async()=>{throw new Error('database offline');};await assert.rejects(()=>service.trend(user(),{}),/offline/); +}); +test('once cutoff passes live values cannot replace missing history, even when capture was not enabled', async () => { + const {store}=setup();store.settingsValue=null;let liveCalls=0; + const service=new HistoryService(store,async()=>{liveCalls++;return[leased];},()=>new Date('2026-10-31T17:00:30+08:00')); + const result=await service.trend(user(),{});assert.equal(result.points[9].state,'MISSING');assert.equal(result.points[9].counts,null); + assert.equal(liveCalls,0);await assert.rejects(()=>service.details(user(),{month:'2026-10'}),HistoryPeriodUnavailable); +}); +test('snapshot count integrity failure never appears as a legitimate zero', async () => { + const {store,service}=setup();store.rows.clear();await assert.rejects(()=>service.trend(user(),{}),/integrity/); +}); +test('invalid parameters fail before querying details or live; valid archived zero retains detail access', async () => { + const {store,service}=setup();store.add('2026-08',[]); + assert.equal((await service.details(user(),{month:'2026-08'})).total,0); + for(const params of [{year:'NaN'},{year:'2027'},{mode:'weekly'}] as Record[]) 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[]) 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']); +}); diff --git a/src/server/routes/asset-history/service.ts b/src/server/routes/asset-history/service.ts new file mode 100644 index 0000000..b4d0b28 --- /dev/null +++ b/src/server/routes/asset-history/service.ts @@ -0,0 +1,126 @@ +import type { AuthUser } from '../../auth/types.js'; +import type { Vehicle } from '../../types.js'; +import { isOperatingVehicle } from '../vehicles/model.js'; +import { HISTORY_STATE_LABELS, nextCapture, shanghaiMonth, shiftMonth, monthCutoff, type HistoryMode, type HistoryTrend, type HistoryCategory, type HistoryDetails } from '../../../shared/assets/history.js'; +import { authorizedVehicles, comparePoint, counts, freezeLive, HistoryStorageUnavailable, makePoint, validateVehicles, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js'; +import type { HistoryReader } from './store.js'; +import { reconstructionItems, reconstructionPoint, type ReconstructionReader } from './reconstruction.js'; + +export class HistoryInputError extends Error {} +export class HistoryPeriodUnavailable extends Error {} +export function parseHistoryQuery(params: Record, 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, 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) { + 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(), ready: false }; + } + } + async trend(user: AuthUser, params: Record): Promise { + 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): Promise { + 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 })) }; + } +} diff --git a/src/server/routes/asset-history/store.test.ts b/src/server/routes/asset-history/store.test.ts new file mode 100644 index 0000000..dd79dec --- /dev/null +++ b/src/server/routes/asset-history/store.test.ts @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { Pool } from 'mysql2/promise'; +import { MysqlHistoryStore } from './store.js'; +import { MAIN_SQL } from '../vehicles/repository.js'; +import { HistoryStorageUnavailable } from './model.js'; +import { testRow } from './fixtures.js'; + +/** Isolated transactional database double: failed transactions discard ALL staged writes. */ +class Database { + commands:string[]=[]; + rows=[testRow('9223372036854775000'),testRow('stock','3')]; + settings='2026-10-19T13:00:00+08:00'; + locked=1; existing: Array<{id:string;status:string}>=[]; + committed:string[]=[]; staged:string[]=[]; + failDetails=0; failReads=false; failCommitAck=false; beforeLock?:()=>void; afterSource?:()=>void; onReadTransaction?:()=>void; + released=false; clock=()=>new Date('2026-10-31T17:00:00.500+08:00'); + async query(sql:string,_args?:unknown[]) { + this.commands.push(sql); + if(sql.includes('GET_LOCK')){this.beforeLock?.();return[[{acquired:this.locked}]];} + if(sql.includes('RELEASE_LOCK'))return[[{released:1}]]; + if(sql.includes('FROM bi_asset_history_settings'))return[[{enabledAt:this.settings}]]; + if(sql.includes('SELECT id,status'))return[this.existing]; + if(sql.startsWith('START TRANSACTION'))this.onReadTransaction?.(); + if(sql===MAIN_SQL){if(this.failReads)throw new Error('read failed');this.afterSource?.();return[this.rows];} + if(sql.startsWith('INSERT INTO bi_asset_history_vehicles')){ + this.staged.push('details');if(this.failDetails-->0)throw new Error('write failed'); + } + if(sql.startsWith('INSERT INTO bi_asset_history_batches'))this.staged.push('batch'); + if(sql.startsWith('UPDATE bi_asset_history_batches'))this.staged.push('complete'); + return[[]]; + } + async getConnection(){return this;} + async beginTransaction(){this.commands.push('BEGIN');this.staged=[];} + async rollback(){this.commands.push('ROLLBACK');this.staged=[];} + async commit(){this.commands.push('COMMIT');this.committed.push(...this.staged);this.staged=[];if(this.failCommitAck){this.failCommitAck=false;this.existing=[{id:'committed',status:'COMPLETE'}];throw new Error('commit acknowledgement lost');}} + release(){this.released=true;} + store(){return new MysqlHistoryStore(this as unknown as Pool);} +} +test('capture uses uncached shared SQL and one consistent read; detail/header publish atomically',async()=>{ + const db=new Database();assert.equal(await db.store().capture('2026-10',db.clock),'captured'); + assert.deepEqual(db.committed,['batch','details','complete']); + assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1); + assert.ok(db.commands.indexOf('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY'){ + const db=new Database();db.failDetails=1;let now=new Date('2026-10-31T17:00:30+08:00'); + db.afterSource=()=>{now=new Date('2026-10-31T18:00:00+08:00');};const clock=()=>now; + assert.equal(await db.store().capture('2026-10',clock),'captured'); + assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1); + assert.deepEqual(db.committed,['batch','details','complete']); + assert.equal(db.commands.filter(s=>s==='BEGIN').length,2); +}); +test('lost commit acknowledgement does not overwrite or duplicate an already-published batch',async()=>{ + const db=new Database();db.failCommitAck=true; + assert.equal(await db.store().capture('2026-10',db.clock),'already-complete'); + assert.deepEqual(db.committed,['batch','details','complete']);assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1); +}); +test('source is not read if transaction establishment crosses the capture deadline',async()=>{ + const db=new Database();let now=new Date('2026-10-31T17:00:59.900+08:00'); + db.onReadTransaction=()=>{now=new Date('2026-10-31T17:01:00+08:00');}; + assert.equal(await db.store().capture('2026-10',()=>now),'outside-window'); + assert.equal(db.commands.includes(MAIN_SQL),false);assert.deepEqual(db.committed,[]); +}); +test('persistent detail-write failure publishes neither partial header nor rows',async()=>{ + const db=new Database();db.failDetails=5; + await assert.rejects(()=>db.store().capture('2026-10',db.clock),/write failed/); + assert.deepEqual(db.committed,[]);assert.deepEqual(db.staged,[]);assert.equal(db.released,true); + assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1); +}); +test('source failure and invalid rows cannot publish snapshots',async()=>{ + const db=new Database();db.failReads=true; + await assert.rejects(()=>db.store().capture('2026-10',db.clock),/read failed/);assert.deepEqual(db.committed,[]); + db.failReads=false;db.rows=[testRow('duplicate'),testRow('duplicate')]; + await assert.rejects(()=>db.store().capture('2026-10',db.clock),/重复/);assert.deepEqual(db.committed,[]); +}); +test('completed period, concurrent lock and expired window cannot issue a source read',async()=>{ + for(const scenario of ['complete','lock','expired','delayed-lock','late-activation']){ + const db=new Database();let clock=db.clock; + if(scenario==='complete')db.existing=[{id:'existing',status:'COMPLETE'}]; + if(scenario==='lock')db.locked=0; + if(scenario==='expired')clock=()=>new Date('2026-10-31T17:01:00+08:00'); + if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T17:01:00+08:00');}; + if(scenario==='late-activation')db.settings='2026-10-31T17:00:01+08:00'; + const result=await db.store().capture('2026-10',()=>clock()); + assert.ok(['already-complete','locked','outside-window'].includes(result)); + assert.equal(db.commands.includes(MAIN_SQL),false,scenario);assert.deepEqual(db.committed,[]); + } +}); +test('a legitimate empty source commits a zero-count complete batch without detail inserts',async()=>{ + const db=new Database();db.rows=[]; + assert.equal(await db.store().capture('2026-10',db.clock),'captured');assert.deepEqual(db.committed,['batch','complete']); +}); +test('missing storage reads never issue schema writes and report storage-unavailable',async()=>{ + const calls:string[]=[]; + const db={query:async(sql:string)=>{calls.push(sql);throw{code:'ER_NO_SUCH_TABLE'};}}; + const store=new MysqlHistoryStore(db as unknown as Pool); + await assert.rejects(()=>store.settings(),HistoryStorageUnavailable); + await assert.rejects(()=>store.batches(),HistoryStorageUnavailable); + assert.ok(calls.every(sql=>sql.startsWith('SELECT'))); +}); diff --git a/src/server/routes/asset-history/store.ts b/src/server/routes/asset-history/store.ts new file mode 100644 index 0000000..3123ad0 --- /dev/null +++ b/src/server/routes/asset-history/store.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto'; +import type { Pool, PoolConnection, RowDataPacket } from 'mysql2/promise'; +import { CAPTURE_WINDOW_MS, HISTORY_VERSION, monthCutoff, shanghaiTimestamp } from '../../../shared/assets/history.js'; +import type { VehicleRow } from '../../types.js'; +import { MAIN_SQL } from '../vehicles/repository.js'; +import { counts, freezeRows, HistoryStorageUnavailable, type HistoryBatch, type HistorySettings, type HistoryVehicle } from './model.js'; + +export interface HistoryReader { + settings(): Promise; + batches(): Promise; + vehicles(batchIds: string[]): Promise>; +} +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) {} + + private async read(sql: string, args: unknown[] = []): Promise { + try { const [rows] = await this.db.query(sql, args); return rows; } + catch (error) { + if ((error as { code?: string }).code === 'ER_NO_SUCH_TABLE') throw new HistoryStorageUnavailable('历史快照存储尚未部署'); + throw error; + } + } + async settings(): Promise { + const rows = await this.read(`SELECT ${dateColumn('enabled_at')} AS enabledAt FROM bi_asset_history_settings WHERE id=1`); + return rows[0] ? { enabledAt: rows[0].enabledAt } : null; + } + async batches(): Promise { + const rows = await this.read(`SELECT id, period_month AS month, status, source, + total_count,operating_count,inventory_count, + ${dateColumn('scheduled_for')} AS scheduledFor, ${dateColumn('captured_at')} AS capturedAt + FROM bi_asset_history_batches WHERE definition_version=? ORDER BY period_month`, [HISTORY_VERSION]); + return rows.map(row => ({ id: row.id, month: row.month, status: row.status, source: row.source, scheduledFor: row.scheduledFor, capturedAt: row.capturedAt, + counts: { total: row.total_count, operating: row.operating_count, inventory: row.inventory_count } })); + } + async vehicles(batchIds: string[]): Promise> { + const result = new Map(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 { + await this.db.query('INSERT IGNORE INTO bi_asset_history_settings (id,enabled_at) VALUES (1,?)', [sqlTime(now)]); + return (await this.settings())!; + } + async recordOutcome(month: string, status: 'FAILED' | 'MISSED', code: string): Promise { + // Never downgrade a completed snapshot, or replace a useful failure with MISSED. + await this.db.query(`INSERT INTO bi_asset_history_batches + (id,period_month,definition_version,scheduled_for,source,status,failure_code) + VALUES (?,?,?,?,'AUTO',?,?) ON DUPLICATE KEY UPDATE + failure_code=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),failure_code,VALUES(failure_code)), + status=IF(status='COMPLETE' OR (status='FAILED' AND VALUES(status)='MISSED'),status,VALUES(status))`, + [randomUUID(), month, HISTORY_VERSION, sqlTime(monthCutoff(month)), status, code]); + } + async capture(month: string, clock: () => Date = () => new Date()): Promise { + const cutoff = monthCutoff(month).getTime(); + const inWindow = () => clock().getTime() >= cutoff && clock().getTime() < cutoff + CAPTURE_WINDOW_MS; + if (!inWindow()) return 'outside-window'; + const connection = await this.db.getConnection(); + const lock = `ln-bi:asset:${HISTORY_VERSION}:${month}`; + let locked = false; + try { + const [locks] = await connection.query("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(`SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=?`, [month, HISTORY_VERSION]); + if (existing[0]?.status === 'COMPLETE') return 'already-complete'; + await connection.query("SET time_zone='+08:00'"); + await connection.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + if (!inWindow()) return 'outside-window'; + await connection.query('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY'); + const capturedAt = clock(); + let frozen: HistoryVehicle[]; + try { + if (!inWindow()) return 'outside-window'; + const [rows] = await connection.query(MAIN_SQL); + frozen = freezeRows(rows as VehicleRow[]); + } finally { await connection.rollback(); } + // Retries below keep this exact frozen dataset. Never reread a later live ledger. + const id = existing[0]?.id || randomUUID(); + for (let attempt = 0; ; attempt++) { + try { return await this.publish(connection, id, month, capturedAt, frozen, clock); } + catch (error) { await connection.rollback(); if (attempt >= 2) throw error; } + } + } finally { + try { if (locked) await connection.query("SELECT RELEASE_LOCK(CONCAT(DATABASE(),':',?))", [lock]); } + finally { connection.release(); } + } + } + private async publish(connection: PoolConnection, id: string, month: string, capturedAt: Date, vehicles: HistoryVehicle[], clock: () => Date) { + const totals = counts(vehicles); + await connection.beginTransaction(); + const [existing] = await connection.query('SELECT id,status FROM bi_asset_history_batches WHERE period_month=? AND definition_version=? FOR UPDATE', [month, HISTORY_VERSION]); + // Also handles a committed transaction whose acknowledgement was lost. + if (existing[0]?.status === 'COMPLETE') { await connection.commit(); return 'already-complete' as const; } + if (existing[0]) id = existing[0].id; + // Under the advisory lock, only FAILED/MISSED placeholders may be promoted. + await connection.query(`INSERT INTO bi_asset_history_batches + (id,period_month,definition_version,scheduled_for,captured_at,source,status,total_count,operating_count,inventory_count) + VALUES (?,?,?,?,?,'AUTO','FAILED',?,?,?) ON DUPLICATE KEY UPDATE captured_at=VALUES(captured_at), + total_count=VALUES(total_count),operating_count=VALUES(operating_count),inventory_count=VALUES(inventory_count)`, + [id, month, HISTORY_VERSION, sqlTime(monthCutoff(month)), sqlTime(capturedAt), totals.total, totals.operating, totals.inventory]); + for (let offset = 0; offset < vehicles.length; offset += 100) { + const chunk = vehicles.slice(offset, offset + 100); + await connection.query(`INSERT INTO bi_asset_history_vehicles + (batch_id,vehicle_id,operation_status,vehicle_status,disposition,subject_org,department_name,manager_id,frozen_vehicle) + VALUES ${chunk.map(() => '(?,?,?,?,?,?,?,?,CAST(? AS JSON))').join(',')}`, chunk.flatMap(v => + [id, v.id, v.operationStatus, v.vehicleStatus, v.disposition, v.subjectOrg, v.departmentName, v.managerId, JSON.stringify(v)])); + } + await connection.query("UPDATE bi_asset_history_batches SET status='COMPLETE',completed_at=?,failure_code=NULL WHERE id=?", [sqlTime(clock()), id]); + await connection.commit(); + return 'captured' as const; + } +} diff --git a/src/server/types.ts b/src/server/types.ts index 933858c..9c0d1b4 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -43,6 +43,7 @@ export interface VehicleRow { } export interface Vehicle { + vehicleStatus?: string | null; opsManagers?: string | null; pendingDelivery?: { contractNo: string | null; customerName: string | null } | null; abnormalMove?: AbnormalMoveDetail | null; diff --git a/src/shared/assets/history.ts b/src/shared/assets/history.ts new file mode 100644 index 0000000..5798040 --- /dev/null +++ b/src/shared/assets/history.ts @@ -0,0 +1,76 @@ +export type HistoryMode = 'monthly' | 'annual'; +export type HistoryState = 'LIVE' | 'ARCHIVED' | 'MISSING' | 'FUTURE' | 'CAPTURING' | 'FAILED' | 'RECONSTRUCTED'; +export type HistoryCategory = 'all' | 'operating' | 'inventory'; +export interface HistoryCounts { total: number; operating: number; inventory: number } +export interface HistoryPoint { + period: string; + label: string; + month: string; + scheduledFor: string; + asOf: string | null; + state: HistoryState; + source: 'AUTO' | 'IMPORT' | 'LIVE' | 'RECONSTRUCTED' | null; + batchId: string | null; + counts: HistoryCounts | null; + difference: HistoryCounts | null; + comparisonLabel: string; + detailAvailable: boolean; + reconstruction?: { + operating: number; inventory: number; unclassified: number; + currentlyExited: number; sourceAsOf: string; sourceReference: string; + workbookSha256: string; + difference: { operating: number; inventory: number } | null; + }; +} +export interface HistoryTrend { + mode: HistoryMode; + year: number; + points: HistoryPoint[]; + years: number[]; + subjects: string[]; + storageReady: boolean; + enabledAt: string | null; + nextCaptureAt: string | null; + warning: string | null; +} +export interface HistoryDetailItem { + id: string; plateNumber: string; vin: string; type: string; model: string; + disposition: 'operating' | 'inventory'; operationStatus: string | null; + vehicleStatus: string | null; subjectOrg: string | null; departmentName: string | null; + customerManager: string | null; customerName: string | null; contractNo: string | null; + reconstructionBasis?: string; +} +export interface HistoryDetails { + point: HistoryPoint; + counts: HistoryCounts | null; + total: number; + page: number; + pageSize: number; + items: HistoryDetailItem[]; +} + +const OFFSET = 8 * 60 * 60 * 1000; +export const CAPTURE_WINDOW_MS = 60_000; +export const HISTORY_VERSION = 'asset-v1'; +export function shanghaiMonth(now: Date): string { return new Date(now.getTime() + OFFSET).toISOString().slice(0, 7); } +export function shanghaiTimestamp(now: Date): string { return new Date(now.getTime() + OFFSET).toISOString().slice(0, -1) + '+08:00'; } +export function monthCutoff(month: string): Date { + if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('月份格式无效'); + const [year, m] = month.split('-').map(Number); + return new Date(Date.UTC(year, m, 0, 9)); // 17:00 Asia/Shanghai, on the last day. +} +export function shiftMonth(month: string, offset: number): string { + const [year, m] = month.split('-').map(Number); + return new Date(Date.UTC(year, m - 1 + offset, 1)).toISOString().slice(0, 7); +} +export function nextCapture(now: Date): string { + const month = shanghaiMonth(now); + return shanghaiTimestamp(monthCutoff(monthCutoff(month).getTime() > now.getTime() ? month : shiftMonth(month, 1))); +} +export function historyTime(value: string | null): string { + if (!value) return '暂无时间'; + return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)); +} +export const HISTORY_STATE_LABELS: Record = { + LIVE: '未结期 · 实时', ARCHIVED: '已归档', MISSING: '暂无快照', FUTURE: '尚未到达', CAPTURING: '归档中', FAILED: '归档失败', RECONSTRUCTED: '历史回溯 · 估算', +};