Compare commits

...

48 Commits

Author SHA1 Message Date
WuYongJie
b6751bb8cb fix: restore legacy print agent compatibility safely 2026-10-09 14:44:18 +08:00
WuYongJie
8cfeb1c6b1 fix: preserve asset state and recover uncertain print results 2026-10-09 14:06:26 +08:00
WuYongJie
843babd381 fix: align disposal flow with administrative role 2026-08-11 21:49:55 +08:00
WuYongJie
02b637064c feat: 报废前提示并支持快捷入库 2026-08-11 21:00:36 +08:00
WuYongJie
3e7486e760 feat: 行政支持发起报废流程 2026-07-21 18:45:54 +08:00
WuYongJie
f31924cc1e fix: 允许IT维护借出资产资料 2026-07-18 16:20:42 +08:00
WuYongJie
f69406e888 feat: 恢复IT资产详情直接维护 2026-07-18 16:12:20 +08:00
WuYongJie
963dcc06af fix: 精简盘点钉钉通知格式 2026-07-17 18:05:02 +08:00
WuYongJie
e1eb946506 feat: 盘点任务按父子资产展示 2026-07-17 17:39:17 +08:00
WuYongJie
3e05835408 feat: 收敛盘点范围筛选 2026-07-17 16:54:21 +08:00
WuYongJie
6e1441f7a4 style: 精简盘点任务创建页面 2026-07-17 16:15:11 +08:00
WuYongJie
68ba8d74e4 feat: 重构盘点任务创建流程 2026-07-17 15:32:12 +08:00
WuYongJie
c29751059e feat: 全量开放钉钉登录并升级资产盘点 2026-07-17 14:51:30 +08:00
WuYongJie
2e316a7104 fix: 恢复旧通道报废催办投递 2026-07-17 13:55:55 +08:00
WuYongJie
f670ccb5d5 优化花名册变动通知排版与IT路由 2026-07-17 13:13:20 +08:00
WuYongJie
a65de1d52f fix: 校准首页统计与压缩页面布局 2026-07-17 10:39:26 +08:00
WuYongJie
149d4510df feat: 完成通知主通道与角色工作台收口 2026-07-16 23:43:10 +08:00
WuYongJie
920fa1289d fix: 收紧资产流转安全与敏感数据权限 2026-07-16 23:12:59 +08:00
WuYongJie
6a38680aa7 优化固定资产钉钉通知排版 2026-07-16 21:16:47 +08:00
WuYongJie
d25d600c7a feat(钉钉): 接入由壹点统一通知中心 2026-07-16 20:06:36 +08:00
WuYongJie
0b83ef7f88 fix(钉钉): 兼容桌面端免登来源 2026-07-15 21:59:18 +08:00
WuYongJie
f4812dc6a2 fix(部署): 保留后端构建依赖 2026-07-15 21:35:15 +08:00
WuYongJie
0a0145210d feat(钉钉): 增加统一通知中心免登灰度 2026-07-15 21:06:36 +08:00
WuYongJie
a1ac722317 fix(钉钉): 收紧统一通知中心灰度与状态 2026-07-15 18:46:46 +08:00
WuYongJie
346e830328 更新统一通知中心接入文档 2026-07-15 18:12:23 +08:00
WuYongJie
8e295e48cd 接入由壹点统一通知中心 2026-07-15 17:44:08 +08:00
WuYongJie
a6463fc30a feat(流程): 增加角色权限、报废审批与钉钉通知 2026-07-15 13:09:20 +08:00
WuYongJie
373825dbfe fix(导出): 修复大批量 Excel 下载超时 2026-07-10 18:41:23 +08:00
WuYongJie
a754472cd5 feat(导出): 按行政台账展示实时资产关系 2026-07-10 18:20:10 +08:00
WuYongJie
81d1419ef5 fix(发布): 规范远端命令换行 2026-07-10 17:07:59 +08:00
WuYongJie
d1f96859f3 feat(设置): 优化信息层级与移动端体验 2026-07-10 17:03:37 +08:00
WuYongJie
3f2151a72f fix(导出): 父资产与子资产合并为单行 2026-07-10 15:19:07 +08:00
WuYongJie
ef4c95f28d chore(下载): 清理打印助手旧版本 2026-07-10 14:14:15 +08:00
WuYongJie
5ce86b0f5c fix(发布): 常规部署跳过下载产物 2026-07-10 14:07:21 +08:00
WuYongJie
879d74f668 feat(导出): 支持资产父子关系快照 2026-07-10 13:37:40 +08:00
WuYongJie
94a5e8a639 fix(发布): 兼容 Windows 中文路径打包 2026-07-10 11:42:06 +08:00
WuYongJie
a484eb9e0d fix(脚本): 移除旧迁移工具生产凭据 2026-07-10 11:36:13 +08:00
WuYongJie
f280380720 feat(资产流程): 完善工号借还盘点与打印闭环 2026-07-10 11:30:35 +08:00
WuYongJie
d2a780a20e fix(证书): 补齐固定资产双域名 HTTPS 配置 2026-07-10 11:28:12 +08:00
WuYongJie
7cdbe3a507 fix(发布): 收紧备份与部署安全边界 2026-07-10 11:28:03 +08:00
WuYongJie
bc7d3cca17 chore(协作): 更新项目子代理工作规则 2026-07-10 11:27:56 +08:00
WuYongJie
aa0f27dce0 feat(组织同步): 上游业务库迁移到报表库, schema 改为可配置
- hrSyncService: 新增 SQLSERVER_SCHEMA(默认 rpt, 带格式校验防注入)
- 3 处 dbo.hr_employee_register/hr_employee 改为动态 ${schema}.表名
- .env.example / docker-compose.yml 注入 SQLSERVER_SCHEMA
- 配合上游通知: 老业务库迁到 JSReportReplica.rpt, 只读使用
2026-06-25 13:27:10 +08:00
WuYongJie
0dc3c09315 feat(资产与打印): 提交近期借还盘点与打印助手源码
- 提交借用单缺失异常处理、扫码借还搜索与站内消息快捷处理源码
- 提交盘点范围、多终端打印、组织同步与认证模式相关源码
- 提交 .NET/Node 打印助手及版本清单源码
2026-03-27 20:18:31 +08:00
WuYongJie
c7a9324635 feat(打印): 新增远程标签打印与 .NET 打印助手
- 新增打印终端、配对码、打印任务相关表结构与接口\n- 增加后台打印终端/任务管理页和资产标签远程打印入口\n- 引入 .NET 8 Windows 打印助手源码及下载清单配置
2026-03-25 21:05:30 +08:00
WuYongJie
9519c10b78 完善父子资产迁移与挂载管理 2026-03-24 14:21:09 +08:00
WuYongJie
247c3107b5 补齐迁移补录闭环与离线验证 2026-03-23 12:05:51 +08:00
WuYongJie
ceacc2f98d 完善组织同步盘点与资产迁移链路 2026-03-23 11:54:41 +08:00
WuYongJie
c3f8eb6f6f 完成本轮功能与界面改造,备份可运行版本 2026-03-18 11:49:56 +08:00
414 changed files with 85379 additions and 9109 deletions

View File

@ -1,20 +1,37 @@
MYSQL_ROOT_PASSWORD=change-me-local-password
MYSQL_DATABASE=equipment_management
SESSION_SECRET=change-me-session-secret
SESSION_COOKIE_SECURE=false
CORS_ALLOWED_ORIGINS=http://localhost:5173
AUTH_MODE=local
KEYCLOAK_URL=
KEYCLOAK_REALM=equipment-management
KEYCLOAK_CLIENT_ID=equipment-client
KEYCLOAK_CLIENT_SECRET=
PORT=3000
VITE_API_BASE_URL=/api
VITE_AUTH_MODE=local
SQLSERVER_HOST=
SQLSERVER_PORT=1433
SQLSERVER_DATABASE=
SQLSERVER_USER=
SQLSERVER_PASSWORD=
SQLSERVER_SCHEMA=rpt
SQLSERVER_ENCRYPT=false
SQLSERVER_TRUST_CERT=true
HR_SYNC_ENABLED=false
HR_SYNC_SCHEDULE=03:00
HR_SYNC_SCHEDULES=
HR_SYNC_ALLOWED_STATUS_VALUES=3
HR_SYNC_INACTIVE_STATUS_VALUES=5
HR_SYNC_EXCLUDED_DEPARTMENTS=供应商,快递公司,外部交流
HR_SYNC_EXCLUDED_NAME_KEYWORDS=
APP_PUBLIC_BASE_URL=https://asset.example.com
YYD_NOTIFICATION_CENTER_URL=
YYD_NOTIFICATION_PROJECT_CODE=fixed_asset
YYD_NOTIFICATION_PROJECT_SECRET=
YYD_NOTIFICATION_SEND_ENABLED=false
YYD_NOTIFICATION_SEND_TEST_USER_IDS=
YYD_NOTIFICATION_SEND_FULL_ROLLOUT=false
YYD_NOTIFICATION_LOGIN_ENABLED=false
YYD_NOTIFICATION_LOGIN_TEST_USER_IDS=
YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT=false

32
.gitignore vendored
View File

@ -9,3 +9,35 @@ frontend/dist/
frontend/tsconfig.tsbuildinfo
frontend/vite.config.js
frontend/vite.config.d.ts
frontend/public/branding/custom-logo.*
frontend/public/branding/custom-favicon.*
backend/*.log
frontend/*.log
error.log
temp_migration_output/
8.xlsx
~$*.xlsx
migration_manual_overrides.json
行政手动补录清单*.xlsx
地址标准化候选.csv
01-login-page.png
02-dashboard-overview.png
03-equipment-list.png
04-borrow-management.png
05-settings-page.png
nul
.dotnet8/
print-agent-dotnet/**/bin/
print-agent-dotnet/**/obj/
print-agent-dotnet/**/*.user
print-agent-dotnet/dist/
downloads/**/*.exe
print-terminals-live*.png
temp_agent_team_test/
temp_db_purchase_dates.tsv
codex_release_files.txt
dingtalk-requests.txt
行政手动处理清单*.xlsx
temp/
temp_reports/
对话续接摘要_*.json

View File

@ -1,45 +1,63 @@
# Repository Guidelines
## 项目结构
仓库分为 `frontend/` 和 `backend/` 两部分。前端是 Vue 3 + Vite,主要代码在 `frontend/src/`:页面在 `src/views/`,通用组件在 `src/components/`,接口封装在 `src/api/`,状态管理在 `src/stores/`,路由在 `src/router/`。后端是 Express + TypeScript,代码在 `backend/src/`,按 `routes/`、`controllers/`、`middleware/`、`utils/` 分层。数据库模型统一在 `backend/prisma/schema.prisma`。部署相关文件在仓库根目录:`docker-compose.yml`、`Dockerfile`、`deploy.sh`。
## 1. 远程安全
## 构建、运行与开发命令
首次进入前后端目录分别执行 `npm install`。
- 服务器 `1.13.22.133` 有两套系统:
- 固定资产:`/opt/equipment-management`,容器前缀 `equipment-*`
- 摄影借还:`/opt/shebeiguanli`,容器前缀 `shebeiguanli-*`
- 任何远程操作只能针对固定资产系统。
- 不得进入 `/opt/shebeiguanli`,不得修改、停止、重建、删除 `shebeiguanli-*` 容器,也不得碰它的数据库、数据卷、配置。
- 部署、查日志、查库前,先确认目标路径、容器名、数据库名都属于固定资产系统。
- 远程自动化默认优先使用 `plink`。
- `cd frontend && npm run dev`:启动前端开发环境。
- `cd frontend && npm run build`:执行类型检查并打包前端。
- `cd backend && npm run dev`:用 `nodemon + ts-node` 启动后端。
- `cd backend && npm run build`:编译后端到 `dist/`。
- `cd backend && npm run prisma:generate`:Prisma 模型变更后重建客户端。
- `cd backend && npm run prisma:migrate`:生成并应用本地数据库迁移。
- `docker compose up --build`:同时启动 MySQL、后端、前端。
## 2. 协作方式
## 代码风格与命名
项目以 TypeScript 为主,现有代码统一使用 2 空格缩进。Vue 组件文件使用 PascalCase,如 `EquipmentForm.vue`;接口、store、工具模块使用语义化名称,如 `equipment.ts`、`storageLocation.ts`。新增功能时,尽量保持前端 API 名称、后端路由、控制器按同一业务域对应,方便排查问题。
- 本项目默认采用官方 `lead + 可复用子代理编排`。
- 用户只提目标、问题、期望效果,不需要做技术判断。
- 主代理默认承担 `lead`:负责理解需求、拆任务、判断是否启用子代理、整合结果、统一汇报。
- 主代理在 `spawn_agent` 前,默认先执行一次 `list_agents`,优先检查当前是否已有可复用的同职责子代理。
- 本项目默认团队分工:
- `frontend-worker`:使用官方 `worker`,只负责前端文件。
- `backend-worker`:使用官方 `worker`,只负责后端 / 数据库相关文件。
- `review-explorer`:使用官方 `explorer`,只负责代码审查、找问题、提风险,不直接改代码。
- 子代理按职责复用,不必每次换一套;但仍遵守官方能力边界,不自定义内建类型。
- 默认固定复用以上 3 个命名子代理;同职责任务优先继续派发给现有同名子代理,不要为相同职责反复新开线程。
- 默认同职责只保留 1 个活跃子代理:
- 前端任务优先发给 `frontend-worker`
- 后端任务优先发给 `backend-worker`
- 审查、排查风险优先发给 `review-explorer`
- 只有以下情况才允许新开同职责子代理:
- 当前不存在该职责子代理
- 现有子代理已经关闭或不可用
- 任务明确需要并行,且写入范围完全不重叠
- 给已存在子代理继续分派工作时,优先使用 `assign_task` 或 `send_message`,不要为了“清空上下文”而随手重建。
- 同一个功能周期内,尽量保留子代理连续上下文;不要刚做完一个相邻子任务就关闭再重开,避免重复解释背景。
- 只有在该子代理本轮职责已经完成、后续短期内不再需要,或继续保留会明显增加干扰时,才执行 `close_agent`。
- 如果现有同职责子代理正在处理相关任务,主代理应先判断是否可以复用同一线程继续推进;不要因为等待而机械式再开一个同类子代理。
- 子代理任务描述必须写清楚文件归属和边界,避免前后端、审查与实现之间职责漂移。
- 不魔改为“常驻团队系统”;子代理只用官方支持的能力边界。
- 本项目内所有子代理默认统一使用 `gpt-5.4`。
- 所有 `worker` 类子代理固定使用 `gpt-5.4 + xhigh` 思考等级。
- `explorer` / 其他子代理思考等级按任务复杂度选择,但最低不得低于 `medium`。
- 只有涉及线上、真实数据、密钥账号、证书、付费资源、不可逆操作时才暂停确认。
## 测试与验证
当前仓库没有现成自动化测试脚本,因此每次改动后至少执行 `frontend` 和 `backend` 的 `npm run build`。涉及接口改动时,还应手动验证对应页面流程,并检查 `GET /api/health`。涉及 Prisma 或数据库结构调整时,额外执行 `npm run prisma:generate`,确认相关增删改查正常。若后续补测试,建议使用 `*.spec.ts` 命名。
## 3. 前端规则
## 提交与合并请求
当前 Git 历史很少,只有初始提交,没有强约束格式。建议提交信息使用简短中文,写清“改了什么”,例如:`后端:修复借用记录筛选条件`。Pull Request 需要说明变更范围、验证方式、是否影响数据库或部署;前端页面改动建议附截图,接口或表结构改动要明确标注兼容性影响。
- 前端改动前先读:`D:\AI Code\GuDingZiChan\前端视觉规范.md`
- 视觉方向固定为:极简、统一、深色主题。
- 优先使用:`btn-primary`、`btn-secondary`、`btn-icon`、`input`、`select`、`card`、`panel`、`page-header`、`page-meta`、`section-title`、`field-stack`、`field-grid`
- 禁止继续使用旧类:`btn-apple-*`、`input-apple`、`card-apple`、`text-apple-*`、`bg-apple-*`、`text-system-label-*`、`bg-system-gray-*`、`border-system-gray-*`、`rounded-apple`、`surface-note`、`page-subtitle`、`section-subtitle`
- 页面间距优先统一用 `gap-4` / `gap-6`,并同时检查桌面端和移动端效果。
## 配置与安全
不要提交真实密码、数据库连接串或生产环境地址。`DATABASE_URL`、会话密钥、认证模式等应通过环境变量管理。修改 `docker-compose.yml`、认证配置或对外端口时,先确认是否会影响本地联调和线上部署。
## 4. 改动与验证
## 远程操作规则
服务器 `1.13.22.133` 同时运行两套系统:
- 固定资产系统:`/opt/equipment-management`,对应 `equipment-*` 容器
- 摄影棚摄影器材借还系统:`/opt/shebeiguanli`,对应 `shebeiguanli-*` 容器
后续远程自动化默认统一优先使用 `plink`,避免依赖人工输入密码。对服务器的代码同步、部署、日志查看和数据库检查,只能操作固定资产系统这套资源,不能进入 `shebeiguanli` 目录,也不能重建、停止或修改 `shebeiguanli-*` 容器。
## Multi-Agent 团队分工
推荐使用 4 个角色,保持团队小而清晰。
- 主控 / 集成代理:负责拆任务、安排子代理、收敛方案、做最终验收;默认可读全仓库,但不要直接承担大批量业务改动。
- 前端业务代理:只负责 `frontend/src/views/`、`frontend/src/components/`、`frontend/src/api/`、`frontend/src/stores/`、`frontend/src/router/`,处理页面、交互、表单、状态和前端接口联调。
- 后端业务代理:只负责 `backend/src/routes/`、`backend/src/controllers/`,处理资产、借还、搜索、统计、人员等业务接口。
- 数据与系统代理:只负责 `backend/prisma/`、`backend/src/middleware/`、`backend/src/utils/`、认证配置、`docker-compose.yml`、`Dockerfile`、`deploy.sh`,处理数据库结构、权限、认证、部署和环境问题。
协作时尽量避免跨边界改动:前端代理不直接改 Prisma,后端业务代理不直接动 Docker,数据与系统代理不直接改页面。
- 前端优先改:`frontend/src/views/`、`frontend/src/components/`、`frontend/src/api/`、`frontend/src/stores/`、`frontend/src/router/`
- 后端优先改:`backend/src/routes/`、`backend/src/controllers/`
- 数据库 / 部署 / 认证优先改:`backend/prisma/`、`backend/src/utils/`、`docker-compose.yml`、`Dockerfile`、`deploy.sh`
- 主要使用 TypeScript,保持现有 2 空格缩进;先读上下文,尽量复用现有实现,不做无关改动。
- 默认至少执行:
- `cd frontend && npm run build`
- `cd backend && npm run build`
- 涉及接口改动时,额外检查相关页面流程与 `GET /api/health`
- 涉及 Prisma 或表结构改动时,额外执行:`cd backend && npm run prisma:generate`
- 不提交真实密码、数据库连接串或生产地址;相关配置优先走环境变量。

View File

@ -25,6 +25,14 @@ chmod +x /opt/equipment-management/scripts/backup-equipment.sh
KEEP_BACKUPS=5 /opt/equipment-management/scripts/backup-equipment.sh
```
脚本不包含数据库密码默认值。密码读取顺序为:
1. 当前进程的 `DB_PASSWORD`
2. 当前进程的 `MYSQL_ROOT_PASSWORD`
3. 只读获取 `equipment-mysql` 容器环境中的 `MYSQL_ROOT_PASSWORD`
三种方式都无法取得密码时,备份会明确失败,不会继续生成不完整备份,也不会把密码打印到日志。
## 备份内容
每次执行会生成一个时间戳目录,包含:

View File

@ -74,20 +74,31 @@ $env:DEPLOY_PASSWORD="<部署密码>"
这个脚本会自动完成:
- 打包当前仓库源码
- 检查 Git 工作树,默认拒绝发布未提交改动
- 只打包后端、前端、下载清单和部署脚本等白名单文件
- 校验下载清单引用的打印助手/驱动安装包,并把对应二进制纳入发布包
- 上传到 `/opt/equipment-management`
- 只覆盖固定资产系统源码
- 校验发布包并保留服务器上的自定义品牌文件
- 只覆盖固定资产系统源码,不处理 `shebeiguanli-*`
- 调用服务器上的安全部署脚本
常用参数:
```powershell
.\scripts\publish-to-server.ps1 -DryRun
.\scripts\publish-to-server.ps1 -SkipDeploy
.\scripts\publish-to-server.ps1 -SkipBackup
.\scripts\publish-to-server.ps1 -DryRun -AllowDirty
```
- `-DryRun`:只显示发布文件数量、体积和白名单,不创建压缩包、不连接服务器
- `-SkipDeploy`:只同步代码,不执行服务器部署
- `-SkipBackup`:执行部署,但跳过备份,不建议常用
- `-AllowDirty`:显式允许应急发布未提交改动;仍只包含白名单文件,常规发布禁止使用
远端目录被强制锁定为 `/opt/equipment-management`,远端临时压缩包被锁定在 `/tmp/equipment-management-release.tar.gz`;即使误传参数,也不能覆盖 `/opt/shebeiguanli` 或其他目录。下载清单中 `server_release` / `company_archive` 类型引用的本地文件只要缺失,发布会在连接服务器前直接失败。
密码模式使用本机的 `plink` / `pscp`。建议设置 `DEPLOY_HOST_KEY` 固定服务器主机指纹;如果未设置,PuTTY 必须已经缓存并信任该主机密钥,否则批处理模式会安全失败。使用 SSH 密钥时走系统 OpenSSH,并使用 `StrictHostKeyChecking=accept-new`,不会再无条件关闭主机校验。
## 备份与回滚
@ -100,6 +111,8 @@ cd /opt/equipment-management
默认只保留最近 `3` 份备份,备份目录在 `/opt/backups/equipment-management`。详细说明见 `BACKUP.md`。
备份脚本不再提供数据库密码默认值。它会优先读取 `DB_PASSWORD` / `MYSQL_ROOT_PASSWORD`,未设置时只读获取 `equipment-mysql` 容器中的 `MYSQL_ROOT_PASSWORD`;仍无法取得时会直接终止备份,并且不会输出密码内容。
## 人员与组织同步
后端已预留钉钉中台同步能力,支持同步:
@ -136,8 +149,9 @@ HR_SYNC_EXCLUDED_NAME_KEYWORDS=
- `HR_SYNC_ENABLED=true` 时,后端会启动每日定时同步
- `HR_SYNC_SCHEDULE` 建议使用 `03:00`
- `HR_SYNC_ALLOWED_STATUS_VALUES` 用来限定“哪些源状态算正式员工”,当前建议 `3`
- `HR_SYNC_ALLOWED_STATUS_VALUES` 用来标记“哪些源状态算正式员工”,当前建议 `3`
- `HR_SYNC_INACTIVE_STATUS_VALUES` 用来标记源系统里的非在职状态码
- 当前组织同步默认以 `hr_employee_register` 为主口径,`hr_employee` 只用于补充姓名、手机、邮箱、岗位等字段
- `HR_SYNC_EXCLUDED_DEPARTMENTS` 用来排除供应商、快递公司等外部对象
- `HR_SYNC_EXCLUDED_NAME_KEYWORDS` 可选,用来额外排除名字特征
- 如果源库是较老版本 SQL Server,建议 `SQLSERVER_ENCRYPT=false`

8
backend/.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist
uploads
coverage
*.log
.env
.env.*
.git

View File

@ -1,5 +1,7 @@
FROM node:20-alpine
ENV NODE_ENV=production
# 安装OpenSSL
RUN apk add --no-cache openssl openssl-dev
@ -7,7 +9,7 @@ WORKDIR /app
# 安装依赖
COPY package*.json ./
RUN npm install --registry=https://registry.npmmirror.com
RUN npm install --include=dev --registry=https://registry.npmmirror.com
# 复制源代码
COPY . .

4171
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test:print": "node --require ts-node/register --test src/services/__tests__/printService.test.ts",
"seed:demo": "node scripts/seedDemoData.js",
"repair:mojibake": "node scripts/repairMojibake.js",
"prisma:generate": "prisma generate",
@ -26,7 +27,6 @@
"express-session": "^1.17.3",
"keycloak-connect": "^23.0.7",
"mssql": "^11.0.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.0.0"
},
"devDependencies": {
@ -34,11 +34,26 @@
"@types/cors": "^2.8.0",
"@types/express": "^4.17.0",
"@types/express-session": "^1.17.7",
"@types/multer": "^1.4.7",
"@types/node": "^20.0.0",
"nodemon": "^3.0.0",
"prisma": "^5.0.0",
"ts-node": "^10.9.0",
"typescript": "^5.0.0"
},
"overrides": {
"exceljs": {
"uuid": "11.1.1"
},
"@azure-rest/core-client": "2.7.0",
"@azure/abort-controller": "2.1.2",
"@azure/core-auth": "1.10.1",
"@azure/core-client": "1.10.2",
"@azure/core-paging": "1.6.2",
"@azure/core-rest-pipeline": "1.24.0",
"@azure/core-tracing": "1.3.1",
"@azure/core-util": "1.13.1",
"@azure/identity": "4.13.1",
"@azure/logger": "1.3.0",
"@typespec/ts-http-runtime": "0.3.6"
}
}

View File

@ -0,0 +1,47 @@
ALTER TABLE `person`
ADD COLUMN `main_department_id` INT NULL;
CREATE INDEX `person_main_department_id_idx`
ON `person`(`main_department_id`);
ALTER TABLE `person`
ADD CONSTRAINT `person_main_department_id_fkey`
FOREIGN KEY (`main_department_id`) REFERENCES `department`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE;
CREATE TABLE `person_department` (
`person_id` INT NOT NULL,
`department_id` INT NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`person_id`, `department_id`),
INDEX `person_department_department_id_idx`(`department_id`),
CONSTRAINT `person_department_person_id_fkey`
FOREIGN KEY (`person_id`) REFERENCES `person`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `person_department_department_id_fkey`
FOREIGN KEY (`department_id`) REFERENCES `department`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE `person` AS `p`
LEFT JOIN `department` AS `d`
ON `d`.`source_group_id` = `p`.`source_main_department_id`
SET `p`.`main_department_id` = `d`.`id`
WHERE `p`.`source_system` = 'ding_hr'
AND `p`.`source_main_department_id` IS NOT NULL;
INSERT INTO `person_department` (`person_id`, `department_id`, `created_at`, `updated_at`)
SELECT DISTINCT
`id`,
`main_department_id`,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
FROM `person`
WHERE `main_department_id` IS NOT NULL
ON DUPLICATE KEY UPDATE
`updated_at` = VALUES(`updated_at`);

View File

@ -0,0 +1,77 @@
CREATE TABLE `sync_job` (
`id` INT NOT NULL AUTO_INCREMENT,
`sync_type` VARCHAR(30) NOT NULL,
`trigger_mode` VARCHAR(30) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'success',
`operator` VARCHAR(50) NULL,
`summary` TEXT NULL,
`result_payload` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `sync_job_sync_type_created_at_idx`(`sync_type`, `created_at`),
INDEX `sync_job_status_created_at_idx`(`status`, `created_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `sync_change` (
`id` INT NOT NULL AUTO_INCREMENT,
`sync_job_id` INT NOT NULL,
`entity_type` VARCHAR(20) NOT NULL,
`entity_key` VARCHAR(100) NULL,
`entity_name` VARCHAR(100) NOT NULL,
`change_type` VARCHAR(50) NOT NULL,
`title` VARCHAR(150) NOT NULL,
`detail` TEXT NULL,
`before_snapshot` TEXT NULL,
`after_snapshot` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `sync_change_sync_job_id_idx`(`sync_job_id`),
INDEX `sync_change_entity_type_change_type_idx`(`entity_type`, `change_type`),
CONSTRAINT `sync_change_sync_job_id_fkey`
FOREIGN KEY (`sync_job_id`) REFERENCES `sync_job`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `notification` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`content` TEXT NULL,
`type` VARCHAR(30) NOT NULL DEFAULT 'info',
`level` VARCHAR(20) NOT NULL DEFAULT 'info',
`action_url` VARCHAR(255) NULL,
`related_sync_job_id` INT NULL,
`metadata` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `notification_related_sync_job_id_idx`(`related_sync_job_id`),
INDEX `notification_created_at_idx`(`created_at`),
CONSTRAINT `notification_related_sync_job_id_fkey`
FOREIGN KEY (`related_sync_job_id`) REFERENCES `sync_job`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `user_notification` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`notification_id` INT NOT NULL,
`is_read` BOOLEAN NOT NULL DEFAULT false,
`read_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `user_notification_user_id_notification_id_key`(`user_id`, `notification_id`),
INDEX `user_notification_user_id_is_read_created_at_idx`(`user_id`, `is_read`, `created_at`),
CONSTRAINT `user_notification_user_id_fkey`
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `user_notification_notification_id_fkey`
FOREIGN KEY (`notification_id`) REFERENCES `notification`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,184 @@
ALTER TABLE `person`
ADD COLUMN `source_department_name` VARCHAR(100) NULL AFTER `source_dept_ids`,
ADD COLUMN `source_main_department_name` VARCHAR(100) NULL AFTER `source_department_name`,
ADD COLUMN `department_sync_bucket` VARCHAR(20) NULL AFTER `source_main_department_name`;
CREATE INDEX `person_department_sync_bucket_idx`
ON `person`(`department_sync_bucket`);
CREATE TABLE `inventory_task` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(150) NOT NULL,
`description` TEXT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`public_token` VARCHAR(120) NOT NULL,
`scope_department_id` INT NULL,
`scope_department_name` VARCHAR(100) NULL,
`scope_asset_type_id` INT NULL,
`scope_asset_type_name` VARCHAR(100) NULL,
`scope_snapshot_json` TEXT NULL,
`participants_count` INT NOT NULL DEFAULT 0,
`items_count` INT NOT NULL DEFAULT 0,
`completed_count` INT NOT NULL DEFAULT 0,
`exception_count` INT NOT NULL DEFAULT 0,
`unassigned_count` INT NOT NULL DEFAULT 0,
`created_by` INT NOT NULL,
`closed_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `inventory_task_public_token_key`(`public_token`),
INDEX `inventory_task_status_created_at_idx`(`status`, `created_at`),
INDEX `inventory_task_scope_department_id_status_idx`(`scope_department_id`, `status`),
INDEX `inventory_task_scope_asset_type_id_status_idx`(`scope_asset_type_id`, `status`),
INDEX `inventory_task_created_by_idx`(`created_by`),
PRIMARY KEY (`id`),
CONSTRAINT `inventory_task_scope_department_id_fkey`
FOREIGN KEY (`scope_department_id`) REFERENCES `department`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_scope_asset_type_id_fkey`
FOREIGN KEY (`scope_asset_type_id`) REFERENCES `asset_type`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_created_by_fkey`
FOREIGN KEY (`created_by`) REFERENCES `user`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `inventory_task_participant` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL,
`person_id` INT NULL,
`person_name` VARCHAR(50) NOT NULL,
`person_nickname` VARCHAR(50) NOT NULL,
`person_source_userid` VARCHAR(100) NULL,
`department_id` INT NULL,
`department_name` VARCHAR(100) NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`confirmed_at` DATETIME(3) NULL,
`last_action_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `inventory_task_participant_task_id_person_id_key`(`task_id`, `person_id`),
INDEX `inventory_task_participant_task_id_status_idx`(`task_id`, `status`),
INDEX `inventory_task_participant_task_id_person_id_idx`(`task_id`, `person_id`),
INDEX `inventory_task_participant_task_id_person_nickname_idx`(`task_id`, `person_nickname`),
INDEX `inventory_task_participant_department_id_idx`(`department_id`),
PRIMARY KEY (`id`),
CONSTRAINT `inventory_task_participant_task_id_fkey`
FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_participant_person_id_fkey`
FOREIGN KEY (`person_id`) REFERENCES `person`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_participant_department_id_fkey`
FOREIGN KEY (`department_id`) REFERENCES `department`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `inventory_task_item` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL,
`participant_id` INT NULL,
`equipment_id` INT NOT NULL,
`equipment_code` VARCHAR(50) NOT NULL,
`equipment_name` VARCHAR(100) NOT NULL,
`equipment_model` VARCHAR(100) NULL,
`equipment_type` VARCHAR(50) NOT NULL,
`asset_type_id` INT NULL,
`asset_type_name` VARCHAR(100) NULL,
`snapshot_department_id` INT NULL,
`snapshot_department_name` VARCHAR(100) NULL,
`snapshot_person_id` INT NULL,
`snapshot_person_name` VARCHAR(50) NULL,
`snapshot_person_nickname` VARCHAR(50) NULL,
`snapshot_status` VARCHAR(20) NULL,
`source_borrow_record_id` INT NULL,
`is_unassigned` BOOLEAN NOT NULL DEFAULT false,
`check_status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`checked_by_participant_id` INT NULL,
`checked_at` DATETIME(3) NULL,
`exception_type` VARCHAR(30) NULL,
`exception_note` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `inventory_task_item_task_id_equipment_id_key`(`task_id`, `equipment_id`),
INDEX `inventory_task_item_task_id_participant_id_check_status_idx`(`task_id`, `participant_id`, `check_status`),
INDEX `inventory_task_item_task_id_equipment_id_idx`(`task_id`, `equipment_id`),
INDEX `inventory_task_item_task_id_is_unassigned_idx`(`task_id`, `is_unassigned`),
INDEX `inventory_task_item_task_id_check_status_idx`(`task_id`, `check_status`),
INDEX `inventory_task_item_checked_by_participant_id_idx`(`checked_by_participant_id`),
INDEX `inventory_task_item_snapshot_department_id_idx`(`snapshot_department_id`),
INDEX `inventory_task_item_snapshot_person_id_idx`(`snapshot_person_id`),
PRIMARY KEY (`id`),
CONSTRAINT `inventory_task_item_task_id_fkey`
FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_participant_id_fkey`
FOREIGN KEY (`participant_id`) REFERENCES `inventory_task_participant`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_checked_by_participant_id_fkey`
FOREIGN KEY (`checked_by_participant_id`) REFERENCES `inventory_task_participant`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_equipment_id_fkey`
FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_asset_type_id_fkey`
FOREIGN KEY (`asset_type_id`) REFERENCES `asset_type`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_snapshot_department_id_fkey`
FOREIGN KEY (`snapshot_department_id`) REFERENCES `department`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_item_snapshot_person_id_fkey`
FOREIGN KEY (`snapshot_person_id`) REFERENCES `person`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `inventory_task_action` (
`id` INT NOT NULL AUTO_INCREMENT,
`task_id` INT NOT NULL,
`participant_id` INT NULL,
`item_id` INT NULL,
`action_type` VARCHAR(40) NOT NULL,
`action_payload` TEXT NULL,
`ip_address` VARCHAR(50) NULL,
`user_agent` VARCHAR(255) NULL,
`operator_user_id` INT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `inventory_task_action_task_id_created_at_idx`(`task_id`, `created_at`),
INDEX `inventory_task_action_participant_id_idx`(`participant_id`),
INDEX `inventory_task_action_item_id_idx`(`item_id`),
INDEX `inventory_task_action_operator_user_id_idx`(`operator_user_id`),
PRIMARY KEY (`id`),
CONSTRAINT `inventory_task_action_task_id_fkey`
FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_action_participant_id_fkey`
FOREIGN KEY (`participant_id`) REFERENCES `inventory_task_participant`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_action_item_id_fkey`
FOREIGN KEY (`item_id`) REFERENCES `inventory_task_item`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `inventory_task_action_operator_user_id_fkey`
FOREIGN KEY (`operator_user_id`) REFERENCES `user`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,17 @@
ALTER TABLE `sync_change`
ADD COLUMN `processing_status` VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER `after_snapshot`,
ADD COLUMN `processing_notes` TEXT NULL AFTER `processing_status`,
ADD COLUMN `processed_by` INT NULL AFTER `processing_notes`,
ADD COLUMN `processed_at` DATETIME(3) NULL AFTER `processed_by`;
CREATE INDEX `sync_change_processing_status_created_at_idx`
ON `sync_change`(`processing_status`, `created_at`);
CREATE INDEX `sync_change_processed_by_idx`
ON `sync_change`(`processed_by`);
ALTER TABLE `sync_change`
ADD CONSTRAINT `sync_change_processed_by_fkey`
FOREIGN KEY (`processed_by`) REFERENCES `user`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE;

View File

@ -0,0 +1,83 @@
CREATE TABLE `print_terminal` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`platform` VARCHAR(30) NOT NULL,
`machine_name` VARCHAR(100) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'offline',
`is_default` BOOLEAN NOT NULL DEFAULT false,
`last_seen_at` DATETIME(3) NULL,
`selected_printer_name` VARCHAR(150) NULL,
`printer_snapshot_json` TEXT NULL,
`label_profile_json` TEXT NULL,
`terminal_token_hash` VARCHAR(128) NOT NULL,
`paired_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`current_error_message` VARCHAR(255) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `print_terminal_terminal_token_hash_key`(`terminal_token_hash`),
INDEX `print_terminal_status_idx`(`status`),
INDEX `print_terminal_is_default_idx`(`is_default`),
INDEX `print_terminal_last_seen_at_idx`(`last_seen_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `print_terminal_pair_code` (
`id` INT NOT NULL AUTO_INCREMENT,
`code` VARCHAR(32) NOT NULL,
`expires_at` DATETIME(3) NOT NULL,
`created_by` INT NOT NULL,
`used_at` DATETIME(3) NULL,
`used_by_terminal_id` INT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `print_terminal_pair_code_code_key`(`code`),
INDEX `print_terminal_pair_code_expires_at_idx`(`expires_at`),
INDEX `print_terminal_pair_code_created_by_idx`(`created_by`),
INDEX `print_terminal_pair_code_used_by_terminal_id_idx`(`used_by_terminal_id`),
PRIMARY KEY (`id`),
CONSTRAINT `print_terminal_pair_code_created_by_fkey`
FOREIGN KEY (`created_by`) REFERENCES `user`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE,
CONSTRAINT `print_terminal_pair_code_used_by_terminal_id_fkey`
FOREIGN KEY (`used_by_terminal_id`) REFERENCES `print_terminal`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `print_job` (
`id` INT NOT NULL AUTO_INCREMENT,
`job_type` VARCHAR(30) NOT NULL DEFAULT 'asset_label',
`status` VARCHAR(20) NOT NULL DEFAULT 'queued',
`target_terminal_id` INT NOT NULL,
`claimed_by_terminal_id` INT NULL,
`requested_by` INT NOT NULL,
`payload_json` TEXT NOT NULL,
`error_message` TEXT NULL,
`claimed_at` DATETIME(3) NULL,
`started_at` DATETIME(3) NULL,
`completed_at` DATETIME(3) NULL,
`attempt_count` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `print_job_status_created_at_idx`(`status`, `created_at`),
INDEX `print_job_target_terminal_id_status_created_at_idx`(`target_terminal_id`, `status`, `created_at`),
INDEX `print_job_requested_by_idx`(`requested_by`),
INDEX `print_job_claimed_by_terminal_id_idx`(`claimed_by_terminal_id`),
PRIMARY KEY (`id`),
CONSTRAINT `print_job_target_terminal_id_fkey`
FOREIGN KEY (`target_terminal_id`) REFERENCES `print_terminal`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE,
CONSTRAINT `print_job_claimed_by_terminal_id_fkey`
FOREIGN KEY (`claimed_by_terminal_id`) REFERENCES `print_terminal`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT `print_job_requested_by_fkey`
FOREIGN KEY (`requested_by`) REFERENCES `user`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,5 @@
ALTER TABLE `person`
ADD COLUMN `job_number` VARCHAR(50) NULL;
CREATE UNIQUE INDEX `person_job_number_key`
ON `person`(`job_number`);

View File

@ -0,0 +1,291 @@
ALTER TABLE `user`
ADD COLUMN `person_id` INT NULL;
CREATE UNIQUE INDEX `user_person_id_key`
ON `user`(`person_id`);
ALTER TABLE `user`
ADD CONSTRAINT `user_person_id_fkey`
FOREIGN KEY (`person_id`) REFERENCES `person`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE;
ALTER TABLE `disposal_record`
ADD COLUMN `completed_by` INT NULL,
ADD COLUMN `pre_status` VARCHAR(20) NOT NULL DEFAULT '可用',
ADD COLUMN `reject_reason` TEXT NULL,
ADD COLUMN `completion_notes` TEXT NULL,
ADD COLUMN `reminded_at` DATETIME(3) NULL;
CREATE INDEX `disposal_record_applied_by_created_at_idx`
ON `disposal_record`(`applied_by`, `created_at`);
CREATE INDEX `disposal_record_approved_by_approved_at_idx`
ON `disposal_record`(`approved_by`, `approved_at`);
CREATE INDEX `disposal_record_completed_by_completed_at_idx`
ON `disposal_record`(`completed_by`, `completed_at`);
ALTER TABLE `disposal_record`
ADD CONSTRAINT `disposal_record_completed_by_fkey`
FOREIGN KEY (`completed_by`) REFERENCES `user`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE;
CREATE TABLE `disposal_item` (
`id` INT NOT NULL AUTO_INCREMENT,
`disposal_id` INT NOT NULL,
`equipment_id` INT NOT NULL,
`equipment_code` VARCHAR(50) NOT NULL,
`equipment_name` VARCHAR(100) NOT NULL,
`parent_equipment_id` INT NULL,
`parent_equipment_code` VARCHAR(50) NULL,
`pre_status` VARCHAR(20) NOT NULL,
`snapshot_json` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `disposal_item_disposal_id_equipment_id_key`(`disposal_id`, `equipment_id`),
INDEX `disposal_item_equipment_id_idx`(`equipment_id`),
INDEX `disposal_item_parent_equipment_id_idx`(`parent_equipment_id`),
PRIMARY KEY (`id`),
CONSTRAINT `disposal_item_disposal_id_fkey`
FOREIGN KEY (`disposal_id`) REFERENCES `disposal_record`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `disposal_item_equipment_id_fkey`
FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `disposal_action` (
`id` INT NOT NULL AUTO_INCREMENT,
`disposal_id` INT NOT NULL,
`action_type` VARCHAR(30) NOT NULL,
`operator_id` INT NULL,
`from_status` VARCHAR(20) NULL,
`to_status` VARCHAR(20) NULL,
`detail` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `disposal_action_disposal_id_created_at_idx`(`disposal_id`, `created_at`),
INDEX `disposal_action_operator_id_idx`(`operator_id`),
PRIMARY KEY (`id`),
CONSTRAINT `disposal_action_disposal_id_fkey`
FOREIGN KEY (`disposal_id`) REFERENCES `disposal_record`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `disposal_action_operator_id_fkey`
FOREIGN KEY (`operator_id`) REFERENCES `user`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `asset_transfer_record` (
`id` INT NOT NULL AUTO_INCREMENT,
`root_equipment_id` INT NOT NULL,
`action_type` VARCHAR(20) NOT NULL,
`from_department_id` INT NULL,
`from_department_name` VARCHAR(100) NULL,
`to_department_id` INT NULL,
`to_department_name` VARCHAR(100) NULL,
`reason` TEXT NULL,
`operated_by` INT NOT NULL,
`operated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `asset_transfer_record_root_equipment_id_operated_at_idx`(`root_equipment_id`, `operated_at`),
INDEX `asset_transfer_record_operated_by_idx`(`operated_by`),
INDEX `asset_transfer_record_action_type_operated_at_idx`(`action_type`, `operated_at`),
PRIMARY KEY (`id`),
CONSTRAINT `asset_transfer_record_root_equipment_id_fkey`
FOREIGN KEY (`root_equipment_id`) REFERENCES `equipment`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE,
CONSTRAINT `asset_transfer_record_operated_by_fkey`
FOREIGN KEY (`operated_by`) REFERENCES `user`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `asset_transfer_item` (
`id` INT NOT NULL AUTO_INCREMENT,
`transfer_id` INT NOT NULL,
`equipment_id` INT NOT NULL,
`equipment_code` VARCHAR(50) NOT NULL,
`equipment_name` VARCHAR(100) NOT NULL,
`parent_equipment_id` INT NULL,
`from_department_id` INT NULL,
`to_department_id` INT NULL,
`storage_location_id` INT NULL,
`snapshot_status` VARCHAR(20) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `asset_transfer_item_transfer_id_equipment_id_key`(`transfer_id`, `equipment_id`),
INDEX `asset_transfer_item_equipment_id_created_at_idx`(`equipment_id`, `created_at`),
INDEX `asset_transfer_item_parent_equipment_id_idx`(`parent_equipment_id`),
PRIMARY KEY (`id`),
CONSTRAINT `asset_transfer_item_transfer_id_fkey`
FOREIGN KEY (`transfer_id`) REFERENCES `asset_transfer_record`(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `asset_transfer_item_equipment_id_fkey`
FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `dingtalk_delivery` (
`id` INT NOT NULL AUTO_INCREMENT,
`idempotency_key` VARCHAR(191) NOT NULL,
`event_type` VARCHAR(50) NOT NULL,
`business_type` VARCHAR(30) NOT NULL,
`business_id` INT NOT NULL,
`recipient_user_id` INT NOT NULL,
`recipient_dingtalk_userid` VARCHAR(100) NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`attempt_count` INT NOT NULL DEFAULT 0,
`max_attempts` INT NOT NULL DEFAULT 5,
`next_attempt_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`lease_owner` VARCHAR(100) NULL,
`lease_expires_at` DATETIME(3) NULL,
`dingtalk_task_id` VARCHAR(100) NULL,
`payload_json` TEXT NOT NULL,
`last_error_code` VARCHAR(100) NULL,
`last_error_message` TEXT NULL,
`sent_at` DATETIME(3) NULL,
`read_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `dingtalk_delivery_idempotency_key_key`(`idempotency_key`),
INDEX `dingtalk_delivery_status_next_attempt_at_idx`(`status`, `next_attempt_at`),
INDEX `dingtalk_delivery_lease_expires_at_idx`(`lease_expires_at`),
INDEX `dingtalk_delivery_business_type_business_id_idx`(`business_type`, `business_id`),
INDEX `dingtalk_delivery_recipient_user_id_created_at_idx`(`recipient_user_id`, `created_at`),
INDEX `dingtalk_delivery_dingtalk_task_id_idx`(`dingtalk_task_id`),
PRIMARY KEY (`id`),
CONSTRAINT `dingtalk_delivery_recipient_user_id_fkey`
FOREIGN KEY (`recipient_user_id`) REFERENCES `user`(`id`)
ON DELETE RESTRICT
ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
INSERT INTO `disposal_item` (
`disposal_id`,
`equipment_id`,
`equipment_code`,
`equipment_name`,
`parent_equipment_id`,
`parent_equipment_code`,
`pre_status`,
`snapshot_json`
)
SELECT
disposal.`id`,
equipment.`id`,
equipment.`equipment_code`,
equipment.`equipment_name`,
equipment.`parent_id`,
parent.`equipment_code`,
disposal.`pre_status`,
JSON_OBJECT(
'equipment_code', equipment.`equipment_code`,
'equipment_name', equipment.`equipment_name`,
'equipment_model', equipment.`equipment_model`,
'equipment_type', equipment.`equipment_type`,
'department_id', equipment.`department_id`,
'storage_location_id', equipment.`storage_location_id`,
'parent_id', equipment.`parent_id`
)
FROM `disposal_record` disposal
INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id`
WHERE NOT EXISTS (
SELECT 1
FROM `disposal_item` existing
WHERE existing.`disposal_id` = disposal.`id`
AND existing.`equipment_id` = equipment.`id`
);
-- 旧版报废申请会将根资产及当时挂载的后代统一进入待报废,
-- 驳回时又统一恢复为“可用”。因此只对仍未完成的历史单按当前关系递归补录,
-- 后代 pre_status 按旧版可逆行为记为“可用”;已驳回/已完成单不猜测后续可能已变化的挂载关系。
INSERT INTO `disposal_item` (
`disposal_id`,
`equipment_id`,
`equipment_code`,
`equipment_name`,
`parent_equipment_id`,
`parent_equipment_code`,
`pre_status`,
`snapshot_json`
)
WITH RECURSIVE `active_disposal_tree` AS (
SELECT
disposal.`id` AS `disposal_id`,
equipment.`id` AS `equipment_id`,
equipment.`parent_id` AS `parent_equipment_id`,
0 AS `depth`,
CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`
FROM `disposal_record` disposal
INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
WHERE disposal.`status` IN ('pending', 'approved')
UNION ALL
SELECT
tree.`disposal_id`,
child.`id`,
child.`parent_id`,
tree.`depth` + 1,
CONCAT(tree.`visited_path`, child.`id`, '/')
FROM `active_disposal_tree` tree
INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id`
WHERE tree.`depth` < 100
AND LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) = 0
)
SELECT
tree.`disposal_id`,
equipment.`id`,
equipment.`equipment_code`,
equipment.`equipment_name`,
equipment.`parent_id`,
parent.`equipment_code`,
'可用',
JSON_OBJECT(
'equipment_code', equipment.`equipment_code`,
'equipment_name', equipment.`equipment_name`,
'equipment_model', equipment.`equipment_model`,
'equipment_type', equipment.`equipment_type`,
'department_id', equipment.`department_id`,
'storage_location_id', equipment.`storage_location_id`,
'parent_id', equipment.`parent_id`
)
FROM `active_disposal_tree` tree
INNER JOIN `equipment` equipment ON equipment.`id` = tree.`equipment_id`
LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id`
WHERE tree.`depth` > 0
AND NOT EXISTS (
SELECT 1
FROM `disposal_item` existing
WHERE existing.`disposal_id` = tree.`disposal_id`
AND existing.`equipment_id` = tree.`equipment_id`
);
INSERT INTO `disposal_action` (
`disposal_id`,
`action_type`,
`operator_id`,
`from_status`,
`to_status`,
`detail`,
`created_at`
)
SELECT
disposal.`id`,
'legacy_imported',
disposal.`applied_by`,
NULL,
disposal.`status`,
'历史报废单迁移补录',
disposal.`created_at`
FROM `disposal_record` disposal;

View File

@ -0,0 +1,140 @@
-- 迁移前只读核查必须在一个一致性快照中同时生成:
-- 1. 未完成报废单数量;2. 缺失根资产/资产树异常数量;3. 供人工核对及计算指纹的完整资产树。
-- deploy.sh 会展示并哈希同一批 REVIEW_ROW,禁止把展示与指纹拆成两次查询。
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;
WITH RECURSIVE `active_disposals` AS (
SELECT
disposal.`id`,
disposal.`status`,
disposal.`equipment_id`,
disposal.`equipment_code`,
disposal.`applied_at`,
disposal.`applied_by`,
disposal.`created_at`,
disposal.`updated_at`
FROM `disposal_record` disposal
WHERE disposal.`status` IN ('pending', 'approved')
), `active_disposal_tree` AS (
SELECT
disposal.`id` AS `disposal_id`,
equipment.`id` AS `equipment_id`,
equipment.`equipment_code`,
equipment.`equipment_name`,
equipment.`equipment_model`,
equipment.`equipment_type`,
equipment.`parent_id`,
parent.`equipment_code` AS `parent_equipment_code`,
equipment.`status` AS `current_status`,
equipment.`department_id`,
equipment.`storage_location_id`,
equipment.`mount_status`,
equipment.`updated_at` AS `equipment_updated_at`,
0 AS `depth`,
CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`,
0 AS `cycle_detected`
FROM `active_disposals` disposal
INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id`
UNION ALL
SELECT
tree.`disposal_id`,
child.`id`,
child.`equipment_code`,
child.`equipment_name`,
child.`equipment_model`,
child.`equipment_type`,
child.`parent_id`,
tree.`equipment_code`,
child.`status`,
child.`department_id`,
child.`storage_location_id`,
child.`mount_status`,
child.`updated_at`,
tree.`depth` + 1,
CONCAT(tree.`visited_path`, child.`id`, '/'),
LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) > 0
FROM `active_disposal_tree` tree
INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id`
WHERE tree.`depth` < 100
AND tree.`cycle_detected` = 0
), `review_rows` AS (
SELECT
1 AS `sort_group`,
CONCAT('D:', LPAD(disposal.`id`, 20, '0')) AS `sort_key`,
CAST(JSON_OBJECT(
'record_type', 'disposal',
'disposal_id', disposal.`id`,
'status', disposal.`status`,
'root_equipment_id', disposal.`equipment_id`,
'root_equipment_code', disposal.`equipment_code`,
'applied_at', DATE_FORMAT(disposal.`applied_at`, '%Y-%m-%dT%H:%i:%s.%f'),
'applied_by', disposal.`applied_by`,
'created_at', DATE_FORMAT(disposal.`created_at`, '%Y-%m-%dT%H:%i:%s.%f'),
'updated_at', DATE_FORMAT(disposal.`updated_at`, '%Y-%m-%dT%H:%i:%s.%f')
) AS CHAR) AS `payload`
FROM `active_disposals` disposal
UNION ALL
SELECT
2 AS `sort_group`,
CONCAT(
'T:', LPAD(tree.`disposal_id`, 20, '0'), ':',
LPAD(tree.`depth`, 3, '0'), ':', LPAD(tree.`equipment_id`, 20, '0')
) AS `sort_key`,
CAST(JSON_OBJECT(
'record_type', 'asset_tree',
'disposal_id', tree.`disposal_id`,
'equipment_id', tree.`equipment_id`,
'equipment_code', tree.`equipment_code`,
'equipment_name', tree.`equipment_name`,
'equipment_model', tree.`equipment_model`,
'equipment_type', tree.`equipment_type`,
'parent_id', tree.`parent_id`,
'parent_equipment_code', tree.`parent_equipment_code`,
'current_status', tree.`current_status`,
'department_id', tree.`department_id`,
'storage_location_id', tree.`storage_location_id`,
'mount_status', tree.`mount_status`,
'equipment_updated_at', DATE_FORMAT(tree.`equipment_updated_at`, '%Y-%m-%dT%H:%i:%s.%f'),
'depth', tree.`depth`,
'cycle_detected', tree.`cycle_detected`
) AS CHAR) AS `payload`
FROM `active_disposal_tree` tree
), `precheck_output` AS (
SELECT 0 AS `sort_group`, '1' AS `sort_key`, 'MISSING_ROOT_COUNT' AS `record_type`,
CAST(COUNT(*) AS CHAR) AS `payload`
FROM `active_disposals` disposal
LEFT JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
WHERE equipment.`id` IS NULL
UNION ALL
SELECT 0, '2', 'TREE_ANOMALY_COUNT', CAST(COUNT(*) AS CHAR)
FROM `active_disposal_tree`
WHERE `cycle_detected` = 1 OR `depth` >= 100
UNION ALL
SELECT 0, '3', 'ACTIVE_DISPOSAL_COUNT', CAST(COUNT(*) AS CHAR)
FROM `active_disposals`
UNION ALL
SELECT 0, '4', 'REVIEW_ROW_COUNT', CAST(COUNT(*) AS CHAR)
FROM `review_rows`
UNION ALL
SELECT `sort_group`, `sort_key`, 'REVIEW_ROW', `payload`
FROM `review_rows`
)
SELECT `record_type`, `payload`
FROM `precheck_output`
ORDER BY `sort_group`, `sort_key`;
COMMIT;

View File

@ -0,0 +1,2 @@
ALTER TABLE `dingtalk_delivery`
ADD COLUMN `provider_type` VARCHAR(20) NOT NULL DEFAULT 'work_notice' AFTER `lease_expires_at`;

View File

@ -0,0 +1,24 @@
-- 旧版驳回动作会把驳回原因写入 notes。只有 rejected 状态语义明确,才安全回填。
UPDATE `disposal_record`
SET `reject_reason` = `notes`
WHERE `status` = 'rejected'
AND (`reject_reason` IS NULL OR TRIM(`reject_reason`) = '')
AND `notes` IS NOT NULL
AND TRIM(`notes`) <> '';
-- completed 的旧 notes 可能是申请备注,也可能是处置备注,无法可靠区分。
-- 因此故意保留在 notes 中作为“历史备注”,不写入 completion_notes。
-- 历史版本曾把身份证号写入 borrow_record.person_identifier。
-- 只清理可明确识别为 15/18 位身份证、且不等于当前员工工号的旧值,避免误删合法工号。
UPDATE `borrow_record` borrow_record
INNER JOIN `person` person ON person.`id` = borrow_record.`person_id`
SET borrow_record.`person_identifier` = NULL
WHERE (
borrow_record.`person_identifier` REGEXP '^[1-9][0-9]{14}$'
OR borrow_record.`person_identifier` REGEXP '^[1-9][0-9]{16}[0-9Xx]$'
)
AND (
person.`job_number` IS NULL
OR borrow_record.`person_identifier` <> person.`job_number`
);

View File

@ -0,0 +1,30 @@
-- Web 会话迁移到现有 MySQL,后端重启后本地登录、钉钉免登和公开盘点会话仍可恢复。
CREATE TABLE `web_session` (
`sid` VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`data` LONGTEXT NOT NULL,
`expires_at` DATETIME(3) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`sid`),
INDEX `web_session_expires_at_idx` (`expires_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 每个钉钉投递失败异常只生成一次站内待处理记录。
ALTER TABLE `dingtalk_delivery`
ADD COLUMN `failure_notified_at` DATETIME(3) NULL;
-- 旧失败任务已在历史设置页中可查,本次上线不把它们重新制造成未读告警。
UPDATE `dingtalk_delivery`
SET `failure_notified_at` = COALESCE(`updated_at`, `created_at`, CURRENT_TIMESTAMP(3))
WHERE `status` = 'failed';
-- 历史组织同步消息保留为系统记录,但不再继续制造未读红点。
UPDATE `user_notification` user_notification
INNER JOIN `notification` notification
ON notification.`id` = user_notification.`notification_id`
SET
user_notification.`is_read` = 1,
user_notification.`read_at` = COALESCE(user_notification.`read_at`, CURRENT_TIMESTAMP(3))
WHERE notification.`type` = 'hr_sync'
AND user_notification.`is_read` = 0;

View File

@ -0,0 +1,31 @@
-- 新投递固定走由壹点统一通知中心;历史 work_notice 记录原值保留用于审计。
ALTER TABLE `dingtalk_delivery`
MODIFY COLUMN `provider_type` VARCHAR(20) NOT NULL DEFAULT 'notification_center',
ADD COLUMN `retry_of_delivery_id` INT NULL,
ADD COLUMN `manual_created_by` INT NULL,
ADD COLUMN `manual_created_at` DATETIME(3) NULL,
ADD COLUMN `last_manual_action_by` INT NULL,
ADD COLUMN `last_manual_action_type` VARCHAR(30) NULL,
ADD COLUMN `last_manual_action_at` DATETIME(3) NULL;
CREATE INDEX `dingtalk_delivery_retry_of_delivery_id_idx`
ON `dingtalk_delivery`(`retry_of_delivery_id`);
-- 兼容上一版已经排队、尚未提交到通知中心的任务;旧 payload 没有 action_title。
UPDATE `dingtalk_delivery`
SET `payload_json` = JSON_SET(
`payload_json`,
'$.action_title',
CASE `event_type`
WHEN 'disposal_applied' THEN '立即审批'
WHEN 'disposal_overdue_reminder' THEN '立即审批'
WHEN 'disposal_approved' THEN '查看审批结果'
WHEN 'disposal_rejected' THEN '查看申请'
WHEN 'disposal_completed' THEN '查看结果'
ELSE '查看详情'
END
)
WHERE `provider_type` = 'notification_center'
AND `status` IN ('pending', 'retrying')
AND JSON_VALID(`payload_json`)
AND JSON_EXTRACT(`payload_json`, '$.action_title') IS NULL;

View File

@ -0,0 +1,11 @@
-- 盘点任务增加截止时间,供钉钉派发和移动端明确展示。
ALTER TABLE `inventory_task`
ADD COLUMN `deadline_at` DATETIME(3) NULL AFTER `created_by`,
ADD COLUMN `identity_mode` VARCHAR(20) NOT NULL DEFAULT 'legacy' AFTER `status`;
-- 历史盘点链接继续兼容手工确认身份;本次发布后新任务默认强制使用钉钉身份。
ALTER TABLE `inventory_task`
MODIFY COLUMN `identity_mode` VARCHAR(20) NOT NULL DEFAULT 'dingtalk';
CREATE INDEX `inventory_task_status_deadline_at_idx`
ON `inventory_task`(`status`, `deadline_at`);

View File

@ -0,0 +1,17 @@
-- 冻结盘点任务创建时的父子关系,历史任务仅在父资产也在同一任务内时回填。
ALTER TABLE `inventory_task_item`
ADD COLUMN `snapshot_parent_equipment_id` INT NULL AFTER `equipment_id`;
CREATE INDEX `inventory_task_item_task_id_snapshot_parent_equipment_id_idx`
ON `inventory_task_item` (`task_id`, `snapshot_parent_equipment_id`);
-- 不能使用当前关系直接覆盖历史任务;只补齐同一任务内可确认的直接父资产。
UPDATE `inventory_task_item` AS `child_item`
INNER JOIN `equipment` AS `current_equipment`
ON `current_equipment`.`id` = `child_item`.`equipment_id`
INNER JOIN `inventory_task_item` AS `parent_item`
ON `parent_item`.`task_id` = `child_item`.`task_id`
AND `parent_item`.`equipment_id` = `current_equipment`.`parent_id`
SET `child_item`.`snapshot_parent_equipment_id` = `current_equipment`.`parent_id`
WHERE `child_item`.`snapshot_parent_equipment_id` IS NULL
AND `current_equipment`.`parent_id` IS NOT NULL;

View File

@ -0,0 +1,57 @@
-- 统一通知中心切换前,旧 work_notice 催办任务可能在零次投递时就把报废单标记为已催办。
-- 保留旧任务用于审计,但仅对“仍待审批、从未尝试/发送、且尚无新通道任务”的记录开放一次重新催办。
START TRANSACTION;
UPDATE `disposal_record` AS `disposal`
SET `disposal`.`reminded_at` = NULL
WHERE `disposal`.`status` = 'pending'
AND `disposal`.`reminded_at` IS NOT NULL
AND EXISTS (
SELECT 1
FROM `dingtalk_delivery` AS `legacy_delivery`
WHERE `legacy_delivery`.`business_type` = 'disposal'
AND `legacy_delivery`.`business_id` = `disposal`.`id`
AND `legacy_delivery`.`event_type` = 'disposal_overdue_reminder'
AND `legacy_delivery`.`provider_type` = 'work_notice'
AND `legacy_delivery`.`status` IN ('pending', 'retrying', 'sending')
AND `legacy_delivery`.`attempt_count` = 0
AND `legacy_delivery`.`dingtalk_task_id` IS NULL
AND `legacy_delivery`.`sent_at` IS NULL
AND `legacy_delivery`.`read_at` IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM `dingtalk_delivery` AS `current_delivery`
WHERE `current_delivery`.`business_type` = 'disposal'
AND `current_delivery`.`business_id` = `disposal`.`id`
AND `current_delivery`.`event_type` = 'disposal_overdue_reminder'
AND `current_delivery`.`provider_type` = 'notification_center'
);
UPDATE `dingtalk_delivery` AS `legacy_delivery`
JOIN `disposal_record` AS `disposal`
ON `disposal`.`id` = `legacy_delivery`.`business_id`
LEFT JOIN `dingtalk_delivery` AS `current_delivery`
ON `current_delivery`.`business_type` = 'disposal'
AND `current_delivery`.`business_id` = `legacy_delivery`.`business_id`
AND `current_delivery`.`event_type` = 'disposal_overdue_reminder'
AND `current_delivery`.`provider_type` = 'notification_center'
SET `legacy_delivery`.`status` = 'failed',
`legacy_delivery`.`next_attempt_at` = NOW(3),
`legacy_delivery`.`lease_owner` = NULL,
`legacy_delivery`.`lease_expires_at` = NULL,
`legacy_delivery`.`last_error_code` = 'LEGACY_CHANNEL_RETIRED',
`legacy_delivery`.`last_error_message` = '历史工作通知通道未实际投递,已开放由统一通知中心重新生成',
`legacy_delivery`.`failure_notified_at` = NOW(3)
WHERE `legacy_delivery`.`business_type` = 'disposal'
AND `legacy_delivery`.`event_type` = 'disposal_overdue_reminder'
AND `legacy_delivery`.`provider_type` = 'work_notice'
AND `legacy_delivery`.`status` IN ('pending', 'retrying', 'sending')
AND `legacy_delivery`.`attempt_count` = 0
AND `legacy_delivery`.`dingtalk_task_id` IS NULL
AND `legacy_delivery`.`sent_at` IS NULL
AND `legacy_delivery`.`read_at` IS NULL
AND `disposal`.`status` = 'pending'
AND `current_delivery`.`id` IS NULL;
COMMIT;

View File

@ -0,0 +1,4 @@
-- Nullable: legacy maintenance records have no reliable pre-maintenance snapshot.
-- Do not backfill guessed asset statuses.
ALTER TABLE `maintenance_record`
ADD COLUMN `original_tree_snapshot` LONGTEXT NULL;

View File

@ -0,0 +1,8 @@
-- Add claim fencing and result verification without guessing historical delivery.
ALTER TABLE `print_terminal` ADD COLUMN `protocol_version` INTEGER NOT NULL DEFAULT 1;
ALTER TABLE `print_job`
ADD COLUMN `claim_token` VARCHAR(64) NULL,
ADD COLUMN `lease_expires_at` DATETIME(3) NULL,
ADD COLUMN `result_confirmed_by` INTEGER NULL,
ADD COLUMN `result_confirmed_at` DATETIME(3) NULL;
CREATE INDEX `print_job_status_lease_expires_at_idx` ON `print_job` (`status`, `lease_expires_at`);

View File

@ -20,7 +20,9 @@ model AssetType {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
equipments Equipment[]
equipments Equipment[]
inventoryTasks InventoryTask[]
inventoryTaskItems InventoryTaskItem[]
@@index([status])
@@index([short_name])
@ -43,7 +45,12 @@ model Department {
parent Department? @relation("DepartmentHierarchy", fields: [parent_id], references: [id])
children Department[] @relation("DepartmentHierarchy")
equipments Equipment[]
equipments Equipment[]
mainPersons Person[] @relation("PersonMainDepartment")
personDepartments PersonDepartment[]
inventoryTasks InventoryTask[]
inventoryTaskParticipants InventoryTaskParticipant[]
inventoryTaskItems InventoryTaskItem[] @relation("InventoryTaskItemSnapshotDepartment")
@@index([status])
@@index([parent_id])
@ -90,8 +97,12 @@ model Equipment {
mountHistories MountHistory[]
// 报废记录关联
disposalRecords DisposalRecord[]
disposalItems DisposalItem[]
// 维修记录关联
maintenanceRecords MaintenanceRecord[]
inventoryTaskItems InventoryTaskItem[]
transferRootRecords AssetTransferRecord[] @relation("AssetTransferRoot")
transferItems AssetTransferItem[]
@@index([equipment_code])
@@index([equipment_type])
@ -144,6 +155,7 @@ model User {
phone String? @db.VarChar(20)
role String @default("user") @db.VarChar(20)
status String @default("active") @db.VarChar(20)
person_id Int? @unique
last_login_at DateTime? @db.DateTime
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -154,14 +166,104 @@ model User {
// 报废记录关联
disposal_applied DisposalRecord[] @relation("DisposalApplier")
disposal_approved DisposalRecord[] @relation("DisposalApprover")
disposal_completed DisposalRecord[] @relation("DisposalCompleter")
disposal_actions DisposalAction[] @relation("DisposalActionOperator")
// 维修记录关联
maintenance_records MaintenanceRecord[]
notifications UserNotification[]
processed_sync_changes SyncChange[] @relation("SyncChangeProcessedBy")
inventory_tasks_created InventoryTask[] @relation("InventoryTaskCreatedBy")
inventory_actions InventoryTaskAction[] @relation("InventoryTaskActionOperator")
print_terminal_pair_codes PrintTerminalPairCode[] @relation("PrintTerminalPairCodeCreator")
requested_print_jobs PrintJob[] @relation("PrintJobRequester")
asset_transfers_operated AssetTransferRecord[] @relation("AssetTransferOperator")
dingtalk_deliveries DingTalkDelivery[] @relation("DingTalkDeliveryRecipient")
person Person? @relation(fields: [person_id], references: [id], onDelete: SetNull)
@@index([role])
@@index([status])
@@map("user")
}
model PrintTerminal {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
platform String @db.VarChar(30)
machine_name String @db.VarChar(100)
status String @default("offline") @db.VarChar(20)
protocol_version Int @default(1)
is_default Boolean @default(false)
last_seen_at DateTime?
selected_printer_name String? @db.VarChar(150)
printer_snapshot_json String? @db.Text
label_profile_json String? @db.Text
terminal_token_hash String @unique @db.VarChar(128)
paired_at DateTime @default(now())
current_error_message String? @db.VarChar(255)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
used_pair_codes PrintTerminalPairCode[] @relation("PrintTerminalPairCodeUsedBy")
target_jobs PrintJob[] @relation("PrintJobTargetTerminal")
claimed_jobs PrintJob[] @relation("PrintJobClaimedByTerminal")
@@index([status])
@@index([is_default])
@@index([last_seen_at])
@@map("print_terminal")
}
model PrintTerminalPairCode {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(32)
expires_at DateTime
created_by Int
used_at DateTime?
used_by_terminal_id Int?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
creator User @relation("PrintTerminalPairCodeCreator", fields: [created_by], references: [id])
usedByTerminal PrintTerminal? @relation("PrintTerminalPairCodeUsedBy", fields: [used_by_terminal_id], references: [id])
@@index([expires_at])
@@index([created_by])
@@index([used_by_terminal_id])
@@map("print_terminal_pair_code")
}
model PrintJob {
id Int @id @default(autoincrement())
job_type String @default("asset_label") @db.VarChar(30)
status String @default("queued") @db.VarChar(20)
target_terminal_id Int
claimed_by_terminal_id Int?
requested_by Int
payload_json String @db.Text
error_message String? @db.Text
claimed_at DateTime?
claim_token String? @db.VarChar(64)
lease_expires_at DateTime?
result_confirmed_by Int?
result_confirmed_at DateTime?
started_at DateTime?
completed_at DateTime?
attempt_count Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
targetTerminal PrintTerminal @relation("PrintJobTargetTerminal", fields: [target_terminal_id], references: [id])
claimedByTerminal PrintTerminal? @relation("PrintJobClaimedByTerminal", fields: [claimed_by_terminal_id], references: [id])
requester User @relation("PrintJobRequester", fields: [requested_by], references: [id])
@@index([status, created_at])
@@index([status, lease_expires_at])
@@index([target_terminal_id, status, created_at])
@@index([requested_by])
@@index([claimed_by_terminal_id])
@@map("print_job")
}
model SiteConfig {
id Int @id @default(autoincrement())
site_name String @db.VarChar(100)
@ -181,8 +283,10 @@ model Person {
id Int @id @default(autoincrement())
nickname String @db.VarChar(50)
real_name String @db.VarChar(50)
job_number String? @unique @db.VarChar(50)
id_number String? @unique @db.VarChar(50)
department String? @db.VarChar(100)
main_department_id Int?
position String? @db.VarChar(100)
phone String? @db.VarChar(20)
email String? @db.VarChar(100)
@ -192,22 +296,46 @@ model Person {
source_system String? @db.VarChar(30)
source_main_department_id Int?
source_dept_ids String? @db.Text
source_department_name String? @db.VarChar(100)
source_main_department_name String? @db.VarChar(100)
department_sync_bucket String? @db.VarChar(20)
manager_source_userid String? @db.VarChar(100)
source_employee_status String? @db.VarChar(30)
last_synced_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
borrow_records BorrowRecord[]
borrow_records BorrowRecord[]
mainDepartment Department? @relation("PersonMainDepartment", fields: [main_department_id], references: [id])
personDepartments PersonDepartment[]
inventoryTaskParticipants InventoryTaskParticipant[]
inventoryTaskItems InventoryTaskItem[] @relation("InventoryTaskItemSnapshotPerson")
user User?
@@index([nickname])
@@index([real_name])
@@index([status])
@@index([main_department_id])
@@index([source_system])
@@index([department_sync_bucket])
@@index([last_synced_at])
@@map("person")
}
model PersonDepartment {
person_id Int
department_id Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
person Person @relation(fields: [person_id], references: [id], onDelete: Cascade)
department Department @relation(fields: [department_id], references: [id], onDelete: Cascade)
@@id([person_id, department_id])
@@index([department_id])
@@map("person_department")
}
model StorageLocation {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
@ -242,6 +370,228 @@ model OperationLog {
@@map("operation_log")
}
model SyncJob {
id Int @id @default(autoincrement())
sync_type String @db.VarChar(30)
trigger_mode String @db.VarChar(30)
status String @default("success") @db.VarChar(20)
operator String? @db.VarChar(50)
summary String? @db.Text
result_payload String? @db.Text
created_at DateTime @default(now())
changes SyncChange[]
notifications Notification[]
@@index([sync_type, created_at])
@@index([status, created_at])
@@map("sync_job")
}
model SyncChange {
id Int @id @default(autoincrement())
sync_job_id Int
entity_type String @db.VarChar(20)
entity_key String? @db.VarChar(100)
entity_name String @db.VarChar(100)
change_type String @db.VarChar(50)
title String @db.VarChar(150)
detail String? @db.Text
before_snapshot String? @db.Text
after_snapshot String? @db.Text
processing_status String @default("pending") @db.VarChar(20)
processing_notes String? @db.Text
processed_by Int?
processed_at DateTime?
created_at DateTime @default(now())
syncJob SyncJob @relation(fields: [sync_job_id], references: [id], onDelete: Cascade)
processedBy User? @relation("SyncChangeProcessedBy", fields: [processed_by], references: [id], onDelete: SetNull)
@@index([sync_job_id])
@@index([entity_type, change_type])
@@index([processing_status, created_at])
@@index([processed_by])
@@map("sync_change")
}
model Notification {
id Int @id @default(autoincrement())
title String @db.VarChar(150)
content String? @db.Text
type String @default("info") @db.VarChar(30)
level String @default("info") @db.VarChar(20)
action_url String? @db.VarChar(255)
related_sync_job_id Int?
metadata String? @db.Text
created_at DateTime @default(now())
syncJob SyncJob? @relation(fields: [related_sync_job_id], references: [id], onDelete: SetNull)
recipients UserNotification[]
@@index([related_sync_job_id])
@@index([created_at])
@@map("notification")
}
model UserNotification {
id Int @id @default(autoincrement())
user_id Int
notification_id Int
is_read Boolean @default(false)
read_at DateTime?
created_at DateTime @default(now())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
notification Notification @relation(fields: [notification_id], references: [id], onDelete: Cascade)
@@unique([user_id, notification_id])
@@index([user_id, is_read, created_at])
@@map("user_notification")
}
model InventoryTask {
id Int @id @default(autoincrement())
title String @db.VarChar(150)
description String? @db.Text
status String @default("active") @db.VarChar(20)
identity_mode String @default("dingtalk") @db.VarChar(20)
public_token String @unique @db.VarChar(120)
scope_department_id Int?
scope_department_name String? @db.VarChar(100)
scope_asset_type_id Int?
scope_asset_type_name String? @db.VarChar(100)
scope_snapshot_json String? @db.Text
participants_count Int @default(0)
items_count Int @default(0)
completed_count Int @default(0)
exception_count Int @default(0)
unassigned_count Int @default(0)
created_by Int
deadline_at DateTime?
closed_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
scopeDepartment Department? @relation(fields: [scope_department_id], references: [id])
scopeAssetType AssetType? @relation(fields: [scope_asset_type_id], references: [id])
creator User @relation("InventoryTaskCreatedBy", fields: [created_by], references: [id])
participants InventoryTaskParticipant[]
items InventoryTaskItem[]
actions InventoryTaskAction[]
@@index([status, created_at])
@@index([scope_department_id, status])
@@index([scope_asset_type_id, status])
@@index([created_by])
@@index([status, deadline_at])
@@map("inventory_task")
}
model InventoryTaskParticipant {
id Int @id @default(autoincrement())
task_id Int
person_id Int?
person_name String @db.VarChar(50)
person_nickname String @db.VarChar(50)
person_source_userid String? @db.VarChar(100)
department_id Int?
department_name String? @db.VarChar(100)
status String @default("pending") @db.VarChar(20)
confirmed_at DateTime?
last_action_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade)
person Person? @relation(fields: [person_id], references: [id], onDelete: SetNull)
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
assigned_items InventoryTaskItem[] @relation("InventoryTaskItemAssignee")
checked_items InventoryTaskItem[] @relation("InventoryTaskItemChecker")
actions InventoryTaskAction[]
@@unique([task_id, person_id])
@@index([task_id, status])
@@index([task_id, person_id])
@@index([task_id, person_nickname])
@@index([department_id])
@@map("inventory_task_participant")
}
model InventoryTaskItem {
id Int @id @default(autoincrement())
task_id Int
participant_id Int?
equipment_id Int
// 任务创建时冻结的直接父资产;为空表示该项在本任务中按根资产展示。
snapshot_parent_equipment_id Int?
equipment_code String @db.VarChar(50)
equipment_name String @db.VarChar(100)
equipment_model String? @db.VarChar(100)
equipment_type String @db.VarChar(50)
asset_type_id Int?
asset_type_name String? @db.VarChar(100)
snapshot_department_id Int?
snapshot_department_name String? @db.VarChar(100)
snapshot_person_id Int?
snapshot_person_name String? @db.VarChar(50)
snapshot_person_nickname String? @db.VarChar(50)
snapshot_status String? @db.VarChar(20)
source_borrow_record_id Int?
is_unassigned Boolean @default(false)
check_status String @default("pending") @db.VarChar(20)
checked_by_participant_id Int?
checked_at DateTime?
exception_type String? @db.VarChar(30)
exception_note String? @db.Text
created_at DateTime @default(now())
updated_at DateTime @updatedAt
task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade)
participant InventoryTaskParticipant? @relation("InventoryTaskItemAssignee", fields: [participant_id], references: [id], onDelete: SetNull)
checked_by_participant InventoryTaskParticipant? @relation("InventoryTaskItemChecker", fields: [checked_by_participant_id], references: [id], onDelete: SetNull)
equipment Equipment @relation(fields: [equipment_id], references: [id], onDelete: Cascade)
assetType AssetType? @relation(fields: [asset_type_id], references: [id], onDelete: SetNull)
snapshotDepartment Department? @relation("InventoryTaskItemSnapshotDepartment", fields: [snapshot_department_id], references: [id], onDelete: SetNull)
snapshotPerson Person? @relation("InventoryTaskItemSnapshotPerson", fields: [snapshot_person_id], references: [id], onDelete: SetNull)
actions InventoryTaskAction[]
@@unique([task_id, equipment_id])
@@index([task_id, participant_id, check_status])
@@index([task_id, equipment_id])
@@index([task_id, snapshot_parent_equipment_id])
@@index([task_id, is_unassigned])
@@index([task_id, check_status])
@@index([checked_by_participant_id])
@@index([snapshot_department_id])
@@index([snapshot_person_id])
@@map("inventory_task_item")
}
model InventoryTaskAction {
id Int @id @default(autoincrement())
task_id Int
participant_id Int?
item_id Int?
action_type String @db.VarChar(40)
action_payload String? @db.Text
ip_address String? @db.VarChar(50)
user_agent String? @db.VarChar(255)
operator_user_id Int?
created_at DateTime @default(now())
task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade)
participant InventoryTaskParticipant? @relation(fields: [participant_id], references: [id], onDelete: SetNull)
item InventoryTaskItem? @relation(fields: [item_id], references: [id], onDelete: SetNull)
operator User? @relation("InventoryTaskActionOperator", fields: [operator_user_id], references: [id], onDelete: SetNull)
@@index([task_id, created_at])
@@index([participant_id])
@@index([item_id])
@@index([operator_user_id])
@@map("inventory_task_action")
}
model MountHistory {
id Int @id @default(autoincrement())
@ -279,6 +629,11 @@ model DisposalRecord {
approved_by Int? // 审批人
approved_at DateTime?
completed_at DateTime? // 完成时间
completed_by Int?
pre_status String @default("可用") @db.VarChar(20)
reject_reason String? @db.Text
completion_notes String? @db.Text
reminded_at DateTime?
notes String? @db.Text
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -286,12 +641,155 @@ model DisposalRecord {
equipment Equipment @relation(fields: [equipment_id], references: [id])
applier User @relation("DisposalApplier", fields: [applied_by], references: [id])
approver User? @relation("DisposalApprover", fields: [approved_by], references: [id])
completer User? @relation("DisposalCompleter", fields: [completed_by], references: [id])
items DisposalItem[]
actions DisposalAction[]
@@index([equipment_id])
@@index([status])
@@index([applied_by, created_at])
@@index([approved_by, approved_at])
@@index([completed_by, completed_at])
@@map("disposal_record")
}
model DisposalItem {
id Int @id @default(autoincrement())
disposal_id Int
equipment_id Int
equipment_code String @db.VarChar(50)
equipment_name String @db.VarChar(100)
parent_equipment_id Int?
parent_equipment_code String? @db.VarChar(50)
pre_status String @db.VarChar(20)
snapshot_json String? @db.Text
created_at DateTime @default(now())
disposal DisposalRecord @relation(fields: [disposal_id], references: [id], onDelete: Cascade)
equipment Equipment @relation(fields: [equipment_id], references: [id])
@@unique([disposal_id, equipment_id])
@@index([equipment_id])
@@index([parent_equipment_id])
@@map("disposal_item")
}
model DisposalAction {
id Int @id @default(autoincrement())
disposal_id Int
action_type String @db.VarChar(30)
operator_id Int?
from_status String? @db.VarChar(20)
to_status String? @db.VarChar(20)
detail String? @db.Text
created_at DateTime @default(now())
disposal DisposalRecord @relation(fields: [disposal_id], references: [id], onDelete: Cascade)
operator User? @relation("DisposalActionOperator", fields: [operator_id], references: [id], onDelete: SetNull)
@@index([disposal_id, created_at])
@@index([operator_id])
@@map("disposal_action")
}
model AssetTransferRecord {
id Int @id @default(autoincrement())
root_equipment_id Int
action_type String @db.VarChar(20)
from_department_id Int?
from_department_name String? @db.VarChar(100)
to_department_id Int?
to_department_name String? @db.VarChar(100)
reason String? @db.Text
operated_by Int
operated_at DateTime @default(now())
created_at DateTime @default(now())
rootEquipment Equipment @relation("AssetTransferRoot", fields: [root_equipment_id], references: [id])
operator User @relation("AssetTransferOperator", fields: [operated_by], references: [id])
items AssetTransferItem[]
@@index([root_equipment_id, operated_at])
@@index([operated_by])
@@index([action_type, operated_at])
@@map("asset_transfer_record")
}
model AssetTransferItem {
id Int @id @default(autoincrement())
transfer_id Int
equipment_id Int
equipment_code String @db.VarChar(50)
equipment_name String @db.VarChar(100)
parent_equipment_id Int?
from_department_id Int?
to_department_id Int?
storage_location_id Int?
snapshot_status String @db.VarChar(20)
created_at DateTime @default(now())
transfer AssetTransferRecord @relation(fields: [transfer_id], references: [id], onDelete: Cascade)
equipment Equipment @relation(fields: [equipment_id], references: [id])
@@unique([transfer_id, equipment_id])
@@index([equipment_id, created_at])
@@index([parent_equipment_id])
@@map("asset_transfer_item")
}
model DingTalkDelivery {
id Int @id @default(autoincrement())
idempotency_key String @unique @db.VarChar(191)
event_type String @db.VarChar(50)
business_type String @db.VarChar(30)
business_id Int
recipient_user_id Int
recipient_dingtalk_userid String? @db.VarChar(100)
status String @default("pending") @db.VarChar(20)
attempt_count Int @default(0)
max_attempts Int @default(5)
next_attempt_at DateTime @default(now())
lease_owner String? @db.VarChar(100)
lease_expires_at DateTime?
provider_type String @default("notification_center") @db.VarChar(20)
dingtalk_task_id String? @db.VarChar(100)
retry_of_delivery_id Int?
manual_created_by Int?
manual_created_at DateTime?
last_manual_action_by Int?
last_manual_action_type String? @db.VarChar(30)
last_manual_action_at DateTime?
payload_json String @db.Text
last_error_code String? @db.VarChar(100)
last_error_message String? @db.Text
failure_notified_at DateTime?
sent_at DateTime?
read_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
recipient User @relation("DingTalkDeliveryRecipient", fields: [recipient_user_id], references: [id])
@@index([status, next_attempt_at])
@@index([lease_expires_at])
@@index([business_type, business_id])
@@index([recipient_user_id, created_at])
@@index([dingtalk_task_id])
@@index([retry_of_delivery_id])
@@map("dingtalk_delivery")
}
model WebSession {
sid String @id @db.VarChar(191)
data String @db.LongText
expires_at DateTime
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([expires_at])
@@map("web_session")
}
// 维修记录
model MaintenanceRecord {
id Int @id @default(autoincrement())
@ -306,6 +804,7 @@ model MaintenanceRecord {
actual_end_date DateTime? // 实际完成日期
status String @default("in_progress") @db.VarChar(20) // in_progress/completed/cancelled
repair_result String? @db.Text // 维修结果
original_tree_snapshot String? @db.LongText // 送修前整棵资产树状态和关系快照
created_by Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View File

@ -0,0 +1,97 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const deployPath = path.resolve(__dirname, '../../../deploy.sh')
const precheckPath = path.resolve(
__dirname,
'../../prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql'
)
const deployScript = fs.readFileSync(deployPath, 'utf8')
const precheckSql = fs.readFileSync(precheckPath, 'utf8')
test('标准部署在 build/up 前必须通过报废迁移预检', () => {
const gateCall = deployScript.lastIndexOf('\nrun_disposal_migration_precheck\n')
const buildCall = deployScript.indexOf('"${COMPOSE_CMD[@]}" build')
const upCall = deployScript.indexOf('"${COMPOSE_CMD[@]}" up -d --remove-orphans')
assert.ok(gateCall > 0, '部署脚本必须调用迁移前预检')
assert.ok(buildCall > gateCall, '预检必须早于镜像构建')
assert.ok(upCall > gateCall, '预检必须早于容器更新/迁移')
assert.match(
deployScript,
/backend\/prisma\/migrations\/20260714_asset_permissions_disposal_dingtalk\/precheck\.sql/
)
})
test('预检只连接固定资产 MySQL,不在命令行暴露密码', () => {
assert.match(deployScript, /MYSQL_CONTAINER="equipment-mysql"/)
assert.match(deployScript, /--defaults-extra-file="\$defaults_file"/)
assert.doesNotMatch(deployScript, /mysql\s+[^\n]*--password=/)
assert.doesNotMatch(deployScript, /shebeiguanli|\/opt\/shebeiguanli/)
assert.doesNotMatch(
deployScript,
/LEGACY_CONTAINERS|record_legacy_container_statuses|verify_legacy_container_statuses/
)
})
test('无法确认或存在未核对报废单时默认阻断,仅允许本次显式确认', () => {
assert.match(precheckSql, /SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;/)
assert.match(precheckSql, /START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;/)
assert.match(precheckSql, /'ACTIVE_DISPOSAL_COUNT'/)
assert.match(precheckSql, /'REVIEW_ROW'/)
assert.match(precheckSql, /COMMIT;/)
assert.match(
deployScript,
/snapshot_output="\$\([\s\S]*run_equipment_mysql --batch --skip-column-names --raw < "\$DISPOSAL_PRECHECK_SQL"[\s\S]*\)"/
)
assert.match(deployScript, /printf '%s\\n' "\$review_rows" \| sha256sum/)
assert.match(
deployScript,
/CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "\$CURRENT_DISPOSAL_FINGERPRINT"/
)
assert.match(deployScript, /已默认阻止部署/)
})
test('未完成报废资产树存在环或深度达到 100 时禁止 override', () => {
const anomalyBlock = deployScript.indexOf('if [ "$tree_anomaly_count" -gt 0 ]')
const overrideCheck = deployScript.indexOf(
'if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$CURRENT_DISPOSAL_FINGERPRINT" ]'
)
assert.match(precheckSql, /WHERE `cycle_detected` = 1 OR `depth` >= 100/)
assert.ok(anomalyBlock > 0, '必须对资产树异常数量做硬阻断')
assert.ok(overrideCheck > anomalyBlock, '资产树异常阻断必须早于指纹 override')
assert.match(deployScript, /不允许通过确认指纹绕过/)
})
test('人工展示与指纹来自同一次数据库一致性快照', () => {
const functionBody = deployScript.slice(
deployScript.indexOf('calculate_active_disposal_fingerprint()'),
deployScript.indexOf('\nrun_disposal_migration_precheck()')
)
const mysqlCalls = functionBody.match(/run_equipment_mysql/g) || []
assert.equal(mysqlCalls.length, 1, '一次指纹计算只能打开一个数据库会话')
assert.match(functionBody, /review_rows=.*snapshot_output/)
assert.match(functionBody, /printf '%s\\n' "\$review_rows"/)
assert.doesNotMatch(
deployScript.slice(
deployScript.indexOf('run_disposal_migration_precheck()'),
deployScript.indexOf('\nrecheck_disposal_migration_precheck()')
),
/run_equipment_mysql\s+<\s+"\$DISPOSAL_PRECHECK_SQL"/
)
})
test('同名迁移同时存在完成和未完成记录时必须优先判为 incomplete', () => {
const stateQuery = deployScript.slice(
deployScript.indexOf('SELECT CASE'),
deployScript.indexOf('END\nFROM _prisma_migrations')
)
assert.ok(
stateQuery.indexOf("THEN 'incomplete'") < stateQuery.indexOf("THEN 'applied'"),
'未完成记录必须优先阻断,不能被旧的已完成记录掩盖'
)
})

View File

@ -0,0 +1,73 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const backfillScript = require('../backfillImportedEquipmentLocations')
const exportScript = require('../exportAdminManualReviewWorkbook')
const importScript = require('../importConfirmedEquipmentToLive')
const prepareScript = require('../prepareEquipmentMigrationDryRun')
const scripts = [backfillScript, exportScript, importScript, prepareScript]
const writerScripts = [backfillScript, importScript]
const explicitConnectionArgs = [
'--plink-path', 'plink-test.exe',
'--ssh-host', 'deploy@example.invalid',
'--ssh-hostkey', 'SHA256:test-host-key',
'--ssh-password', 'test-secret-value'
]
test('两个旧写入脚本默认只预览,必须显式 --apply 才允许写入', () => {
for (const script of writerScripts) {
const preview = script.parseArgs([])
assert.equal(preview.apply, false)
assert.equal(preview.dryRun, true)
const apply = script.parseArgs(['--apply'])
assert.equal(apply.apply, true)
assert.equal(apply.dryRun, false)
assert.throws(() => script.parseArgs(['--apply', '--dry-run']), /不能同时使用/)
assert.throws(() => script.parseArgs(['--aply']), /不支持的参数/)
}
})
test('四个旧脚本缺少连接参数时在联网前失败且不回显密码', () => {
for (const script of scripts) {
const options = script.parseArgs(['--ssh-password', 'test-secret-value'])
assert.throws(
() => script.resolveRemoteConnection(options, {}),
(error) => {
assert.match(error.message, /缺少固定资产远程连接参数/)
assert.match(error.message, /未发起网络连接/)
assert.doesNotMatch(error.message, /test-secret-value/)
return true
}
)
}
})
test('四个旧脚本仅接受显式连接配置且 CLI 优先于环境变量', () => {
const env = {
FIXED_ASSET_PLINK_PATH: 'env-plink.exe',
FIXED_ASSET_SSH_HOST: 'env@example.invalid',
FIXED_ASSET_SSH_HOSTKEY: 'SHA256:env-key',
FIXED_ASSET_SSH_PASSWORD: 'env-secret'
}
for (const script of scripts) {
const options = script.parseArgs(explicitConnectionArgs)
assert.deepEqual(script.resolveRemoteConnection(options, env), {
plinkPath: 'plink-test.exe',
sshHost: 'deploy@example.invalid',
sshHostkey: 'SHA256:test-host-key',
sshPassword: 'test-secret-value'
})
}
})
test('两个旧只读脚本拒绝未知参数且不暴露写入开关', () => {
for (const script of [exportScript, prepareScript]) {
const options = script.parseArgs([])
assert.equal(Object.prototype.hasOwnProperty.call(options, 'apply'), false)
assert.throws(() => script.parseArgs(['--apply']), /不支持的参数/)
}
})

View File

@ -0,0 +1,58 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const {
buildAssignableExistingSheet2PublicEquipmentWhere,
PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES,
buildPublicAssetBorrowState,
isSheet2PublicEquipmentDescription
} = require('../sheet2PublicAssetImportUtils')
test('isSheet2PublicEquipmentDescription 只识别总表待导入 Sheet2 公共设备描述', () => {
assert.equal(
isSheet2PublicEquipmentDescription(
'source_workbook=docs/总表待导入.xlsx; source_sheet=Sheet2; source_row=66'
),
true
)
assert.equal(
isSheet2PublicEquipmentDescription(
'source_workbook=docs/8.xlsx; source_sheet=Sheet1; source_row=66'
),
false
)
assert.equal(isSheet2PublicEquipmentDescription(''), false)
})
test('buildPublicAssetBorrowState 应把公共资产挂名状态统一转成已借出', () => {
assert.deepEqual(buildPublicAssetBorrowState(0), {
equipmentStatus: '已借出',
remainingQuantity: 0,
totalValue: 0,
borrowedValue: 0,
availableValue: 0
})
assert.deepEqual(buildPublicAssetBorrowState('1280.50'), {
equipmentStatus: '已借出',
remainingQuantity: 0,
totalValue: 1280.5,
borrowedValue: 1280.5,
availableValue: 0
})
})
test('PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES 只允许当前可用的既有公共设备自动挂名', () => {
assert.deepEqual(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES, ['可用'])
})
test('buildAssignableExistingSheet2PublicEquipmentWhere 只筛可用且未在借的 Sheet2 公共设备', () => {
assert.deepEqual(buildAssignableExistingSheet2PublicEquipmentWhere(), {
AND: [
{ description: { contains: 'source_workbook=docs/总表待导入.xlsx' } },
{ description: { contains: 'source_sheet=Sheet2' } }
],
status: { in: ['可用'] },
borrow_records: { none: { status: '已借出' } }
})
})

View File

@ -0,0 +1,113 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const {
mergeCandidateRowsWithSourceCodes,
resolveSheet2LocationMatch
} = require('../sheet2PublicSupplementUtils')
const buildLocationByName = (names) =>
new Map(
names.map((name, index) => [
String(name).trim().toUpperCase(),
{
id: index + 1,
name
}
])
)
test('mergeCandidateRowsWithSourceCodes 应补回候选集中漏掉的显式编码且不重复已有记录', () => {
const candidateRows = [
{
来源工作表: 'Sheet2',
资产编码: 'JS-DZ-0172',
资产名称: '打印机'
}
]
const sourceRows = [
{
来源工作表: 'Sheet2',
资产编码: 'JS-DZ-0168',
资产名称: '打印机-喷墨'
},
{
来源工作表: 'Sheet2',
资产编码: 'JS-DZ-0172',
资产名称: '打印机'
}
]
const result = mergeCandidateRowsWithSourceCodes(candidateRows, sourceRows, [
'JS-DZ-0168',
'JS-DZ-0172',
'JS-DZ-9999'
])
assert.deepEqual(
result.rows.map((row) => row.资产编码),
['JS-DZ-0172', 'JS-DZ-0168']
)
assert.deepEqual(result.missingCodes, ['JS-DZ-9999'])
assert.deepEqual(result.duplicateSourceCodes, [])
})
test('mergeCandidateRowsWithSourceCodes 遇到源表同编码多行冲突时不应自动选第一条', () => {
const result = mergeCandidateRowsWithSourceCodes(
[],
[
{
来源工作表: 'Sheet2',
资产编码: 'JS-DQ-0373',
资产名称: '冰箱',
地址: '佛山顺德仓'
},
{
来源工作表: 'Sheet2',
资产编码: 'JS-DQ-0373',
资产名称: '空调',
地址: '天安'
}
],
['JS-DQ-0373']
)
assert.deepEqual(result.rows, [])
assert.deepEqual(result.missingCodes, [])
assert.deepEqual(result.duplicateSourceCodes.map((item) => item.code), ['JS-DQ-0373'])
assert.equal(result.duplicateSourceCodes[0].rows.length, 2)
})
test('resolveSheet2LocationMatch 应识别带仓储前缀的苏北仓地址', () => {
const result = resolveSheet2LocationMatch(
'仓储一部 苏北仓',
buildLocationByName(['苏北仓库', '常州-仓库'])
)
assert.equal(result.normalizedValue, '苏北仓库')
assert.equal(result.match?.name, '苏北仓库')
assert.equal(result.rule, 'sheet2-prefix-override')
})
test('resolveSheet2LocationMatch 应识别带仓储前缀的崔桥工厂仓地址', () => {
const result = resolveSheet2LocationMatch(
'仓储二部 崔桥工厂仓',
buildLocationByName(['常州-仓库'])
)
assert.equal(result.normalizedValue, '常州-仓库')
assert.equal(result.match?.name, '常州-仓库')
assert.equal(result.rule, 'sheet2-prefix-alias')
})
test('resolveSheet2LocationMatch 对未明确确认的地址不应猜测映射', () => {
const result = resolveSheet2LocationMatch(
'仓储二部 山东仓',
buildLocationByName(['济南', '常州-仓库'])
)
assert.equal(result.match, null)
assert.equal(result.normalizedValue, '山东仓')
assert.equal(result.rule, 'sheet2-prefix-stripped')
})

View File

@ -0,0 +1,80 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const prepareScript = require('../prepareSheet2SupplementDryRun')
const importScript = require('../importSheet2PublicAssetsToLive')
const explicitConnectionArgs = [
'--plink-path',
'plink-test.exe',
'--ssh-host',
'deploy@example.invalid',
'--ssh-hostkey',
'SHA256:test-host-key',
'--ssh-password',
'test-secret-value'
]
test('Sheet2 导入脚本默认只能 preview,只有显式 --apply 才允许写入', () => {
assert.deepEqual(importScript.parseArgs([]), {
inputDir: '',
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
})
const applyOptions = importScript.parseArgs(['--apply'])
assert.equal(applyOptions.apply, true)
assert.equal(applyOptions.dryRun, false)
const previewOptions = importScript.parseArgs(['--dry-run'])
assert.equal(previewOptions.apply, false)
assert.equal(previewOptions.dryRun, true)
})
test('Sheet2 导入脚本拒绝冲突或拼错的写入参数', () => {
assert.throws(
() => importScript.parseArgs(['--apply', '--dry-run']),
/不能同时使用/
)
assert.throws(() => importScript.parseArgs(['--aply']), /不支持的参数/)
assert.throws(() => prepareScript.parseArgs(['--ssh-host']), /缺少参数值/)
})
test('两个 Sheet2 脚本缺少连接参数时应在联网前明确失败且不包含秘密', () => {
for (const script of [prepareScript, importScript]) {
const options = script.parseArgs(['--ssh-password', 'test-secret-value'])
assert.throws(
() => script.resolveRemoteConnection(options, {}),
(error) => {
assert.match(error.message, /缺少固定资产远程连接参数/)
assert.match(error.message, /FIXED_ASSET_SSH_HOST/)
assert.match(error.message, /未发起网络连接/)
assert.doesNotMatch(error.message, /test-secret-value/)
return true
}
)
}
})
test('两个 Sheet2 脚本支持显式连接参数且 CLI 优先于环境变量', () => {
const env = {
FIXED_ASSET_PLINK_PATH: 'env-plink.exe',
FIXED_ASSET_SSH_HOST: 'env@example.invalid',
FIXED_ASSET_SSH_HOSTKEY: 'SHA256:env-key',
FIXED_ASSET_SSH_PASSWORD: 'env-secret'
}
for (const script of [prepareScript, importScript]) {
const options = script.parseArgs(explicitConnectionArgs)
assert.deepEqual(script.resolveRemoteConnection(options, env), {
plinkPath: 'plink-test.exe',
sshHost: 'deploy@example.invalid',
sshHostkey: 'SHA256:test-host-key',
sshPassword: 'test-secret-value'
})
}
})

View File

@ -0,0 +1,34 @@
-- 仅用于“100% 可确定”的历史借用记录工号回填。
-- 执行前必须先确认:
-- 1. person.job_number 已由可信 HR 同步写入
-- 2. 本次只允许回填当前 person_identifier 为空的记录
-- 3. 只处理 source_system = 'ding_hr' 且 source_userid 非空的可信同步人员
SELECT
br.id,
br.equipment_code,
br.person_id,
br.person_name,
br.person_nickname,
br.person_identifier AS current_identifier,
p.job_number AS target_job_number,
p.source_userid,
p.source_system
FROM borrow_record br
JOIN person p ON p.id = br.person_id
WHERE (br.person_identifier IS NULL OR TRIM(br.person_identifier) = '')
AND p.job_number IS NOT NULL
AND TRIM(p.job_number) <> ''
AND p.source_system = 'ding_hr'
AND p.source_userid IS NOT NULL
ORDER BY br.id;
-- 确认上面的候选数据无误后,再手动执行下面的 UPDATE。
-- UPDATE borrow_record br
-- JOIN person p ON p.id = br.person_id
-- SET br.person_identifier = p.job_number
-- WHERE (br.person_identifier IS NULL OR TRIM(br.person_identifier) = '')
-- AND p.job_number IS NOT NULL
-- AND TRIM(p.job_number) <> ''
-- AND p.source_system = 'ding_hr'
-- AND p.source_userid IS NOT NULL;

View File

@ -0,0 +1,397 @@
let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const { resolveStorageLocationName } = require('./locationNormalization')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const readJsonl = (filePath) =>
fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
const ensureOutputDir = () => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `location_backfill_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const resolveLatestMigrationDir = () => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('migration_dry_run_'))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到 dry-run 输出目录: ${OUTPUT_ROOT}`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
inputDir: '',
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
let explicitDryRun = false
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--dry-run') {
explicitDryRun = true
} else if (arg === '--apply') {
options.apply = true
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
if (options.apply && explicitDryRun) {
throw new Error('--apply 与 --dry-run 不能同时使用')
}
options.dryRun = !options.apply
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const resolveInputDir = (inputDirArg) => {
if (!inputDirArg) {
return resolveLatestMigrationDir()
}
const resolved = path.isAbsolute(inputDirArg)
? inputDirArg
: path.resolve(process.cwd(), inputDirArg)
if (!fs.existsSync(resolved)) {
throw new Error(`指定的输入目录不存在: ${resolved}`)
}
return resolved
}
const buildPayload = (records) => {
const importableRecords = records.filter(
(record) => record.bucket === 'import-ready' || record.bucket === 'import-without-owner'
)
const grouped = new Map()
for (const record of importableRecords) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
continue
}
if (!grouped.has(legacyCode)) {
grouped.set(legacyCode, [])
}
grouped.get(legacyCode).push(record)
}
const duplicateCodes = new Set(
[...grouped.entries()].filter(([, items]) => items.length > 1).map(([legacyCode]) => legacyCode)
)
const updates = []
for (const record of importableRecords) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode || duplicateCodes.has(legacyCode)) {
continue
}
const resolvedLocationName = normalizeText(
record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue
)
if (!resolvedLocationName || !record.locationMatch) {
continue
}
updates.push({
legacyCode,
rawLocation: normalizeText(record.addressRaw),
resolvedLocationName,
sourceRow: record.sourceRow
})
}
return updates
}
const runRemoteNodeScript = (script, connection) => {
const result = spawnSync(
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
maxBuffer: 64 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('远程脚本返回为空')
}
try {
return JSON.parse(stdout)
} catch {
throw new Error('远程脚本返回格式无效')
}
}
const buildRemoteScript = (payload, apply) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const shouldApply = ${apply ? 'true' : 'false'}
;(async () => {
const locationNames = [...new Set(payload.map((item) => item.resolvedLocationName))]
const [locations, equipmentsBefore] = await Promise.all([
prisma.storageLocation.findMany({
where: { status: 'active', name: { in: locationNames } },
select: { id: true, name: true, code: true }
}),
prisma.equipment.findMany({
where: { equipment_code: { in: payload.map((item) => item.legacyCode) } },
select: {
id: true,
equipment_code: true,
equipment_name: true,
storage_location_id: true,
storageLocation: { select: { id: true, name: true } }
}
})
])
const locationByName = new Map(locations.map((item) => [item.name, item]))
const equipmentByCode = new Map(equipmentsBefore.map((item) => [item.equipment_code, item]))
const skipped = []
const planned = []
for (const item of payload) {
const equipment = equipmentByCode.get(item.legacyCode)
if (!equipment) {
skipped.push({ ...item, reason: 'equipment_not_found' })
continue
}
if (equipment.storage_location_id) {
skipped.push({
...item,
reason: 'already_has_storage_location',
current_storage_location: equipment.storageLocation?.name || ''
})
continue
}
const location = locationByName.get(item.resolvedLocationName)
if (!location) {
skipped.push({ ...item, reason: 'storage_location_not_found' })
continue
}
planned.push({
equipmentId: equipment.id,
legacyCode: item.legacyCode,
equipmentName: equipment.equipment_name,
rawLocation: item.rawLocation,
resolvedLocationName: item.resolvedLocationName,
storageLocationId: location.id
})
}
const updated = []
if (shouldApply) {
for (const item of planned) {
const result = await prisma.equipment.update({
where: { id: item.equipmentId },
data: { storage_location_id: item.storageLocationId },
select: {
id: true,
equipment_code: true,
storage_location_id: true,
storageLocation: { select: { id: true, name: true } }
}
})
updated.push(result)
}
}
const verification = {
nullStorageLocationCount: await prisma.equipment.count({ where: { storage_location_id: null } }),
updatedCount: updated.length
}
console.log(JSON.stringify({
applied: shouldApply,
payloadCount: payload.length,
plannedCount: planned.length,
updatedCount: updated.length,
skippedCount: skipped.length,
planned,
skipped,
verification
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const run = () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const inputDir = resolveInputDir(options.inputDir)
const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl')
if (!fs.existsSync(normalizedPath)) {
throw new Error(`未找到 normalized_assets.jsonl: ${normalizedPath}`)
}
const normalizedRecords = readJsonl(normalizedPath)
const payload = buildPayload(normalizedRecords)
if (!payload.length) {
throw new Error('没有可回填的地址记录')
}
const outputDir = ensureOutputDir()
writeJson(path.join(outputDir, 'planned_payload.json'), {
inputDir,
payloadCount: payload.length,
payload
})
const result = runRemoteNodeScript(buildRemoteScript(payload, options.apply), connection)
writeJson(path.join(outputDir, 'result.json'), result)
console.log(
JSON.stringify(
{
outputDir,
inputDir,
applied: options.apply,
payloadCount: payload.length,
result
},
null,
2
)
)
}
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -0,0 +1,389 @@
const fs = require('fs')
const path = require('path')
const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
const PROJECT_ROOT = path.resolve(__dirname, '../..')
const DEFAULT_OUTPUT = path.join(PROJECT_ROOT, 'migration_manual_overrides.json')
const WORKBOOK_PREFIX = '行政手动补录清单'
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeAssetCode = (value) => normalizeText(value).toUpperCase()
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
workbookPath: '',
outputPath: DEFAULT_OUTPUT
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--workbook') {
options.workbookPath = args[index + 1] || ''
index += 1
} else if (arg === '--output') {
options.outputPath = args[index + 1] || DEFAULT_OUTPUT
index += 1
}
}
return options
}
const resolvePathArg = (value) => {
const target = normalizeText(value)
if (!target) {
return ''
}
return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target)
}
const resolveLatestWorkbookPath = () => {
const matches = fs
.readdirSync(PROJECT_ROOT, { withFileTypes: true })
.filter(
(entry) =>
entry.isFile() &&
entry.name.startsWith(WORKBOOK_PREFIX) &&
entry.name.endsWith('.xlsx') &&
!entry.name.startsWith('~$')
)
.map((entry) => ({
path: path.join(PROJECT_ROOT, entry.name),
mtimeMs: fs.statSync(path.join(PROJECT_ROOT, entry.name)).mtimeMs
}))
.sort((left, right) => left.mtimeMs - right.mtimeMs)
if (!matches.length) {
throw new Error(`未找到 ${WORKBOOK_PREFIX}*.xlsx`)
}
return matches[matches.length - 1].path
}
const resolveWorkbookPath = (value) => {
const resolved = resolvePathArg(value)
const workbookPath = resolved || resolveLatestWorkbookPath()
if (!fs.existsSync(workbookPath)) {
throw new Error(`工作簿不存在: ${workbookPath}`)
}
return workbookPath
}
const resolveOutputPath = (value) => {
const resolved = resolvePathArg(value || DEFAULT_OUTPUT)
if (!resolved) {
return DEFAULT_OUTPUT
}
return resolved
}
const normalizeOverrideMap = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const result = {}
for (const [key, rawValue] of Object.entries(value)) {
const normalizedKey = normalizeText(key)
const normalizedValue = normalizeText(rawValue)
if (!normalizedKey || !normalizedValue) {
continue
}
result[normalizedKey] = normalizedValue
}
return result
}
const normalizeAssetOverrideMap = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const result = {}
for (const [key, rawOverride] of Object.entries(value)) {
const assetCode = normalizeAssetCode(key)
if (!assetCode || !rawOverride || typeof rawOverride !== 'object' || Array.isArray(rawOverride)) {
continue
}
const normalizedOverride = {}
for (const [fieldKey, fieldValue] of Object.entries(rawOverride)) {
const normalizedValue = normalizeText(fieldValue)
if (!normalizedValue) {
continue
}
normalizedOverride[fieldKey] = normalizedValue
}
if (Object.keys(normalizedOverride).length > 0) {
result[assetCode] = normalizedOverride
}
}
return result
}
const loadExistingOverrides = (filePath) => {
if (!fs.existsSync(filePath)) {
return {
departmentAliases: {},
locationAliases: {},
ownerAliases: {},
assetOverrides: {}
}
}
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
return {
departmentAliases: normalizeOverrideMap(parsed.departmentAliases),
locationAliases: normalizeOverrideMap(parsed.locationAliases),
ownerAliases: normalizeOverrideMap(parsed.ownerAliases),
assetOverrides: normalizeAssetOverrideMap(parsed.assetOverrides)
}
}
const readSheetRows = (workbook, sheetName) => {
const sheet = workbook.Sheets[sheetName]
if (!sheet) {
return []
}
return XLSX.utils.sheet_to_json(sheet, { defval: '' })
}
const createWorkbookState = (existing) => ({
output: {
departmentAliases: { ...existing.departmentAliases },
locationAliases: { ...existing.locationAliases },
ownerAliases: { ...existing.ownerAliases },
assetOverrides: Object.fromEntries(
Object.entries(existing.assetOverrides).map(([assetCode, fields]) => [assetCode, { ...fields }])
)
},
provenance: {
ownerAliases: Object.fromEntries(
Object.keys(existing.ownerAliases).map((key) => [key, 'existing output'])
),
assetOverrides: Object.fromEntries(
Object.entries(existing.assetOverrides).map(([assetCode, fields]) => [
assetCode,
Object.fromEntries(Object.keys(fields).map((fieldKey) => [fieldKey, 'existing output']))
])
)
},
stats: {
ownerAliasRowsApplied: 0,
assetOverrideFieldsApplied: 0,
assetOverrideRowsTouched: 0,
blankEditableRows: 0,
skippedRowsWithoutAssetCode: 0
}
})
const setOwnerAlias = (state, ownerName, targetEmployee, context) => {
const normalizedOwnerName = normalizeText(ownerName)
const normalizedTarget = normalizeText(targetEmployee)
if (!normalizedOwnerName || !normalizedTarget) {
return
}
const currentValue = state.output.ownerAliases[normalizedOwnerName]
const currentSource = state.provenance.ownerAliases[normalizedOwnerName] || ''
if (currentValue && currentValue !== normalizedTarget && currentSource !== 'existing output') {
throw new Error(
`花名映射冲突: ${normalizedOwnerName} 在 ${currentSource} 和 ${context} 填了不同目标员工`
)
}
if (currentValue !== normalizedTarget) {
state.stats.ownerAliasRowsApplied += 1
}
state.output.ownerAliases[normalizedOwnerName] = normalizedTarget
state.provenance.ownerAliases[normalizedOwnerName] = context
}
const setAssetOverrideField = (state, assetCode, fieldKey, fieldValue, context) => {
const normalizedAssetCode = normalizeAssetCode(assetCode)
const normalizedValue = normalizeText(fieldValue)
if (!normalizedValue) {
return false
}
if (!normalizedAssetCode) {
state.stats.skippedRowsWithoutAssetCode += 1
return false
}
if (!state.output.assetOverrides[normalizedAssetCode]) {
state.output.assetOverrides[normalizedAssetCode] = {}
}
if (!state.provenance.assetOverrides[normalizedAssetCode]) {
state.provenance.assetOverrides[normalizedAssetCode] = {}
}
const currentValue = state.output.assetOverrides[normalizedAssetCode][fieldKey]
const currentSource = state.provenance.assetOverrides[normalizedAssetCode][fieldKey] || ''
if (currentValue && currentValue !== normalizedValue && currentSource !== 'existing output') {
throw new Error(
`资产覆盖冲突: ${normalizedAssetCode} 的 ${fieldKey} 在 ${currentSource} 和 ${context} 填了不同值`
)
}
if (currentValue !== normalizedValue) {
if (Object.keys(state.output.assetOverrides[normalizedAssetCode]).length === 0) {
state.stats.assetOverrideRowsTouched += 1
}
state.stats.assetOverrideFieldsApplied += 1
}
state.output.assetOverrides[normalizedAssetCode][fieldKey] = normalizedValue
state.provenance.assetOverrides[normalizedAssetCode][fieldKey] = context
return true
}
const processPendingSheet = (state, rows) => {
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
const context = `待确认部门 第${index + 2}行`
const assetCode = row.legacy_asset_code
const targets = [
['department', row.行政填写_目标部门],
['owner', row.行政填写_目标员工],
['location', row.行政填写_标准地址],
['currentStatus', row.行政填写_资产状态]
]
let applied = false
for (const [fieldKey, fieldValue] of targets) {
applied = setAssetOverrideField(state, assetCode, fieldKey, fieldValue, context) || applied
}
if (!applied) {
state.stats.blankEditableRows += 1
}
}
}
const processOwnerManualSheet = (state, rows) => {
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
const context = `待补使用人 第${index + 2}行`
const applied = setAssetOverrideField(state, row.legacy_asset_code, 'owner', row.行政填写_目标员工, context)
if (!applied) {
state.stats.blankEditableRows += 1
}
}
}
const processOwnerAliasSheet = (state, rows) => {
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
const ownerName = normalizeText(row.owner_name)
const targetEmployee = normalizeText(row.行政填写_目标员工)
if (!ownerName || !targetEmployee) {
state.stats.blankEditableRows += 1
continue
}
setOwnerAlias(state, ownerName, targetEmployee, `花名核对 第${index + 2}行`)
}
}
const processLocationSheet = (state, rows) => {
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
const context = `待补地址 第${index + 2}行`
const applied = setAssetOverrideField(
state,
row.legacy_asset_code,
'location',
row.行政填写_标准地址,
context
)
if (!applied) {
state.stats.blankEditableRows += 1
}
}
}
const sortObjectByKey = (value) =>
Object.fromEntries(
Object.entries(value).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey, 'zh-CN'))
)
const sortNestedAssetOverrides = (assetOverrides) =>
Object.fromEntries(
Object.entries(assetOverrides)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey, 'zh-CN'))
.map(([assetCode, fields]) => [assetCode, sortObjectByKey(fields)])
)
const buildSummary = (state) => ({
ownerAliases: Object.keys(state.output.ownerAliases).length,
departmentAliases: Object.keys(state.output.departmentAliases).length,
locationAliases: Object.keys(state.output.locationAliases).length,
assetOverrides: Object.keys(state.output.assetOverrides).length,
assetOverrideFields: Object.values(state.output.assetOverrides).reduce(
(total, fields) => total + Object.keys(fields).length,
0
),
ownerAliasRowsApplied: state.stats.ownerAliasRowsApplied,
assetOverrideFieldsApplied: state.stats.assetOverrideFieldsApplied,
assetOverrideRowsTouched: state.stats.assetOverrideRowsTouched,
blankEditableRows: state.stats.blankEditableRows,
skippedRowsWithoutAssetCode: state.stats.skippedRowsWithoutAssetCode
})
const run = () => {
const options = parseArgs()
const workbookPath = resolveWorkbookPath(options.workbookPath)
const outputPath = resolveOutputPath(options.outputPath)
const existing = loadExistingOverrides(outputPath)
const state = createWorkbookState(existing)
const workbook = XLSX.readFile(workbookPath, { raw: false })
processPendingSheet(state, readSheetRows(workbook, '待确认部门'))
processOwnerManualSheet(state, readSheetRows(workbook, '待补使用人'))
processOwnerAliasSheet(state, readSheetRows(workbook, '花名核对'))
processLocationSheet(state, readSheetRows(workbook, '待补地址'))
const finalOutput = {
departmentAliases: sortObjectByKey(state.output.departmentAliases),
locationAliases: sortObjectByKey(state.output.locationAliases),
ownerAliases: sortObjectByKey(state.output.ownerAliases),
assetOverrides: sortNestedAssetOverrides(state.output.assetOverrides)
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
fs.writeFileSync(outputPath, `${JSON.stringify(finalOutput, null, 2)}\n`)
console.log(
JSON.stringify(
{
workbookPath,
outputPath,
summary: buildSummary(state)
},
null,
2
)
)
}
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}

View File

@ -0,0 +1,514 @@
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DEFAULT_OUTPUT = path.resolve(__dirname, '../../行政手动补录清单.xlsx')
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
inputDir: '',
importBackupDir: '',
output: DEFAULT_OUTPUT,
nullLocationJson: '',
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--import-backup-dir') {
options.importBackupDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--output') {
options.output = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--null-location-json') {
options.nullLocationJson = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const resolveLatestDir = (prefix) => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到目录前缀 ${prefix} 的输出结果`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const resolvePathArg = (value, fallbackPrefix) => {
if (!value) {
return resolveLatestDir(fallbackPrefix)
}
const resolved = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value)
if (!fs.existsSync(resolved)) {
throw new Error(`路径不存在: ${resolved}`)
}
return resolved
}
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))
const readJsonl = (filePath) =>
fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
const parseDescription = (description) => {
const result = {}
const normalized = normalizeText(description)
if (!normalized) {
return result
}
for (const part of normalized.split(';')) {
const trimmed = normalizeText(part)
if (!trimmed) {
continue
}
const splitIndex = trimmed.indexOf('=')
if (splitIndex === -1) {
continue
}
const key = normalizeText(trimmed.slice(0, splitIndex))
const value = normalizeText(trimmed.slice(splitIndex + 1))
if (key) {
result[key] = value
}
}
return result
}
const withEditableFields = (row, fields) => ({
...row,
...fields
})
const buildPendingRows = (records) =>
records
.filter((record) => record.bucket === 'pending-review')
.map((record) =>
withEditableFields(
{
source_row: record.sourceRow,
legacy_asset_code: normalizeText(record.assetCodeRaw),
asset_name: normalizeText(record.assetNameRaw),
source_status: normalizeText(record.currentStatusRaw),
raw_owner_name: normalizeText(record.ownerRaw),
raw_department: normalizeText(record.departmentRaw),
normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
raw_location: normalizeText(record.addressRaw),
pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '',
suggested_action:
Array.isArray(record.pendingReasons) && record.pendingReasons.includes('unmapped_department')
? '先确认业务部门映射'
: '人工核对后再补录'
},
{
行政填写_目标部门: '',
行政填写_目标员工: '',
行政填写_标准地址: '',
行政填写_资产状态: '',
行政填写_备注: ''
}
)
)
const flattenDuplicateRows = (duplicateDetails) =>
duplicateDetails.flatMap((item) =>
item.items.map((detail, index) => ({
legacy_code: item.legacyCode,
conflict_index: index + 1,
bucket: detail.bucket,
source_row: detail.sourceRow,
asset_name: detail.assetName,
owner: detail.owner,
department: detail.department,
source_status: detail.sourceStatus,
address: detail.address,
unit_price: detail.unitPrice,
purchase_date: detail.purchaseDate,
suggested_action: '同一个资产编码对应了多条记录,先人工确认保留哪一条'
}))
)
const runRemoteNodeScript = (script, connection) => {
const result = spawnSync(
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('远程脚本返回为空')
}
try {
return JSON.parse(stdout)
} catch {
throw new Error('远程脚本返回格式无效')
}
}
const readNullLocationRows = (nullLocationJsonArg, options) => {
const resolvedPath = nullLocationJsonArg
? path.isAbsolute(nullLocationJsonArg)
? nullLocationJsonArg
: path.resolve(process.cwd(), nullLocationJsonArg)
: ''
if (resolvedPath) {
if (!fs.existsSync(resolvedPath)) {
throw new Error(`待补地址 JSON 不存在: ${resolvedPath}`)
}
return readJson(resolvedPath)
}
return runRemoteNodeScript(buildRemoteNullLocationScript(), resolveRemoteConnection(options))
}
const buildRemoteNullLocationScript = () => `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const rows = await prisma.equipment.findMany({
where: { storage_location_id: null },
orderBy: [{ status: 'asc' }, { equipment_code: 'asc' }],
select: {
equipment_code: true,
equipment_name: true,
equipment_type: true,
status: true,
unit_price: true,
purchase_date: true,
description: true,
department: { select: { name: true } }
}
})
process.stdout.write(JSON.stringify(rows))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const buildOwnerManualRows = (records, duplicateCodeSet) =>
records
.filter(
(record) =>
record.bucket === 'import-without-owner' &&
record.statusKind === 'assigned' &&
normalizeText(record.assetCodeRaw) &&
!duplicateCodeSet.has(normalizeText(record.assetCodeRaw).toUpperCase())
)
.map((record) =>
withEditableFields(
{
source_row: record.sourceRow,
legacy_asset_code: normalizeText(record.assetCodeRaw).toUpperCase(),
asset_name: normalizeText(record.assetNameRaw),
source_status: normalizeText(record.currentStatusRaw),
raw_owner_name: normalizeText(record.ownerRaw),
raw_department: normalizeText(record.departmentRaw),
normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
raw_location: normalizeText(record.addressRaw),
pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '',
suggested_action: '资产已导入,但当前使用人还没落到系统里;请行政核对后补登记使用人'
},
{
行政填写_目标员工: '',
行政填写_备注: ''
}
)
)
const buildOwnerAliasSummaryRows = (records, duplicateCodeSet) => {
const grouped = new Map()
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
const ownerName = normalizeText(record.ownerRaw)
if (
record.bucket !== 'import-without-owner' ||
record.statusKind !== 'assigned' ||
!legacyCode ||
duplicateCodeSet.has(legacyCode) ||
!ownerName
) {
continue
}
const key = ownerName
if (!grouped.has(key)) {
grouped.set(key, {
owner_name: ownerName,
asset_count: 0,
departments: new Set(),
pending_reasons: new Set(),
asset_codes: []
})
}
const current = grouped.get(key)
current.asset_count += 1
current.departments.add(normalizeText(record.departmentRaw))
for (const reason of Array.isArray(record.pendingReasons) ? record.pendingReasons : []) {
current.pending_reasons.add(reason)
}
current.asset_codes.push(legacyCode)
}
return [...grouped.values()]
.map((item) =>
withEditableFields(
{
owner_name: item.owner_name,
asset_count: item.asset_count,
departments: [...item.departments].filter(Boolean).join('、'),
pending_reasons: [...item.pending_reasons].filter(Boolean).join('|'),
sample_asset_codes: item.asset_codes.slice(0, 10).join('、'),
suggested_action: '先核对该花名对应的当前在职员工,再统一补挂名下资产'
},
{
行政填写_目标员工: '',
行政填写_备注: ''
}
)
)
.sort((left, right) => right.asset_count - left.asset_count || left.owner_name.localeCompare(right.owner_name, 'zh-CN'))
}
const buildNullLocationRows = (rows) =>
rows.map((row) => {
const metadata = parseDescription(row.description)
return withEditableFields(
{
source_row: metadata.source_row || '',
legacy_asset_code: normalizeText(row.equipment_code),
asset_name: normalizeText(row.equipment_name),
equipment_type: normalizeText(row.equipment_type),
current_status: normalizeText(row.status),
current_department: normalizeText(row.department?.name || ''),
raw_department: metadata.raw_department || '',
raw_owner: metadata.raw_owner || '',
raw_location: metadata.raw_location || '',
purchase_date: row.purchase_date ? String(row.purchase_date).slice(0, 10) : '',
unit_price: normalizeText(row.unit_price),
suggested_action: '资产已导入,但没有标准地址;请行政确认后补齐标准存放地点'
},
{
行政填写_标准地址: '',
行政填写_备注: ''
}
)
})
const appendSheet = (workbook, name, rows) => {
const safeRows = rows.length ? rows : [{ message: '当前没有数据' }]
const sheet = XLSX.utils.json_to_sheet(safeRows)
XLSX.utils.book_append_sheet(workbook, sheet, name)
}
const writeWorkbookWithFallback = (workbook, outputPath) => {
try {
XLSX.writeFile(workbook, outputPath)
return outputPath
} catch (error) {
if (error && error.code === 'EBUSY') {
const parsed = path.parse(outputPath)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const fallbackPath = path.join(parsed.dir, `${parsed.name}-${timestamp}${parsed.ext}`)
XLSX.writeFile(workbook, fallbackPath)
return fallbackPath
}
throw error
}
}
const run = () => {
const options = parseArgs()
const inputDir = resolvePathArg(options.inputDir, 'migration_dry_run_')
const importBackupDir = resolvePathArg(options.importBackupDir, 'live_import_backup_')
const outputPath = path.isAbsolute(options.output)
? options.output
: path.resolve(process.cwd(), options.output)
const normalizedRecords = readJsonl(path.join(inputDir, 'normalized_assets.jsonl'))
const missingCode = readJson(path.join(importBackupDir, 'excluded_missing_code_rows.json'))
const duplicateCodes = readJson(path.join(importBackupDir, 'excluded_duplicate_codes.json'))
const plannedSummary = readJson(path.join(importBackupDir, 'planned_import_summary.json'))
const liveNullLocationRows = readNullLocationRows(options.nullLocationJson, options)
const duplicateCodeSet = new Set(duplicateCodes.duplicateDetails.map((item) => item.legacyCode))
const pendingRows = buildPendingRows(normalizedRecords)
const ownerManualRows = buildOwnerManualRows(normalizedRecords, duplicateCodeSet)
const ownerAliasSummaryRows = buildOwnerAliasSummaryRows(normalizedRecords, duplicateCodeSet)
const flattenedDuplicateRows = flattenDuplicateRows(duplicateCodes.duplicateDetails)
const nullLocationRows = buildNullLocationRows(liveNullLocationRows)
const workbook = XLSX.utils.book_new()
appendSheet(workbook, '概览', [
{ 项目: 'dry-run 待人工确认', 数量: pendingRows.length },
{ 项目: '缺少资产编码', 数量: missingCode.excludedMissingCodeCount || 0 },
{ 项目: '重复资产编码(编码数)', 数量: duplicateCodes.excludedDuplicateCodeCount || 0 },
{ 项目: '重复资产编码(记录数)', 数量: duplicateCodes.excludedDuplicateRecordCount || 0 },
{ 项目: '已导入待补使用人', 数量: ownerManualRows.length },
{ 项目: '待核对历史花名', 数量: ownerAliasSummaryRows.length },
{ 项目: 'live 待补标准地址', 数量: nullLocationRows.length },
{ 项目: '本轮已导入总数', 数量: plannedSummary.finalImportCount || 0 },
{ 项目: '其中已导入不带使用人', 数量: plannedSummary.finalImportWithoutOwnerCount || 0 }
])
appendSheet(workbook, '填写说明', [
{ 工作表: '待确认部门', 说明: '只在“行政填写_目标部门 / 目标员工 / 标准地址 / 资产状态”里填写需要修正的值。' },
{ 工作表: '待补使用人', 说明: '在“行政填写_目标员工”里填写当前在职员工姓名或花名。' },
{ 工作表: '花名核对', 说明: '同一个历史花名如果能统一对应到当前员工,优先在这里填写。' },
{ 工作表: '待补地址', 说明: '在“行政填写_标准地址”里填写系统里的标准地址名。' },
{ 工作表: '输出用途', 说明: '填写完成后可生成 migration_manual_overrides.json,再重新 dry-run / 导入。' }
])
appendSheet(workbook, '待确认部门', pendingRows)
appendSheet(workbook, '缺少编码', missingCode.rows || [])
appendSheet(workbook, '重复编码', flattenedDuplicateRows)
appendSheet(workbook, '待补使用人', ownerManualRows)
appendSheet(workbook, '花名核对', ownerAliasSummaryRows)
appendSheet(workbook, '待补地址', nullLocationRows)
const finalOutputPath = writeWorkbookWithFallback(workbook, outputPath)
console.log(
JSON.stringify(
{
requestedOutputPath: outputPath,
outputPath: finalOutputPath,
summary: {
pendingReview: pendingRows.length,
missingCode: missingCode.excludedMissingCodeCount || 0,
duplicateCodeCount: duplicateCodes.excludedDuplicateCodeCount || 0,
duplicateRecordCount: duplicateCodes.excludedDuplicateRecordCount || 0,
ownerManual: ownerManualRows.length,
ownerAliasSummary: ownerAliasSummaryRows.length,
nullLocation: nullLocationRows.length
}
},
null,
2
)
)
}
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -0,0 +1,231 @@
const fs = require('fs')
const path = require('path')
const ExcelJS = require('exceljs')
const SOURCE_PATH = path.resolve(
__dirname,
'../../temp_migration_output/multi_unit_child_repair_2026-03-24_01-16-47/manual_review_candidates.csv'
)
const OUTPUT_PATH = path.resolve(
__dirname,
'../../行政手动处理清单-最终仅剩8条-2026-03-24.xlsx'
)
const OBSOLETE_FILES = [
path.resolve(__dirname, '../../父子资产补建-行政手动处理清单-2026-03-23.xlsx'),
path.resolve(__dirname, '../../行政手动补录清单-2026-03-23_当前待人工处理.xlsx'),
path.resolve(__dirname, '../../子资产多件拆分-行政手动处理清单-2026-03-23.xlsx')
]
const REASON_MAP = {
outer_capacity_not_equal_to_inner_times_quantity:
'表格总容量与单条容量乘数量对不上,系统不能在不猜测的前提下自动拆分。',
child_type_and_model_are_inconsistent: '子资产类型与型号内容不一致,系统不能自动处理。',
pattern_detected_but_no_safe_split_rule: '识别到多件格式,但现有信息不足以安全拆分。',
storage_plus_pattern_is_not_single_capacity_pair:
'硬盘型号中混有容量和具体型号,不能只按容量安全拆分。'
}
const SPECIAL_REASON_OVERRIDES = {
'JS-PC-2127':
'原表错行:该行硬件字段整体错位,不能按当前行内容自动落库,需行政按原始资料人工核对后录入。'
}
const normalize = (value) => String(value == null ? '' : value)
const extractMeta = (description, key) => {
const text = normalize(description)
const matched = text.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`))
return matched ? matched[1].trim() : ''
}
async function readCsvRows(csvPath) {
const workbook = new ExcelJS.Workbook()
const sheet = await workbook.csv.readFile(csvPath)
const headers = sheet.getRow(1).values.slice(1).map((item) => normalize(item))
const rows = []
for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber)
if (row.cellCount === 0) {
continue
}
const record = {}
headers.forEach((header, index) => {
record[header] = normalize(row.getCell(index + 1).text)
})
if (Object.values(record).some((value) => value !== '')) {
rows.push(record)
}
}
return { headers, rows }
}
function styleHeaderRow(row) {
row.font = { bold: true }
row.alignment = { vertical: 'middle', horizontal: 'center' }
}
function resolveReasonDescription(row) {
return SPECIAL_REASON_OVERRIDES[row.parent_code] || REASON_MAP[row.reason] || row.reason
}
function buildTimestampedOutputPath() {
const now = new Date()
const pad = (value) => String(value).padStart(2, '0')
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
return path.resolve(__dirname, `../../行政手动处理清单-最终仅剩8条-${stamp}.xlsx`)
}
async function writeWorkbookWithFallback(workbook) {
try {
await workbook.xlsx.writeFile(OUTPUT_PATH)
return OUTPUT_PATH
} catch (error) {
if (error && error.code === 'EBUSY') {
const fallbackPath = buildTimestampedOutputPath()
await workbook.xlsx.writeFile(fallbackPath)
return fallbackPath
}
throw error
}
}
async function exportWorkbook() {
const { headers, rows } = await readCsvRows(SOURCE_PATH)
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.created = new Date()
const taskSheet = workbook.addWorksheet('人工处理清单')
taskSheet.columns = [
{ header: '源表行号', key: 'source_row', width: 10 },
{ header: '父资产编码', key: 'parent_code', width: 16 },
{ header: '父资产名称', key: 'parent_name', width: 12 },
{ header: '配件字段', key: 'field_key', width: 12 },
{ header: '子资产类型', key: 'child_type', width: 12 },
{ header: '系统当前子资产编码', key: 'current_child_code', width: 18 },
{ header: '表格原始型号', key: 'source_child_model', width: 42 },
{ header: '表格部门', key: 'workbook_department', width: 18 },
{ header: '表格使用人', key: 'workbook_owner', width: 12 },
{ header: '表格位置', key: 'workbook_location', width: 18 },
{ header: '原因代码', key: 'reason', width: 44 },
{ header: '人工处理原因', key: 'reason_desc', width: 50 }
]
taskSheet.views = [{ state: 'frozen', ySplit: 1 }]
taskSheet.autoFilter = 'A1:L1'
styleHeaderRow(taskSheet.getRow(1))
rows.forEach((row) => {
taskSheet.addRow({
source_row: row.source_row,
parent_code: row.parent_code,
parent_name: row.parent_name,
field_key: row.field_key,
child_type: row.child_type,
current_child_code: row.current_child_code,
source_child_model: row.source_child_model,
workbook_department: extractMeta(row.planned_description, 'workbook_department'),
workbook_owner: extractMeta(row.planned_description, 'workbook_owner'),
workbook_location: extractMeta(row.planned_description, 'workbook_location'),
reason: row.reason,
reason_desc: resolveReasonDescription(row)
})
})
taskSheet.eachRow((row, rowNumber) => {
row.alignment = { vertical: 'top', wrapText: true }
if (rowNumber > 1) {
row.height = 22
}
})
const rawSheet = workbook.addWorksheet('原始数据')
rawSheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.max(14, header.length + 2)
}))
rawSheet.views = [{ state: 'frozen', ySplit: 1 }]
styleHeaderRow(rawSheet.getRow(1))
rows.forEach((row) => rawSheet.addRow(row))
rawSheet.eachRow((row) => {
row.alignment = { vertical: 'top', wrapText: true }
})
const actualOutputPath = await writeWorkbookWithFallback(workbook)
const verifyBook = new ExcelJS.Workbook()
await verifyBook.xlsx.readFile(actualOutputPath)
const verifyRawSheet = verifyBook.getWorksheet('原始数据')
const verifyTaskSheet = verifyBook.getWorksheet('人工处理清单')
const verifiedRows = []
for (let rowNumber = 2; rowNumber <= verifyRawSheet.rowCount; rowNumber += 1) {
const row = verifyRawSheet.getRow(rowNumber)
if (row.cellCount === 0) {
continue
}
const record = {}
headers.forEach((header, index) => {
record[header] = normalize(row.getCell(index + 1).text)
})
if (Object.values(record).some((value) => value !== '')) {
verifiedRows.push(record)
}
}
if (verifiedRows.length !== rows.length) {
throw new Error(`校验失败:原始数据行数不一致,source=${rows.length}, xlsx=${verifiedRows.length}`)
}
rows.forEach((row, index) => {
headers.forEach((header) => {
if (normalize(verifiedRows[index][header]) !== normalize(row[header])) {
throw new Error(`校验失败:第 ${index + 1} 行字段 ${header} 不一致`)
}
})
})
const taskRowCount = Math.max(verifyTaskSheet.rowCount - 1, 0)
if (taskRowCount !== rows.length) {
throw new Error(`校验失败:人工处理清单行数不一致,source=${rows.length}, xlsx=${taskRowCount}`)
}
const removedFiles = []
const skippedFiles = []
for (const filePath of OBSOLETE_FILES) {
if (fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath)
removedFiles.push(filePath)
} catch (error) {
skippedFiles.push({
filePath,
code: error && error.code ? error.code : 'UNKNOWN'
})
}
}
}
return {
outputPath: actualOutputPath,
rowCount: rows.length,
removedFiles,
skippedFiles
}
}
exportWorkbook()
.then((result) => {
console.log(JSON.stringify({ verified: true, ...result }, null, 2))
})
.catch((error) => {
console.error(error)
process.exit(1)
})

View File

@ -0,0 +1,534 @@
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const cleanModelText = (value) =>
normalizeText(value)
.replace(/\s+([,,/])/g, '$1')
.replace(/([((])\s+/g, '$1')
.replace(/\s+([))])/g, '$1')
.replace(/\s+/g, ' ')
.trim()
const MULTI_UNIT_HINT_PATTERNS = [
{ pattern: 'cpu_x_multiplier', regex: /[((]\s*[xX]\s*[2-9]\d*\s*[))]\s*$/ },
{ pattern: 'suffix_quantity_label', regex: /\*\s*[2-9]\d*\s*(个|条|根|块|张|颗)\s*$/ },
{ pattern: 'bracket_capacity_multiplier', regex: /[((][^()()]*\d+\s*[A-Za-z]{0,3}\s*\*\s*[2-9]\d*[^()()]*[))]/i },
{ pattern: 'inline_capacity_multiplier', regex: /(?:^|\s)\d+\s*[A-Za-z]{0,3}\s*\*\s*[2-9]\d*(?:\s|$)/i },
{ pattern: 'suffix_multiplier_with_paren_unit', regex: /\*\s*[2-9]\d*\s*[((]\s*\d+\s*[A-Za-z]{1,3}\s*[))]\s*$/i },
{ pattern: 'plus_capacity_pair', regex: /\d+(?:\.\d+)?\s*[A-Za-z]{0,3}\s*\+\s*\d+(?:\.\d+)?\s*[A-Za-z]{0,3}/i }
]
const CAPACITY_TOKEN_REGEX = /(\d+(?:\.\d+)?)\s*([A-Za-z]{1,3})/g
const normalizeCapacityUnit = (value) => {
const unit = String(value || '').trim().toUpperCase()
if (unit === 'GB') return 'G'
if (unit === 'TB') return 'T'
return unit
}
const parseCapacityToken = (value) => {
const matched = normalizeText(value).match(/^(\d+(?:\.\d+)?)\s*([A-Za-z]{0,3})$/)
if (!matched) {
return null
}
return {
raw: matched[0],
value: Number(matched[1]),
unit: matched[2] || '',
normalizedUnit: normalizeCapacityUnit(matched[2] || '')
}
}
const formatCapacityToken = (value, unit) => {
const numeric = Number(value)
const rendered = Number.isInteger(numeric) ? String(numeric) : String(numeric)
return `${rendered}${unit || ''}`
}
const findLastCapacityTokenWithUnit = (value) => {
const text = String(value || '')
let matched = null
let current = null
while ((current = CAPACITY_TOKEN_REGEX.exec(text)) !== null) {
matched = {
raw: current[0],
value: Number(current[1]),
unit: current[2],
normalizedUnit: normalizeCapacityUnit(current[2]),
index: current.index,
length: current[0].length
}
}
CAPACITY_TOKEN_REGEX.lastIndex = 0
return matched
}
const findFirstCapacityTokenWithUnit = (value) => {
const text = String(value || '')
const matched = CAPACITY_TOKEN_REGEX.exec(text)
CAPACITY_TOKEN_REGEX.lastIndex = 0
if (!matched) {
return null
}
return {
raw: matched[0],
value: Number(matched[1]),
unit: matched[2],
normalizedUnit: normalizeCapacityUnit(matched[2]),
index: matched.index,
length: matched[0].length
}
}
const countCapacityTokensWithUnit = (value) => {
const text = String(value || '')
const matched = text.match(/(\d+(?:\.\d+)?)\s*([A-Za-z]{1,3})/g)
return matched ? matched.length : 0
}
const splitAroundFirstCapacityToken = (value) => {
const text = normalizeText(value)
const token = findFirstCapacityTokenWithUnit(text)
if (!token) {
return null
}
return {
prefix: normalizeText(text.slice(0, token.index)),
token,
suffix: normalizeText(text.slice(token.index + token.length))
}
}
const buildModelText = (prefix, token, suffix) =>
cleanModelText(`${prefix || ''}${formatCapacityToken(token.value, token.unit)}${suffix ? ` ${suffix}` : ''}`)
const isMemoryChildType = (childType) => normalizeText(childType) === '内存条'
const isStorageChildType = (childType) => ['固态硬盘', '机械硬盘'].includes(normalizeText(childType))
const canShareMemoryPrefix = (value) => {
const text = normalizeText(value)
return Boolean(text) && !/[+/]/.test(text) && !/\d/.test(text)
}
const canShareMemorySuffix = (value) => {
const text = normalizeText(value)
return Boolean(text) && !/[+/]/.test(text) && !/[\\/]/.test(text)
}
const replaceTokenByIndex = (value, token, replacement) =>
`${value.slice(0, token.index)}${replacement}${value.slice(token.index + token.length)}`
const removeBundleKeyword = (value) =>
normalizeText(value)
.replace(/^套装/i, '')
.replace(/套装/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
const detectMultiUnitHint = (value) => {
const text = normalizeText(value)
if (!text) {
return null
}
return MULTI_UNIT_HINT_PATTERNS.find((item) => item.regex.test(text))?.pattern || null
}
const resolveBracketCapacitySplit = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*?)[((]\s*(\d+\s*[A-Za-z]{0,3}|\d+)\s*\*\s*([2-9]\d*)\s*[))](.*)$/i)
if (!matched) {
return null
}
const prefix = matched[1] || ''
const rawInner = matched[2] || ''
const quantity = Number(matched[3] || 0)
const suffix = matched[4] || ''
const inner = parseCapacityToken(rawInner)
const outer = findLastCapacityTokenWithUnit(prefix)
if (!inner || !outer || quantity < 2) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'missing_outer_or_inner_capacity_token',
pattern: 'bracket_capacity_multiplier',
originalModel: text
}
}
const unitToken = formatCapacityToken(inner.value, inner.unit || outer.unit)
if (outer.value !== inner.value * quantity) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'outer_capacity_not_equal_to_inner_times_quantity',
pattern: 'bracket_capacity_multiplier',
originalModel: text,
quantity
}
}
const updatedPrefix = replaceTokenByIndex(prefix, outer, unitToken)
const unitModel = cleanModelText(`${updatedPrefix}${removeBundleKeyword(suffix) ? ` ${removeBundleKeyword(suffix)}` : ''}`)
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity,
unitModel,
pattern: 'bracket_capacity_multiplier',
originalModel: text
}
}
const resolveInlineCapacitySplit = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*?)(\d+\s*[A-Za-z]{0,3}|\d+)\s*\*\s*([2-9]\d*)\s*$/i)
if (!matched) {
return null
}
const prefix = matched[1] || ''
const rawInner = matched[2] || ''
const quantity = Number(matched[3] || 0)
const inner = parseCapacityToken(rawInner)
const outer = findLastCapacityTokenWithUnit(prefix)
if (!inner || !outer || quantity < 2) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'missing_outer_or_inner_capacity_token',
pattern: 'inline_capacity_multiplier',
originalModel: text
}
}
const unitToken = formatCapacityToken(inner.value, inner.unit || outer.unit)
if (outer.value !== inner.value * quantity) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'outer_capacity_not_equal_to_inner_times_quantity',
pattern: 'inline_capacity_multiplier',
originalModel: text,
quantity
}
}
const updatedPrefix = replaceTokenByIndex(prefix, outer, unitToken)
const unitModel = cleanModelText(updatedPrefix)
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity,
unitModel,
pattern: 'inline_capacity_multiplier',
originalModel: text
}
}
const resolveSuffixMultiplierWithParenUnit = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*)\*\s*([2-9]\d*)\s*[((]\s*(\d+\s*[A-Za-z]{1,3})\s*[))]\s*$/i)
if (!matched) {
return null
}
const prefix = normalizeText(matched[1] || '')
const quantity = Number(matched[2] || 0)
const rawInner = normalizeText(matched[3] || '')
if (quantity < 2 || !prefix || !rawInner) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'invalid_suffix_multiplier_with_paren_unit',
pattern: 'suffix_multiplier_with_paren_unit',
originalModel: text
}
}
if (!prefix.includes(rawInner)) {
return {
isMultiUnit: true,
isAutoSplittable: false,
reason: 'paren_unit_not_present_in_prefix',
pattern: 'suffix_multiplier_with_paren_unit',
originalModel: text,
quantity
}
}
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity,
unitModel: cleanModelText(prefix),
pattern: 'suffix_multiplier_with_paren_unit',
originalModel: text
}
}
const resolveCpuXMultiplier = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*?)[((]\s*[xX]\s*([2-9]\d*)\s*[))]\s*$/)
if (!matched) {
return null
}
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity: Number(matched[2] || 0),
unitModel: cleanModelText(matched[1]),
pattern: 'cpu_x_multiplier',
originalModel: text
}
}
const resolveSuffixQuantityLabel = (value) => {
const text = normalizeText(value)
const matched = text.match(/^(.*?)\s*\*\s*([2-9]\d*)\s*(个|条|根|块|张|颗)\s*$/)
if (!matched) {
return null
}
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity: Number(matched[2] || 0),
unitModel: cleanModelText(matched[1]),
pattern: 'suffix_quantity_label',
originalModel: text
}
}
const resolveMemoryPlusSplit = (childType, value) => {
if (!isMemoryChildType(childType)) {
return null
}
const text = normalizeText(value)
const parts = text.split('+')
if (parts.length !== 2) {
return null
}
const left = splitAroundFirstCapacityToken(parts[0])
const right = splitAroundFirstCapacityToken(parts[1])
if (!left || !right || countCapacityTokensWithUnit(parts[0]) !== 1 || countCapacityTokensWithUnit(parts[1]) !== 1) {
return {
isMultiUnit: true,
isAutoSplittable: false,
quantity: 0,
unitModel: '',
reason: 'memory_plus_pattern_is_not_single_capacity_pair',
pattern: 'plus_capacity_pair',
originalModel: text
}
}
const leftPrefix = left.prefix || (canShareMemoryPrefix(right.prefix) ? right.prefix : '')
const rightPrefix = right.prefix || (canShareMemoryPrefix(left.prefix) ? left.prefix : '')
const leftSuffix = left.suffix || (canShareMemorySuffix(right.suffix) ? right.suffix : '')
const rightSuffix = right.suffix || (canShareMemorySuffix(left.suffix) ? left.suffix : '')
const models = [
buildModelText(leftPrefix, left.token, leftSuffix),
buildModelText(rightPrefix, right.token, rightSuffix)
]
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity: models.length,
unitModel: models[0] === models[1] ? models[0] : '',
unitModels: models,
pattern: 'plus_capacity_pair',
originalModel: text
}
}
const resolveStorageSimplePlusSplit = (childType, value) => {
if (!isStorageChildType(childType)) {
return null
}
const text = normalizeText(value)
const parts = text.split('+')
if (parts.length !== 2) {
return null
}
const left = splitAroundFirstCapacityToken(parts[0])
const right = splitAroundFirstCapacityToken(parts[1])
if (!left || !right || countCapacityTokensWithUnit(parts[0]) !== 1 || countCapacityTokensWithUnit(parts[1]) !== 1) {
return {
isMultiUnit: true,
isAutoSplittable: false,
quantity: 0,
unitModel: '',
reason: 'storage_plus_pattern_is_not_single_capacity_pair',
pattern: 'plus_capacity_pair',
originalModel: text
}
}
if (left.prefix || left.suffix || right.prefix || right.suffix) {
return {
isMultiUnit: true,
isAutoSplittable: false,
quantity: 0,
unitModel: '',
reason: 'storage_plus_pattern_contains_extra_model_details',
pattern: 'plus_capacity_pair',
originalModel: text
}
}
const models = [
formatCapacityToken(left.token.value, left.token.unit),
formatCapacityToken(right.token.value, right.token.unit)
]
return {
isMultiUnit: true,
isAutoSplittable: true,
quantity: models.length,
unitModel: models[0] === models[1] ? models[0] : '',
unitModels: models,
pattern: 'plus_capacity_pair',
originalModel: text
}
}
const isTypeAndModelConsistent = (childType, model) => {
const normalizedType = normalizeText(childType)
const normalizedModel = normalizeText(model).toUpperCase()
if (!normalizedType || !normalizedModel) {
return true
}
if ((normalizedType === '固态硬盘' || normalizedType === '机械硬盘') && /(DDR\d|MHZ|内存|套装)/i.test(normalizedModel)) {
return false
}
return true
}
const splitHardwareQuantity = (childType, value) => {
const text = normalizeText(value)
if (!text) {
return {
isMultiUnit: false,
isAutoSplittable: false,
quantity: 1,
unitModel: ''
}
}
const hintPattern = detectMultiUnitHint(text)
if (!hintPattern) {
return {
isMultiUnit: false,
isAutoSplittable: false,
quantity: 1,
unitModel: cleanModelText(text),
pattern: 'single_unit',
originalModel: text
}
}
const strategies = [
(type, text) => resolveCpuXMultiplier(text),
(type, text) => resolveSuffixQuantityLabel(text),
(type, text) => resolveBracketCapacitySplit(text),
(type, text) => resolveInlineCapacitySplit(text),
(type, text) => resolveSuffixMultiplierWithParenUnit(text),
resolveMemoryPlusSplit,
resolveStorageSimplePlusSplit
]
for (const strategy of strategies) {
const result = strategy(childType, text)
if (!result) {
continue
}
if (result.isAutoSplittable && !isTypeAndModelConsistent(childType, result.unitModel)) {
return {
...result,
isAutoSplittable: false,
reason: 'child_type_and_model_are_inconsistent'
}
}
return result
}
return {
isMultiUnit: true,
isAutoSplittable: false,
quantity: 0,
unitModel: '',
reason: 'pattern_detected_but_no_safe_split_rule',
pattern: hintPattern,
originalModel: text
}
}
const expandHardwareModels = (childType, value) => {
const result = splitHardwareQuantity(childType, value)
if (!result.isMultiUnit) {
return {
...result,
models: result.unitModel ? [result.unitModel] : []
}
}
if (!result.isAutoSplittable || !result.unitModel || !result.quantity) {
if (Array.isArray(result.unitModels) && result.unitModels.length > 0) {
return {
...result,
models: result.unitModels.map((item) => cleanModelText(item)).filter(Boolean)
}
}
return {
...result,
models: []
}
}
if (Array.isArray(result.unitModels) && result.unitModels.length > 0) {
return {
...result,
models: result.unitModels.map((item) => cleanModelText(item)).filter(Boolean)
}
}
return {
...result,
models: Array.from({ length: result.quantity }, () => result.unitModel)
}
}
module.exports = {
normalizeText,
cleanModelText,
detectMultiUnitHint,
splitHardwareQuantity,
expandHardwareModels
}

View File

@ -0,0 +1,858 @@
let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const { resolveStorageLocationName } = require('./locationNormalization')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
inputDir: '',
keepExisting: false,
includeOwnerless: false,
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
let explicitDryRun = false
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--keep-existing') {
options.keepExisting = true
} else if (arg === '--include-ownerless') {
options.includeOwnerless = true
} else if (arg === '--dry-run') {
explicitDryRun = true
} else if (arg === '--apply') {
options.apply = true
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
if (options.apply && explicitDryRun) {
throw new Error('--apply 与 --dry-run 不能同时使用')
}
options.dryRun = !options.apply
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const ensureOutputDir = (prefix) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const resolveLatestMigrationDir = () => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('migration_dry_run_'))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到 dry-run 输出目录: ${OUTPUT_ROOT}`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const resolveInputDir = (inputDirArg) => {
if (!inputDirArg) {
return resolveLatestMigrationDir()
}
const resolved = path.isAbsolute(inputDirArg)
? inputDirArg
: path.resolve(process.cwd(), inputDirArg)
if (!fs.existsSync(resolved)) {
throw new Error(`指定的输入目录不存在: ${resolved}`)
}
return resolved
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const readJsonl = (filePath) =>
fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
const parseDate = (value) => {
const normalized = normalizeText(value)
if (!normalized || normalized === '#REF!') {
return null
}
const directDate = new Date(normalized)
if (!Number.isNaN(directDate.getTime())) {
return directDate.toISOString()
}
return null
}
const buildDescription = (record) => {
const parts = [
['legacy_asset_code', record.assetCodeRaw || ''],
['source_row', record.sourceRow],
['source_status', record.currentStatusRaw || ''],
['raw_owner', record.ownerRaw || ''],
['raw_department', record.departmentRaw || ''],
['raw_location', record.addressRaw || ''],
['raw_category', record.assetCategoryRaw || '']
]
return parts
.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`)
.join('; ')
}
const collectDuplicateCodeDetails = (records) => {
const grouped = new Map()
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
continue
}
if (!grouped.has(legacyCode)) {
grouped.set(legacyCode, [])
}
grouped.get(legacyCode).push({
bucket: normalizeText(record.bucket),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
resolvedType: normalizeText(record.assetTypeResolvedName || record.assetTypeName),
address: normalizeText(record.addressRaw),
purchaseDate: normalizeText(record.purchaseDateParsed),
unitPrice: normalizeText(record.unitPriceParsed),
sourceStatus: normalizeText(record.currentStatusRaw),
statusKind: normalizeText(record.statusKind),
pendingReasons: Array.isArray(record.pendingReasons) ? record.pendingReasons : []
})
}
return [...grouped.entries()]
.filter(([, items]) => items.length > 1)
.map(([legacyCode, items]) => ({ legacyCode, items }))
}
const partitionImportCandidates = (records) => {
const missingCodeRows = []
const validCodeRecords = []
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
if (!legacyCode) {
missingCodeRows.push({
bucket: normalizeText(record.bucket),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
currentStatus: normalizeText(record.currentStatusRaw),
address: normalizeText(record.addressRaw)
})
continue
}
validCodeRecords.push(record)
}
const duplicateDetails = collectDuplicateCodeDetails(validCodeRecords)
const duplicateCodes = new Set(duplicateDetails.map((item) => item.legacyCode))
const filteredRecords = validCodeRecords.filter(
(record) => !duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase())
)
const duplicateRows = validCodeRecords
.filter((record) => duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase()))
.map((record) => ({
bucket: normalizeText(record.bucket),
legacyCode: normalizeText(record.assetCodeRaw).toUpperCase(),
sourceRow: record.sourceRow,
assetName: normalizeText(record.assetNameRaw),
owner: normalizeText(record.ownerRaw),
department: normalizeText(record.departmentRaw),
currentStatus: normalizeText(record.currentStatusRaw)
}))
return {
filteredRecords,
missingCodeRows,
duplicateRows,
duplicateDetails
}
}
const buildImportPayload = (records) => {
const payload = []
for (const record of records) {
const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase()
const importMode = record.bucket === 'import-without-owner' ? 'without_owner' : 'with_owner'
const statusKind = normalizeText(record.statusKind)
const pendingReasons = Array.isArray(record.pendingReasons) ? record.pendingReasons : []
const matchedPerson = record.ownerMatch?.matchedPerson || null
if (importMode === 'with_owner' && !matchedPerson?.id) {
throw new Error(`发现未匹配 owner 的 import-ready 记录,sourceRow=${record.sourceRow} code=${legacyCode}`)
}
if (importMode === 'without_owner' && !['assigned', 'unassigned'].includes(statusKind)) {
throw new Error(
`import-without-owner 记录状态异常,无法安全导入,sourceRow=${record.sourceRow} code=${legacyCode}`
)
}
const descriptionExtras = [`migration_bucket=${record.bucket}`]
if (importMode === 'without_owner') {
descriptionExtras.push(`owner_pending_reasons=${pendingReasons.join('|') || 'none'}`)
descriptionExtras.push(`owner_resolution=manual_followup_required`)
}
payload.push({
legacyCode,
importMode,
sourceStatusKind: statusKind,
unresolvedOwnerReasons: pendingReasons.join('|'),
rawOwnerName: normalizeText(record.ownerRaw),
equipmentName: normalizeText(record.assetNameRaw),
equipmentModel: normalizeText(record.equipment_model || record.modelRaw) || null,
equipmentType: normalizeText(record.assetTypeResolvedName || record.assetTypeName),
departmentName: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''),
storageLocationName: record.locationMatch
? normalizeText(record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue)
: '',
unitPrice: Number(record.unitPriceParsed || 0),
purchaseDate: normalizeText(record.purchaseDateParsed) || null,
supplier: '',
description: [buildDescription(record), ...descriptionExtras].join('; '),
ownerPersonId: matchedPerson?.id || null,
ownerNickname: normalizeText(matchedPerson?.nickname || ''),
ownerRealName: normalizeText(matchedPerson?.real_name || ''),
borrowTime: parseDate(record.claimedAtRaw) || parseDate(record.purchaseDateParsed) || null,
sourceRow: record.sourceRow
})
}
return payload
}
const runRemoteNodeScript = (script, connection) => {
const result = spawnSync(
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
maxBuffer: 64 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('远程脚本返回为空')
}
try {
return JSON.parse(stdout)
} catch {
throw new Error('远程脚本返回格式无效')
}
}
const buildRemoteBackupScript = () => `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const [
equipment,
borrowRecords,
maintenanceRecords,
disposalRecords,
mountHistories,
inventoryTasks,
inventoryTaskParticipants,
inventoryTaskItems,
inventoryTaskActions
] = await Promise.all([
prisma.equipment.findMany({ orderBy: { id: 'asc' } }),
prisma.borrowRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.maintenanceRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.disposalRecord.findMany({ orderBy: { id: 'asc' } }),
prisma.mountHistory.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTask.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskParticipant.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskItem.findMany({ orderBy: { id: 'asc' } }),
prisma.inventoryTaskAction.findMany({ orderBy: { id: 'asc' } })
])
console.log(JSON.stringify({
generatedAt: new Date().toISOString(),
counts: {
equipment: equipment.length,
borrowRecords: borrowRecords.length,
maintenanceRecords: maintenanceRecords.length,
disposalRecords: disposalRecords.length,
mountHistories: mountHistories.length,
inventoryTasks: inventoryTasks.length,
inventoryTaskParticipants: inventoryTaskParticipants.length,
inventoryTaskItems: inventoryTaskItems.length,
inventoryTaskActions: inventoryTaskActions.length
},
equipment,
borrowRecords,
maintenanceRecords,
disposalRecords,
mountHistories,
inventoryTasks,
inventoryTaskParticipants,
inventoryTaskItems,
inventoryTaskActions
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const buildRemoteWipeScript = () => `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const before = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
maintenanceRecords: await prisma.maintenanceRecord.count(),
disposalRecords: await prisma.disposalRecord.count(),
mountHistories: await prisma.mountHistory.count(),
inventoryTasks: await prisma.inventoryTask.count(),
inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(),
inventoryTaskItems: await prisma.inventoryTaskItem.count(),
inventoryTaskActions: await prisma.inventoryTaskAction.count()
}
await prisma.$transaction(async (tx) => {
await tx.inventoryTaskAction.deleteMany({})
await tx.inventoryTaskItem.deleteMany({})
await tx.inventoryTaskParticipant.deleteMany({})
await tx.inventoryTask.deleteMany({})
await tx.maintenanceRecord.deleteMany({})
await tx.disposalRecord.deleteMany({})
await tx.mountHistory.deleteMany({})
await tx.borrowRecord.deleteMany({})
await tx.equipment.deleteMany({})
})
const after = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
maintenanceRecords: await prisma.maintenanceRecord.count(),
disposalRecords: await prisma.disposalRecord.count(),
mountHistories: await prisma.mountHistory.count(),
inventoryTasks: await prisma.inventoryTask.count(),
inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(),
inventoryTaskItems: await prisma.inventoryTaskItem.count(),
inventoryTaskActions: await prisma.inventoryTaskAction.count()
}
console.log(JSON.stringify({ before, after }))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const buildRemoteImportScript = (payload, keepExisting) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const keepExisting = ${keepExisting ? 'true' : 'false'}
const normalizeText = (value) => String(value == null ? '' : value).replace(/\\r/g, ' ').replace(/\\n/g, ' ').replace(/\\s+/g, ' ').trim()
const toDateOrNull = (value) => {
const normalized = normalizeText(value)
if (!normalized) return null
const parsed = new Date(normalized)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
const calculateValues = (unitPrice, remainingQuantity) => {
const normalizedUnitPrice = Number(unitPrice || 0)
const totalValue = normalizedUnitPrice
const borrowedQuantity = Math.max(1 - Number(remainingQuantity || 0), 0)
return {
totalValue,
borrowedValue: normalizedUnitPrice * borrowedQuantity,
availableValue: normalizedUnitPrice * Number(remainingQuantity || 0)
}
}
;(async () => {
const [assetTypes, departments, locations] = await Promise.all([
prisma.assetType.findMany({ where: { status: 'active' }, select: { id: true, name: true } }),
prisma.department.findMany({
where: { status: 'active', source_system: 'ding_hr_business' },
select: { id: true, name: true }
}),
prisma.storageLocation.findMany({ where: { status: 'active' }, select: { id: true, name: true } })
])
const assetTypeMap = new Map(assetTypes.map((item) => [item.name, item]))
const departmentMap = new Map(departments.map((item) => [item.name, item]))
const locationMap = new Map(locations.map((item) => [item.name, item]))
const duplicatePayloadCodes = new Set()
const payloadCodes = new Set()
for (const item of payload) {
if (payloadCodes.has(item.legacyCode)) {
duplicatePayloadCodes.add(item.legacyCode)
}
payloadCodes.add(item.legacyCode)
}
if (duplicatePayloadCodes.size > 0) {
throw new Error('导入 payload 中存在重复资产编码: ' + [...duplicatePayloadCodes].join(', '))
}
const existingCodes = await prisma.equipment.findMany({
where: { equipment_code: { in: [...payloadCodes] } },
select: { equipment_code: true }
})
if (!keepExisting && existingCodes.length > 0) {
throw new Error('线上已存在同名资产编码: ' + existingCodes.map((item) => item.equipment_code).join(', '))
}
const existingCodeSet = new Set(existingCodes.map((item) => item.equipment_code))
const results = []
const skippedExisting = []
for (const item of payload) {
if (existingCodeSet.has(item.legacyCode)) {
skippedExisting.push({
legacyCode: item.legacyCode,
importMode: item.importMode,
reason: 'already_exists'
})
continue
}
const assetType = assetTypeMap.get(item.equipmentType)
if (!assetType) {
throw new Error('资产类型不存在: ' + item.equipmentType + ' code=' + item.legacyCode)
}
const department = item.departmentName ? departmentMap.get(item.departmentName) || null : null
if (item.departmentName && !department) {
throw new Error('业务部门不存在: ' + item.departmentName + ' code=' + item.legacyCode)
}
const location = item.storageLocationName ? locationMap.get(item.storageLocationName) || null : null
if (item.storageLocationName && !location) {
throw new Error('存放地点不存在: ' + item.storageLocationName + ' code=' + item.legacyCode)
}
let person = null
if (item.importMode === 'with_owner') {
person = await prisma.person.findFirst({
where: {
id: item.ownerPersonId,
status: 'active',
source_system: 'ding_hr'
},
select: {
id: true,
real_name: true,
nickname: true,
id_number: true
}
})
if (!person) {
throw new Error('使用人不存在或不再是 active: personId=' + item.ownerPersonId + ' code=' + item.legacyCode)
}
}
const unitPrice = Number(item.unitPrice || 0)
const purchaseDate = toDateOrNull(item.purchaseDate)
const borrowTime = toDateOrNull(item.borrowTime) || new Date()
let equipmentStatus = '可用'
let remainingQuantity = 1
let borrowRecordNeeded = false
let borrowNotes = 'Excel迁移导入;在职长期'
if (item.importMode === 'with_owner') {
equipmentStatus = '已借出'
remainingQuantity = 0
borrowRecordNeeded = true
} else if (item.sourceStatusKind === 'assigned') {
equipmentStatus = '已借出'
remainingQuantity = 0
borrowNotes = 'Excel迁移导入;当前使用人待行政补录'
} else if (item.sourceStatusKind === 'unassigned') {
equipmentStatus = '可用'
remainingQuantity = 1
} else {
throw new Error('ownerless 资产状态异常: ' + item.sourceStatusKind + ' code=' + item.legacyCode)
}
const values = calculateValues(unitPrice, remainingQuantity)
const created = await prisma.$transaction(async (tx) => {
const equipment = await tx.equipment.create({
data: {
equipment_code: item.legacyCode,
equipment_name: item.equipmentName,
equipment_model: normalizeText(item.equipmentModel) || null,
equipment_type: assetType.name,
code_generation_mode: 'manual',
status: equipmentStatus,
total_quantity: 1,
remaining_quantity: remainingQuantity,
unit_price: unitPrice,
total_value: values.totalValue,
borrowed_value: values.borrowedValue,
available_value: values.availableValue,
total_value_with_children: values.totalValue,
description: normalizeText(item.description) || null,
purchase_date: purchaseDate,
supplier: normalizeText(item.supplier) || null,
storage_location_id: location?.id,
asset_type_id: assetType.id,
department_id: department?.id,
mount_status: 'none'
}
})
if (borrowRecordNeeded && person) {
await tx.borrowRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.id_number,
borrow_time: borrowTime,
expected_return_time: null,
is_long_term: true,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: borrowNotes
}
})
}
return {
id: equipment.id,
equipment_code: equipment.equipment_code,
importMode: item.importMode,
status: equipment.status,
person_id: person?.id || null,
person_nickname: person?.nickname || null
}
})
results.push(created)
}
const verification = {
equipment: await prisma.equipment.count(),
borrowRecords: await prisma.borrowRecord.count(),
activeBorrow: await prisma.borrowRecord.count({ where: { status: '已借出' } }),
importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: [...payloadCodes] } } }),
importedWithOwnerCount: results.filter((item) => item.importMode === 'with_owner').length,
importedWithoutOwnerCount: results.filter((item) => item.importMode === 'without_owner').length,
skippedExistingCount: skippedExisting.length
}
console.log(JSON.stringify({
imported: results.length,
verification,
skippedExisting,
sample: results.slice(0, 10)
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const buildRemoteVerifyScript = (payload) => {
const payloadBase64 = Buffer.from(
JSON.stringify(payload.map((item) => item.legacyCode)),
'utf8'
).toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const codes = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
;(async () => {
const [equipment, borrowRecords, activeBorrow, importedEquipments] = await Promise.all([
prisma.equipment.count(),
prisma.borrowRecord.count(),
prisma.borrowRecord.count({ where: { status: '已借出' } }),
prisma.equipment.findMany({
where: { equipment_code: { in: codes } },
orderBy: { equipment_code: 'asc' },
take: 20,
select: {
equipment_code: true,
equipment_name: true,
equipment_type: true,
status: true,
remaining_quantity: true,
department: { select: { name: true } },
storageLocation: { select: { name: true } }
}
})
])
console.log(JSON.stringify({
equipment,
borrowRecords,
activeBorrow,
importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: codes } } }),
sample: importedEquipments
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const run = () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const inputDir = resolveInputDir(options.inputDir)
const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl')
if (!fs.existsSync(normalizedPath)) {
throw new Error(`未找到 normalized_assets.jsonl: ${normalizedPath}`)
}
const normalizedRecords = readJsonl(normalizedPath)
const importReadyRecords = normalizedRecords.filter((record) => record.bucket === 'import-ready')
const importWithoutOwnerRecords = options.includeOwnerless
? normalizedRecords.filter((record) => record.bucket === 'import-without-owner')
: []
const importCandidates = [...importReadyRecords, ...importWithoutOwnerRecords]
if (!importCandidates.length) {
throw new Error('没有可导入的确认记录')
}
const partitioned = partitionImportCandidates(importCandidates)
const payload = buildImportPayload(partitioned.filteredRecords)
const backupDir = ensureOutputDir(options.apply ? 'live_import_backup' : 'live_import_preview')
writeJson(path.join(backupDir, 'excluded_missing_code_rows.json'), {
excludedMissingCodeCount: partitioned.missingCodeRows.length,
rows: partitioned.missingCodeRows
})
writeJson(path.join(backupDir, 'excluded_duplicate_codes.json'), {
excludedDuplicateCodeCount: partitioned.duplicateDetails.length,
excludedDuplicateRecordCount: partitioned.duplicateRows.length,
duplicateDetails: partitioned.duplicateDetails,
excludedRows: partitioned.duplicateRows
})
writeJson(path.join(backupDir, 'planned_import_summary.json'), {
inputDir,
importReadyCount: importReadyRecords.length,
importWithoutOwnerCount: importWithoutOwnerRecords.length,
selectedCandidateCount: importCandidates.length,
excludedMissingCodeCount: partitioned.missingCodeRows.length,
excludedDuplicateRecordCount: partitioned.duplicateRows.length,
excludedDuplicateCodeCount: partitioned.duplicateDetails.length,
finalImportCount: payload.length,
finalImportReadyCount: payload.filter((item) => item.importMode === 'with_owner').length,
finalImportWithoutOwnerCount: payload.filter((item) => item.importMode === 'without_owner').length,
apply: options.apply,
keepExisting: options.keepExisting,
includeOwnerless: options.includeOwnerless
})
writeJson(path.join(backupDir, 'planned_payload.json'), {
payloadCount: payload.length,
payload
})
const backup = runRemoteNodeScript(buildRemoteBackupScript(), connection)
writeJson(path.join(backupDir, 'pre_import_live_backup.json'), backup)
if (!options.apply) {
console.log(
JSON.stringify(
{
backupDir,
inputDir,
dryRun: true,
plannedImportCount: payload.length,
message: '当前仅完成 preview,未执行清空或导入;如确认写入,请显式传入 --apply。'
},
null,
2
)
)
return
}
let wipeResult = { skipped: true }
if (!options.keepExisting) {
wipeResult = runRemoteNodeScript(buildRemoteWipeScript(), connection)
writeJson(path.join(backupDir, 'wipe_result.json'), wipeResult)
}
const importResult = runRemoteNodeScript(
buildRemoteImportScript(payload, options.keepExisting),
connection
)
writeJson(path.join(backupDir, 'import_result.json'), importResult)
const verification = runRemoteNodeScript(buildRemoteVerifyScript(payload), connection)
writeJson(path.join(backupDir, 'post_import_verification.json'), verification)
console.log(
JSON.stringify(
{
backupDir,
inputDir,
importedCount: importResult.imported,
wipeResult,
importResult,
verification
},
null,
2
)
)
}
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -0,0 +1,854 @@
const fs = require('fs')
const path = require('path')
const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs'))
const { resolveStorageLocationMatch } = require('./locationNormalization')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DEFAULT_API_BASE = 'https://www.jsgdzc.cn/api'
const MIGRATION_MARKER = 'parent_child_excel_20260323'
const PARENT_BUILDABLE_STATUSES = new Set(['可用', '已借出', '维护中'])
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
mode: 'preflight',
planDir: '',
apiBase: DEFAULT_API_BASE,
username: '',
password: '',
limit: 0,
parentCode: '',
sourceRow: 0
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--mode') {
options.mode = args[index + 1] || options.mode
index += 1
} else if (arg === '--plan-dir') {
options.planDir = args[index + 1] || ''
index += 1
} else if (arg === '--api-base') {
options.apiBase = args[index + 1] || DEFAULT_API_BASE
index += 1
} else if (arg === '--username') {
options.username = args[index + 1] || ''
index += 1
} else if (arg === '--password') {
options.password = args[index + 1] || ''
index += 1
} else if (arg === '--limit') {
options.limit = Number(args[index + 1] || 0) || 0
index += 1
} else if (arg === '--parent-code') {
options.parentCode = args[index + 1] || ''
index += 1
} else if (arg === '--source-row') {
options.sourceRow = Number(args[index + 1] || 0) || 0
index += 1
}
}
return options
}
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const normalizeMode = (value) => {
const mode = normalizeText(value).toLowerCase()
if (mode === 'apply') {
return 'apply'
}
return 'preflight'
}
const resolvePlanDir = (value) => {
if (normalizeText(value)) {
const resolved = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value)
if (!fs.existsSync(resolved)) {
throw new Error(`计划目录不存在: ${resolved}`)
}
return resolved
}
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('parent_child_dry_run_'))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到 parent_child_dry_run 输出目录: ${OUTPUT_ROOT}`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const ensureOutputDir = (prefix) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const escapeCsv = (value) => {
const stringValue = String(value == null ? '' : value)
if (/[,"\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
const writeCsv = (filePath, headers, rows) => {
const lines = [headers.map(escapeCsv).join(',')]
for (const row of rows) {
lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(','))
}
fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`)
}
const buildSummaryRows = (summary) =>
Object.entries(summary).map(([key, value]) => ({
key,
value: Array.isArray(value) || (value && typeof value === 'object') ? JSON.stringify(value) : value
}))
const writeWorkbook = async (filePath, sheets) => {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.created = new Date()
for (const sheet of sheets) {
const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}]
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
const worksheet = workbook.addWorksheet(sheet.name)
worksheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.min(Math.max(header.length + 4, 16), 48)
}))
rows.forEach((row) => worksheet.addRow(row))
worksheet.getRow(1).font = { bold: true }
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
}
await workbook.xlsx.writeFile(filePath)
}
const loadPlan = (planDir, options) => {
const reportPath = path.join(planDir, 'report.json')
const summaryPath = path.join(planDir, 'summary.json')
if (!fs.existsSync(reportPath)) {
throw new Error(`未找到 report.json: ${reportPath}`)
}
const report = readJsonFile(reportPath)
const summary = fs.existsSync(summaryPath) ? readJsonFile(summaryPath) : report.summary || {}
let plannedChildren = Array.isArray(report.plannedChildren) ? report.plannedChildren : []
if (normalizeText(options.parentCode)) {
const parentCodeKey = normalizeLookupKey(options.parentCode)
plannedChildren = plannedChildren.filter((item) => normalizeLookupKey(item.parent_code) === parentCodeKey)
}
if (options.sourceRow > 0) {
plannedChildren = plannedChildren.filter((item) => Number(item.source_row) === Number(options.sourceRow))
}
if (options.limit > 0) {
plannedChildren = plannedChildren.slice(0, options.limit)
}
return {
planDir,
report,
summary,
plannedChildren
}
}
const sanitizeTokenValue = (value) => normalizeText(value).replace(/;/g, ',')
const buildActionKey = (item) =>
[
normalizeLookupKey(item.parent_code),
normalizeLookupKey(item.field_key),
Number(item.source_row) || 0,
Number(item.split_index) || 1
].join('||')
const buildChildTypeModelKey = (type, model) =>
`${normalizeLookupKey(type)}||${normalizeLookupKey(model)}`
const buildMigrationTokens = (item) => ({
migration_marker: MIGRATION_MARKER,
source_row: String(Number(item.source_row) || 0),
parent_code: sanitizeTokenValue(item.parent_code),
field_key: sanitizeTokenValue(item.field_key),
split_index: String(Number(item.split_index) || 1)
})
const buildChildDescription = (item) => {
const tokens = [
['migration_marker', MIGRATION_MARKER],
['source_row', Number(item.source_row) || 0],
['parent_code', item.parent_code],
['field_key', item.field_key],
['child_type', item.child_type],
['child_model', item.child_model],
['source_child_model', item.source_child_model || item.child_model],
['split_index', Number(item.split_index) || 1],
['split_quantity', Number(item.split_quantity) || 1],
['split_pattern', item.split_pattern || 'single_unit'],
['parent_name', item.parent_name],
['parent_type', item.parent_type],
['workbook_department', item.workbook_department],
['workbook_owner', item.workbook_owner],
['workbook_location', item.workbook_location]
]
return tokens.map(([key, value]) => `${key}=${sanitizeTokenValue(value)}`).join('; ')
}
const buildMountNote = (item) =>
Number(item.split_quantity) > 1
? `根据8.xlsx第${item.source_row}行补建${item.child_name || item.child_type}第${item.split_index}/${item.split_quantity}件,字段=${item.field_key},原始型号=${sanitizeTokenValue(item.source_child_model || item.child_model)},拆分后型号=${sanitizeTokenValue(item.child_model)}`
: `根据8.xlsx第${item.source_row}行补建${item.child_name || item.child_type},字段=${item.field_key},型号=${sanitizeTokenValue(item.child_model)}`
const descriptionMatchesPlan = (description, item) => {
const normalizedDescription = normalizeText(description)
if (!normalizedDescription) {
return false
}
const tokens = buildMigrationTokens(item)
return Object.entries(tokens).every(([key, value]) =>
normalizedDescription.includes(`${key}=${sanitizeTokenValue(value)}`)
)
}
const buildCookieHeader = (response) => {
if (typeof response.headers.getSetCookie === 'function') {
return response.headers.getSetCookie().map((item) => item.split(';')[0]).join('; ')
}
const setCookie = response.headers.get('set-cookie')
return setCookie ? setCookie.split(';')[0] : ''
}
class ApiSession {
constructor({ apiBase, username, password }) {
this.apiBase = normalizeText(apiBase).replace(/\/$/, '')
this.username = normalizeText(username)
this.password = String(password == null ? '' : password)
this.cookieHeader = ''
}
async login() {
if (!this.apiBase || !this.username || !this.password) {
throw new Error('缺少 API 登录参数')
}
const response = await fetch(`${this.apiBase}/auth/local/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: this.username,
password: this.password
})
})
const text = await response.text()
if (!response.ok) {
throw new Error(`登录失败: status=${response.status} body=${text}`)
}
this.cookieHeader = buildCookieHeader(response)
if (!this.cookieHeader) {
throw new Error('登录成功但未拿到 session cookie')
}
return JSON.parse(text)
}
async requestJson(endpoint, options = {}, retry = true) {
if (!this.cookieHeader) {
await this.login()
}
const headers = {
...(options.headers || {}),
Cookie: this.cookieHeader
}
const response = await fetch(`${this.apiBase}${endpoint}`, {
...options,
headers
})
const text = await response.text()
if (response.status === 401 && retry) {
await this.login()
return this.requestJson(endpoint, options, false)
}
if (!response.ok) {
throw new Error(`请求失败: ${endpoint} status=${response.status} body=${text}`)
}
return JSON.parse(text)
}
}
const unwrapListPayload = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (payload && Array.isArray(payload.list)) {
return payload.list
}
if (payload?.data && Array.isArray(payload.data.list)) {
return payload.data.list
}
throw new Error('接口返回结构不符合预期,未找到 list')
}
const fetchLiveData = async (session, outputDir, tag) => {
const [equipmentPayload, assetTypePayload, storageLocationPayload] = await Promise.all([
session.requestJson('/equipment/list?page=1&pageSize=5000'),
session.requestJson('/asset-types/list?page=1&pageSize=200'),
session.requestJson('/storage-locations/list?page=1&pageSize=200')
])
const equipmentSourcePath = path.join(outputDir, `live_equipment_${tag}.json`)
const assetTypeSourcePath = path.join(outputDir, `live_asset_types_${tag}.json`)
const storageLocationSourcePath = path.join(outputDir, `live_storage_locations_${tag}.json`)
writeJson(equipmentSourcePath, equipmentPayload)
writeJson(assetTypeSourcePath, assetTypePayload)
writeJson(storageLocationSourcePath, storageLocationPayload)
return {
equipmentList: unwrapListPayload(equipmentPayload),
assetTypes: unwrapListPayload(assetTypePayload),
storageLocations: unwrapListPayload(storageLocationPayload),
equipmentSourcePath,
assetTypeSourcePath,
storageLocationSourcePath
}
}
const buildLiveRefs = (liveData) => {
const topLevelByCode = new Map()
const topLevelItems = Array.isArray(liveData.equipmentList) ? liveData.equipmentList : []
for (const item of topLevelItems) {
const code = normalizeLookupKey(item.equipment_code)
if (code) {
topLevelByCode.set(code, item)
}
}
const assetTypeByName = new Map()
for (const item of liveData.assetTypes || []) {
assetTypeByName.set(normalizeLookupKey(item.name), item)
}
const locationByName = new Map()
for (const item of liveData.storageLocations || []) {
locationByName.set(normalizeLookupKey(item.name), item)
}
return {
topLevelItems,
topLevelByCode,
assetTypeByName,
locationByName
}
}
const summarizeByReason = (rows, reasonKey = 'reason') => {
const counts = {}
for (const row of rows) {
const reason = normalizeText(row[reasonKey]) || 'unknown'
counts[reason] = (counts[reason] || 0) + 1
}
return counts
}
const analyzePlan = (plan, liveData) => {
const refs = buildLiveRefs(liveData)
const actions = []
const blocked = []
const satisfied = []
const mountedChildQueueCache = new Map()
const getMountedChildQueue = (parent, key) => {
const cacheKey = `${parent.id}||${key}`
if (mountedChildQueueCache.has(cacheKey)) {
return mountedChildQueueCache.get(cacheKey)
}
const queue = (Array.isArray(parent.children) ? parent.children : []).filter(
(child) => buildChildTypeModelKey(child.equipment_type, child.equipment_model) === key
)
mountedChildQueueCache.set(cacheKey, queue)
return queue
}
for (const item of plan.plannedChildren) {
const parentCodeKey = normalizeLookupKey(item.parent_code)
const parent = refs.topLevelByCode.get(parentCodeKey)
const actionBase = {
action_key: buildActionKey(item),
source_row: item.source_row,
parent_code: item.parent_code,
parent_name: item.parent_name,
parent_type: item.parent_type,
parent_status: item.parent_status,
child_type: item.child_type,
child_name: item.child_name,
child_model: item.child_model,
source_child_model: item.source_child_model || item.child_model,
field_key: item.field_key,
split_index: Number(item.split_index) || 1,
split_quantity: Number(item.split_quantity) || 1,
split_pattern: item.split_pattern || 'single_unit',
notes: item.notes,
workbook_department: item.workbook_department,
workbook_owner: item.workbook_owner,
workbook_location: item.workbook_location,
planned_description: buildChildDescription(item),
planned_mount_note: buildMountNote(item)
}
const block = (reason, extra = {}) => {
blocked.push({
...actionBase,
reason,
...extra
})
}
if (!parent) {
block('live_parent_not_found')
continue
}
const liveParentType = refs.assetTypeByName.get(normalizeLookupKey(parent.equipment_type))
if (!liveParentType?.can_be_parent) {
block('live_parent_type_not_parent_capable', { live_parent_type: parent.equipment_type })
continue
}
if (parent.parent_id != null) {
block('live_parent_is_child', { live_parent_id: parent.parent_id })
continue
}
if (!PARENT_BUILDABLE_STATUSES.has(normalizeText(parent.status))) {
block('live_parent_status_not_buildable', { live_parent_status: parent.status })
continue
}
const childTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(item.child_type))
if (!childTypeConfig?.can_be_child) {
block('child_asset_type_not_child_capable')
continue
}
const expectedChildKey = buildChildTypeModelKey(item.child_type, item.child_model)
const matchingMountedChildQueue = getMountedChildQueue(parent, expectedChildKey)
const matchingMountedChild = matchingMountedChildQueue.shift()
if (matchingMountedChild) {
satisfied.push({
...actionBase,
reason: 'already_mounted_matching_type_model',
live_parent_id: parent.id,
live_child_id: matchingMountedChild.id,
live_child_code: matchingMountedChild.equipment_code,
live_child_status: matchingMountedChild.status
})
continue
}
const orphanMatches = refs.topLevelItems.filter((equipment) => descriptionMatchesPlan(equipment.description, item))
if (orphanMatches.length > 1) {
block('duplicate_script_created_orphans', {
matched_orphan_codes: orphanMatches.map((equipment) => equipment.equipment_code).join(' | ')
})
continue
}
if (orphanMatches.length === 1) {
const orphan = orphanMatches[0]
if (buildChildTypeModelKey(orphan.equipment_type, orphan.equipment_model) !== expectedChildKey) {
block('script_created_orphan_model_mismatch', {
matched_orphan_code: orphan.equipment_code,
matched_orphan_type: orphan.equipment_type,
matched_orphan_model: orphan.equipment_model
})
continue
}
if (orphan.parent_id != null) {
block('script_created_orphan_already_mounted_elsewhere', {
matched_orphan_code: orphan.equipment_code,
matched_orphan_parent_id: orphan.parent_id
})
continue
}
if (normalizeText(orphan.status) !== '可用' || Number(orphan.remaining_quantity) <= 0) {
block('script_created_orphan_not_mountable', {
matched_orphan_code: orphan.equipment_code,
matched_orphan_status: orphan.status,
matched_orphan_remaining_quantity: orphan.remaining_quantity
})
continue
}
if (orphan.current_borrow) {
block('script_created_orphan_has_active_borrow', {
matched_orphan_code: orphan.equipment_code
})
continue
}
actions.push({
...actionBase,
action_type: 'mount_existing',
live_parent_id: parent.id,
existing_child_id: orphan.id,
existing_child_code: orphan.equipment_code,
parent_storage_location_id: parent.storage_location_id,
parent_department_id: parent.department_id
})
continue
}
const fallbackLocationResolution = !parent.storage_location_id
? resolveStorageLocationMatch(item.workbook_location, refs.locationByName)
: { match: null, normalizedValue: '', rule: '' }
const resolvedStorageLocationId = parent.storage_location_id || fallbackLocationResolution.match?.id || null
const resolvedStorageLocationName =
parent.storageLocation?.name ||
fallbackLocationResolution.match?.name ||
''
if (!resolvedStorageLocationId) {
block('live_parent_missing_storage_location', {
live_parent_id: parent.id,
workbook_location_resolution: fallbackLocationResolution.normalizedValue || '',
workbook_location_rule: fallbackLocationResolution.rule || ''
})
continue
}
actions.push({
...actionBase,
action_type: 'create_and_mount',
live_parent_id: parent.id,
parent_storage_location_id: resolvedStorageLocationId,
parent_storage_location_name: resolvedStorageLocationName,
parent_storage_location_source: parent.storage_location_id ? 'live_parent' : 'workbook_fallback',
parent_department_id: parent.department_id
})
}
const summary = {
planned_child_count: plan.plannedChildren.length,
actionable_count: actions.length,
actionable_create_and_mount_count: actions.filter((item) => item.action_type === 'create_and_mount').length,
actionable_mount_existing_count: actions.filter((item) => item.action_type === 'mount_existing').length,
satisfied_count: satisfied.length,
blocked_count: blocked.length,
blocked_reason_counts: summarizeByReason(blocked),
satisfied_reason_counts: summarizeByReason(satisfied)
}
return {
summary,
actions,
blocked,
satisfied
}
}
const buildCreateChildPayload = (action) => ({
entry_mode: 'child',
equipment_name: action.child_name,
equipment_model: action.child_model,
equipment_type: action.child_type,
code_generation_mode: 'auto',
unit_price: 0,
total_quantity: 1,
remaining_quantity: 1,
status: '可用',
storage_location_id: action.parent_storage_location_id,
department_id: action.parent_department_id || null,
description: action.planned_description
})
const applyActions = async (session, analysis) => {
const results = []
const errors = []
for (let index = 0; index < analysis.actions.length; index += 1) {
const action = analysis.actions[index]
const progress = `${index + 1}/${analysis.actions.length}`
console.log(`[${progress}] ${action.action_type} ${action.parent_code} ${action.child_type} ${action.child_model}`)
let createdChild = null
let stage = 'mount'
try {
let childId = action.existing_child_id || null
let childCode = action.existing_child_code || ''
if (action.action_type === 'create_and_mount') {
stage = 'create'
const createResult = await session.requestJson('/equipment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildCreateChildPayload(action))
})
createdChild = createResult?.data || null
childId = createdChild?.id || null
childCode = normalizeText(createdChild?.equipment_code)
if (!childId || !childCode) {
throw new Error('创建子资产返回的数据不完整')
}
}
stage = 'mount'
await session.requestJson(`/mount/${action.live_parent_id}/mount`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
child_id: childId,
notes: action.planned_mount_note
})
})
results.push({
...action,
apply_status: 'success',
child_id: childId,
child_code: childCode,
created_new_child: action.action_type === 'create_and_mount' ? 'yes' : 'no'
})
} catch (error) {
let cleanupStatus = 'not_needed'
let cleanupMessage = ''
if (action.action_type === 'create_and_mount' && createdChild?.id) {
cleanupStatus = 'pending'
try {
await session.requestJson(`/equipment/${createdChild.id}`, {
method: 'DELETE'
})
cleanupStatus = 'deleted'
} catch (cleanupError) {
cleanupStatus = 'delete_failed'
cleanupMessage = cleanupError?.message || String(cleanupError)
}
}
errors.push({
...action,
apply_status: 'failed',
failed_stage: stage,
error_message: error?.message || String(error),
created_child_id: createdChild?.id || '',
created_child_code: createdChild?.equipment_code || '',
cleanup_status: cleanupStatus,
cleanup_message: cleanupMessage
})
break
}
}
return {
results,
errors,
stopped_early: errors.length > 0
}
}
const writeAnalysisOutputs = async ({
outputDir,
plan,
liveData,
analysis,
execution = null,
postVerify = null,
mode
}) => {
const manifest = {
generated_at: new Date().toISOString(),
mode,
plan_dir: plan.planDir,
source_plan_summary: plan.summary,
selected_planned_child_count: plan.plannedChildren.length,
live_total_equipment: liveData.equipmentList.length,
live_total_asset_types: liveData.assetTypes.length,
live_total_storage_locations: (liveData.storageLocations || []).length,
analysis_summary: analysis.summary,
execution_summary: execution
? {
success_count: execution.results.length,
error_count: execution.errors.length,
stopped_early: execution.stopped_early
}
: null,
post_verify_summary: postVerify?.summary || null
}
writeJson(path.join(outputDir, 'manifest.json'), manifest)
writeJson(path.join(outputDir, 'analysis_summary.json'), analysis.summary)
writeJson(path.join(outputDir, 'blocked.json'), analysis.blocked)
writeJson(path.join(outputDir, 'satisfied.json'), analysis.satisfied)
writeJson(path.join(outputDir, 'actions.json'), analysis.actions)
writeCsv(
path.join(outputDir, 'actions.csv'),
Object.keys(analysis.actions[0] || { action_key: '', action_type: '', parent_code: '', child_type: '', child_model: '' }),
analysis.actions
)
writeCsv(
path.join(outputDir, 'blocked.csv'),
Object.keys(analysis.blocked[0] || { action_key: '', reason: '', parent_code: '', child_type: '', child_model: '' }),
analysis.blocked
)
writeCsv(
path.join(outputDir, 'satisfied.csv'),
Object.keys(analysis.satisfied[0] || { action_key: '', reason: '', parent_code: '', child_type: '', child_model: '' }),
analysis.satisfied
)
const workbookSheets = [
{ name: 'Summary', rows: buildSummaryRows(manifest) },
{ name: 'Actions', rows: analysis.actions },
{ name: 'Blocked', rows: analysis.blocked },
{ name: 'Satisfied', rows: analysis.satisfied },
{ name: 'OriginalSkippedRows', rows: plan.report.skippedRows || [] }
]
if (execution) {
writeJson(path.join(outputDir, 'apply_results.json'), execution.results)
writeJson(path.join(outputDir, 'apply_errors.json'), execution.errors)
writeCsv(
path.join(outputDir, 'apply_results.csv'),
Object.keys(execution.results[0] || { action_key: '', apply_status: '', child_code: '' }),
execution.results
)
writeCsv(
path.join(outputDir, 'apply_errors.csv'),
Object.keys(execution.errors[0] || { action_key: '', apply_status: '', error_message: '' }),
execution.errors
)
workbookSheets.push({ name: 'ApplyResults', rows: execution.results })
workbookSheets.push({ name: 'ApplyErrors', rows: execution.errors })
}
if (postVerify) {
writeJson(path.join(outputDir, 'post_verify.json'), postVerify)
workbookSheets.push({ name: 'PostVerifySummary', rows: buildSummaryRows(postVerify.summary || {}) })
workbookSheets.push({ name: 'PostVerifyBlocked', rows: postVerify.blocked || [] })
workbookSheets.push({ name: 'PostVerifyActions', rows: postVerify.actions || [] })
workbookSheets.push({ name: 'PostVerifySatisfied', rows: postVerify.satisfied || [] })
}
await writeWorkbook(path.join(outputDir, 'parent_child_live_migration.xlsx'), workbookSheets)
}
const run = async () => {
const options = parseArgs()
const mode = normalizeMode(options.mode)
const plan = loadPlan(resolvePlanDir(options.planDir), options)
const outputDir = ensureOutputDir(
mode === 'apply' ? 'parent_child_live_apply' : 'parent_child_live_preflight'
)
if (!plan.plannedChildren.length) {
throw new Error('筛选后没有可处理的 plannedChildren')
}
const session = new ApiSession({
apiBase: options.apiBase,
username: options.username,
password: options.password
})
const liveBefore = await fetchLiveData(session, outputDir, 'before')
const analysis = analyzePlan(plan, liveBefore)
let execution = null
let postVerify = null
if (mode === 'apply' && analysis.actions.length > 0) {
execution = await applyActions(session, analysis)
const liveAfter = await fetchLiveData(session, outputDir, 'after')
postVerify = analyzePlan(plan, liveAfter)
}
await writeAnalysisOutputs({
outputDir,
plan,
liveData: liveBefore,
analysis,
execution,
postVerify,
mode
})
console.log(
JSON.stringify(
{
outputDir,
mode,
selectedPlannedChildCount: plan.plannedChildren.length,
analysisSummary: analysis.summary,
executionSummary: execution
? {
successCount: execution.results.length,
errorCount: execution.errors.length,
stoppedEarly: execution.stopped_early
}
: null,
postVerifySummary: postVerify ? postVerify.summary : null
},
null,
2
)
)
}
run().catch((error) => {
console.error(error?.stack ? error.stack : String(error))
process.exit(1)
})

View File

@ -0,0 +1,613 @@
let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const {
buildAssignableExistingSheet2PublicEquipmentWhere,
PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES,
buildPublicAssetBorrowState,
isSheet2PublicEquipmentDescription,
normalizeText
} = require('./sheet2PublicAssetImportUtils')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node'
const DEFAULT_PUBLIC_PERSON = {
nickname: '公共资产',
real_name: '公共资产',
job_number: null,
id_number: null,
department: null,
main_department_id: null,
position: '系统保留',
phone: null,
email: null,
status: 'active',
remarks: '系统保留人员:用于承接公共设备,不参与 HR 组织同步。',
source_userid: '__public_asset__',
source_system: 'system_reserved',
source_main_department_id: null,
source_dept_ids: null,
source_department_name: null,
source_main_department_name: null,
department_sync_bucket: null,
manager_source_userid: null,
source_employee_status: null,
last_synced_at: null
}
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
inputDir: '',
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
let explicitDryRun = false
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--dry-run') {
explicitDryRun = true
} else if (arg === '--apply') {
options.apply = true
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
if (options.apply && explicitDryRun) {
throw new Error('--apply 与 --dry-run 不能同时使用')
}
options.dryRun = !options.apply
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const ensureOutputDir = (prefix) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const resolveLatestSheet2Dir = () => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('sheet2_public_dry_run_'))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到 Sheet2 公共设备 dry-run 输出目录: ${OUTPUT_ROOT}`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const resolveInputDir = (inputDirArg) => {
if (!inputDirArg) {
return resolveLatestSheet2Dir()
}
const resolved = path.isAbsolute(inputDirArg)
? inputDirArg
: path.resolve(process.cwd(), inputDirArg)
if (!fs.existsSync(resolved)) {
throw new Error(`指定的输入目录不存在: ${resolved}`)
}
return resolved
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))
const runRemoteNodeScript = (script, connection) => {
const result = spawnSync(
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
maxBuffer: 64 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('远程脚本返回为空')
}
try {
return JSON.parse(stdout)
} catch {
throw new Error('远程脚本返回格式无效')
}
}
const buildRemotePreviewScript = (payload) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
const seedBase64 = Buffer.from(JSON.stringify(DEFAULT_PUBLIC_PERSON), 'utf8').toString('base64')
const assignableExistingWhereBase64 = Buffer.from(
JSON.stringify(
buildAssignableExistingSheet2PublicEquipmentWhere(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES)
),
'utf8'
).toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const publicSeed = JSON.parse(Buffer.from('${seedBase64}', 'base64').toString('utf8'))
const assignableExistingWhere = JSON.parse(Buffer.from('${assignableExistingWhereBase64}', 'base64').toString('utf8'))
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\\r/g, ' ')
.replace(/\\n/g, ' ')
.replace(/\\s+/g, ' ')
.trim()
;(async () => {
const payloadCodes = payload.map((item) => normalizeText(item.legacyCode).toUpperCase()).filter(Boolean)
const [existingCodes, assignableExisting, publicAssetPerson] = await Promise.all([
prisma.equipment.findMany({
where: { equipment_code: { in: payloadCodes } },
select: { equipment_code: true, equipment_name: true, status: true },
orderBy: { equipment_code: 'asc' }
}),
prisma.equipment.findMany({
where: assignableExistingWhere,
select: { id: true, equipment_code: true, equipment_name: true, status: true },
orderBy: { equipment_code: 'asc' }
}),
prisma.person.findFirst({
where: { source_userid: publicSeed.source_userid },
select: { id: true, nickname: true, status: true, source_system: true }
})
])
console.log(JSON.stringify({
payloadCount: payload.length,
existingPayloadCodeCount: existingCodes.length,
existingPayloadCodes: existingCodes,
assignableExistingCount: assignableExisting.length,
assignableExistingSample: assignableExisting.slice(0, 30),
publicAssetPerson
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const buildRemoteApplyScript = (payload) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
const seedBase64 = Buffer.from(JSON.stringify(DEFAULT_PUBLIC_PERSON), 'utf8').toString('base64')
const assignableExistingWhereBase64 = Buffer.from(
JSON.stringify(
buildAssignableExistingSheet2PublicEquipmentWhere(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES)
),
'utf8'
).toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const publicSeed = JSON.parse(Buffer.from('${seedBase64}', 'base64').toString('utf8'))
const assignableExistingWhere = JSON.parse(Buffer.from('${assignableExistingWhereBase64}', 'base64').toString('utf8'))
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\\r/g, ' ')
.replace(/\\n/g, ' ')
.replace(/\\s+/g, ' ')
.trim()
const buildBorrowState = (unitPrice) => {
const normalizedUnitPrice = Number(unitPrice || 0)
return {
equipmentStatus: '已借出',
remainingQuantity: 0,
totalValue: normalizedUnitPrice,
borrowedValue: normalizedUnitPrice,
availableValue: 0
}
}
const importResults = []
const skippedExistingImports = []
const assignedExisting = []
;(async () => {
const publicAssetPerson = await prisma.$transaction(async (tx) => {
const person = await tx.person.upsert({
where: { source_userid: publicSeed.source_userid },
create: publicSeed,
update: {
nickname: publicSeed.nickname,
real_name: publicSeed.real_name,
department: null,
main_department_id: null,
position: publicSeed.position,
status: 'active',
remarks: publicSeed.remarks,
source_system: publicSeed.source_system,
source_main_department_id: null,
source_dept_ids: null,
source_department_name: null,
source_main_department_name: null,
department_sync_bucket: null,
manager_source_userid: null,
source_employee_status: null,
last_synced_at: null
}
})
await tx.personDepartment.deleteMany({
where: { person_id: person.id }
})
return person
})
for (const item of payload) {
const legacyCode = normalizeText(item.legacyCode).toUpperCase()
if (!legacyCode) {
continue
}
const existing = await prisma.equipment.findUnique({
where: { equipment_code: legacyCode },
select: { id: true, equipment_code: true, equipment_name: true, status: true }
})
if (existing) {
skippedExistingImports.push(existing)
continue
}
const [assetType, department, location] = await Promise.all([
prisma.assetType.findFirst({
where: { name: normalizeText(item.equipmentType), status: 'active' },
select: { id: true, name: true }
}),
normalizeText(item.departmentName)
? prisma.department.findFirst({
where: { name: normalizeText(item.departmentName), status: 'active' },
select: { id: true, name: true }
})
: Promise.resolve(null),
normalizeText(item.storageLocationName)
? prisma.storageLocation.findFirst({
where: { name: normalizeText(item.storageLocationName), status: 'active' },
select: { id: true, name: true }
})
: Promise.resolve(null)
])
if (!assetType) {
throw new Error('资产类型不存在或未启用: ' + normalizeText(item.equipmentType) + ' code=' + legacyCode)
}
if (normalizeText(item.departmentName) && !department) {
throw new Error('部门不存在或未启用: ' + normalizeText(item.departmentName) + ' code=' + legacyCode)
}
if (normalizeText(item.storageLocationName) && !location) {
throw new Error('地址不存在或未启用: ' + normalizeText(item.storageLocationName) + ' code=' + legacyCode)
}
const unitPrice = Number(item.unitPrice || 0)
const purchaseDate = normalizeText(item.purchaseDate) ? new Date(item.purchaseDate) : null
const borrowTime = purchaseDate || new Date()
const borrowState = buildBorrowState(unitPrice)
const created = await prisma.$transaction(async (tx) => {
const equipment = await tx.equipment.create({
data: {
equipment_code: legacyCode,
equipment_name: normalizeText(item.equipmentName),
equipment_model: normalizeText(item.equipmentModel) || null,
equipment_type: assetType.name,
code_generation_mode: 'manual',
status: borrowState.equipmentStatus,
total_quantity: 1,
remaining_quantity: borrowState.remainingQuantity,
unit_price: unitPrice,
total_value: borrowState.totalValue,
borrowed_value: borrowState.borrowedValue,
available_value: borrowState.availableValue,
total_value_with_children: borrowState.totalValue,
description: normalizeText(item.description) || null,
purchase_date: purchaseDate,
supplier: normalizeText(item.supplier) || null,
storage_location_id: location?.id || null,
asset_type_id: assetType.id,
department_id: department?.id || null,
mount_status: 'none'
}
})
await tx.borrowRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
person_id: publicAssetPerson.id,
person_name: publicAssetPerson.real_name,
person_nickname: publicAssetPerson.nickname,
person_identifier: publicAssetPerson.job_number || null,
borrow_time: borrowTime,
expected_return_time: null,
is_long_term: true,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: '总表待导入 Sheet2 公共设备补导;系统挂名到公共资产'
}
})
return {
id: equipment.id,
equipment_code: equipment.equipment_code,
equipment_name: equipment.equipment_name,
status: equipment.status
}
})
importResults.push(created)
}
const existingCandidates = await prisma.equipment.findMany({
where: assignableExistingWhere,
select: {
id: true,
equipment_code: true,
equipment_name: true,
unit_price: true,
purchase_date: true,
status: true
},
orderBy: { equipment_code: 'asc' }
})
for (const equipment of existingCandidates) {
const borrowState = buildBorrowState(equipment.unit_price)
const borrowTime = equipment.purchase_date || new Date()
const assigned = await prisma.$transaction(async (tx) => {
const updatedEquipment = await tx.equipment.update({
where: { id: equipment.id },
data: {
status: borrowState.equipmentStatus,
remaining_quantity: borrowState.remainingQuantity,
total_value: borrowState.totalValue,
borrowed_value: borrowState.borrowedValue,
available_value: borrowState.availableValue,
total_value_with_children: borrowState.totalValue
}
})
await tx.borrowRecord.create({
data: {
equipment_id: updatedEquipment.id,
equipment_code: updatedEquipment.equipment_code,
person_id: publicAssetPerson.id,
person_name: publicAssetPerson.real_name,
person_nickname: publicAssetPerson.nickname,
person_identifier: publicAssetPerson.job_number || null,
borrow_time: borrowTime,
expected_return_time: null,
is_long_term: true,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: '总表待导入 Sheet2 公共设备补挂;系统挂名到公共资产'
}
})
return {
id: updatedEquipment.id,
equipment_code: updatedEquipment.equipment_code,
equipment_name: updatedEquipment.equipment_name,
status: updatedEquipment.status
}
})
assignedExisting.push(assigned)
}
const verification = {
publicAssetPerson: {
id: publicAssetPerson.id,
nickname: publicAssetPerson.nickname,
realName: publicAssetPerson.real_name
},
importedCount: importResults.length,
skippedExistingImportCount: skippedExistingImports.length,
assignedExistingCount: assignedExisting.length,
sheet2PublicEquipmentCount: await prisma.equipment.count({
where: {
AND: [
{ description: { contains: 'source_workbook=docs/总表待导入.xlsx' } },
{ description: { contains: 'source_sheet=Sheet2' } }
]
}
}),
activePublicBorrowCount: await prisma.borrowRecord.count({
where: {
person_id: publicAssetPerson.id,
status: '已借出',
equipment: {
AND: [
{ description: { contains: 'source_workbook=docs/总表待导入.xlsx' } },
{ description: { contains: 'source_sheet=Sheet2' } }
]
}
}
})
}
console.log(JSON.stringify({
publicAssetPerson: verification.publicAssetPerson,
importedCount: importResults.length,
skippedExistingImportCount: skippedExistingImports.length,
assignedExistingCount: assignedExisting.length,
importedSample: importResults.slice(0, 30),
assignedExistingSample: assignedExisting.slice(0, 30),
skippedExistingImportSample: skippedExistingImports.slice(0, 30),
verification
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const run = () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const inputDir = resolveInputDir(options.inputDir)
const payloadPath = path.join(inputDir, 'sheet2_public_ready_payload.json')
if (!fs.existsSync(payloadPath)) {
throw new Error(`未找到 sheet2_public_ready_payload.json: ${payloadPath}`)
}
const payloadDocument = readJson(payloadPath)
const payload = Array.isArray(payloadDocument.payload) ? payloadDocument.payload : []
if (!payload.length) {
throw new Error('可导入 payload 为空')
}
const backupDir = ensureOutputDir(options.dryRun ? 'sheet2_public_preview' : 'sheet2_public_apply')
writeJson(path.join(backupDir, 'planned_payload.json'), {
inputDir,
payloadCount: payload.length,
payload
})
const preview = runRemoteNodeScript(buildRemotePreviewScript(payload), connection)
writeJson(path.join(backupDir, 'preview_result.json'), preview)
if (options.dryRun) {
console.log(JSON.stringify({ backupDir, inputDir, dryRun: true, preview }, null, 2))
return
}
const applyResult = runRemoteNodeScript(buildRemoteApplyScript(payload), connection)
writeJson(path.join(backupDir, 'apply_result.json'), applyResult)
console.log(
JSON.stringify(
{
backupDir,
inputDir,
preview,
applyResult
},
null,
2
)
)
}
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -0,0 +1,133 @@
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const STORAGE_LOCATION_ALIASES = {
天安: '常州-天安',
天安平面: '常州-天安',
天安3D: '常州-天安',
天安二楼分销: '常州-天安',
二楼分销: '常州-天安',
天安策划: '常州-天安',
天安二楼: '常州-天安',
天安直播间: '常州-天安',
天安东直播间: '常州-天安',
天安东南直播间: '常州-天安',
天安东小直播间: '常州-天安',
'天安-大龙虾': '常州-天安',
天安库存: '常州-天安',
天安数据机: '常州-天安',
天安3楼: '常州-天安',
天安3楼机房: '常州-天安',
天安影棚: '常州-天安',
天安三楼经管胖大海: '常州-天安',
影棚: '常州-天安',
摄影棚: '常州-天安',
摄影中心: '常州-天安',
摄影中心监控: '常州-天安',
二楼外贸: '常州-天安',
横林: '常州-仓库',
横林仓: '常州-仓库',
横林二仓: '常州-仓库',
横林客退: '常州-仓库',
客退: '常州-仓库',
常州临时仓: '常州-仓库',
崔桥工厂仓: '常州-仓库',
崔桥仓: '常州-仓库',
欧伯娜崔桥工厂: '常州-仓库',
欧伯娜工厂: '常州-仓库',
崔桥工厂欧伯娜: '常州-仓库',
新苏北仓: '常州-仓库',
常州: '常州-天安',
杭州: '杭州-万通中心',
杭州宋达: '杭州-万通中心',
南京: '南京-苏宁园区',
南京运营网盘: '南京-苏宁园区',
南京机房远程数据机: '南京-苏宁园区',
'10号楼1楼机房监控': '南京-苏宁园区',
'10号楼1楼监控室': '南京-苏宁园区',
'10号楼1楼配电间监控': '南京-苏宁园区',
'10号楼3楼机房': '南京-苏宁园区',
佛山3D: '佛山',
佛山平面: '佛山',
佛山建模: '佛山',
广东佛山: '佛山',
顺德仓: '佛山'
}
const resolveStorageLocationName = (rawValue) => {
const normalized = normalizeText(rawValue)
if (!normalized) {
return {
rawValue: normalized,
normalizedValue: '',
rule: ''
}
}
const aliased = STORAGE_LOCATION_ALIASES[normalized]
if (aliased) {
return {
rawValue: normalized,
normalizedValue: aliased,
rule: 'alias'
}
}
return {
rawValue: normalized,
normalizedValue: normalized,
rule: 'direct'
}
}
const resolveStorageLocationMatch = (rawValue, locationByName) => {
const normalized = normalizeText(rawValue)
if (!normalized) {
return {
rawValue: '',
normalizedValue: '',
match: null,
rule: ''
}
}
const directMatch = locationByName.get(normalizeLookupKey(normalized)) || null
if (directMatch) {
return {
rawValue: normalized,
normalizedValue: directMatch.name,
match: directMatch,
rule: 'direct'
}
}
const resolved = resolveStorageLocationName(rawValue)
if (!resolved.normalizedValue) {
return {
rawValue: normalized,
normalizedValue: '',
match: null,
rule: ''
}
}
const aliasMatch = locationByName.get(normalizeLookupKey(resolved.normalizedValue)) || null
return {
rawValue: normalized,
normalizedValue: resolved.normalizedValue,
match: aliasMatch,
rule: resolved.rule
}
}
module.exports = {
STORAGE_LOCATION_ALIASES,
resolveStorageLocationName,
resolveStorageLocationMatch
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,733 @@
const fs = require('fs')
const path = require('path')
const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs'))
const { expandHardwareModels } = require('./hardwareQuantitySplit')
const WORKBOOK_PATH = path.resolve(__dirname, '../../8.xlsx')
const SHEET_NAME = '📜固定资产明细'
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const HEADER_MAP = {
assetCode: '资产编码',
currentStatus: '当前状态',
assetName: '资产名称',
assetCategory: '资产分类',
department: '部门',
owner: '当前使用员工',
address: '地址',
cpu: 'cpu',
motherboard: '主板',
memory: '内存',
ssd: '固态硬盘',
hdd: '机械硬盘',
gpu: '显卡',
parentRecord: '父记录'
}
const CHILD_FIELD_SPECS = [
{ fieldKey: 'cpu', childType: 'cpu', childName: 'CPU' },
{ fieldKey: 'motherboard', childType: '主板', childName: '主板' },
{ fieldKey: 'memory', childType: '内存条', childName: '内存条' },
{ fieldKey: 'ssd', childType: '固态硬盘', childName: '固态硬盘' },
{ fieldKey: 'hdd', childType: '机械硬盘', childName: '机械硬盘' },
{ fieldKey: 'gpu', childType: '显卡', childName: '显卡' }
]
const PARENT_BUILDABLE_STATUSES = new Set(['可用', '已借出', '维护中'])
const ASSET_TYPE_ALIASES = {
笔记本电脑: '笔记本'
}
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
workbookPath: WORKBOOK_PATH,
equipmentJsonPath: '',
assetTypesJsonPath: '',
apiBase: '',
username: '',
password: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--workbook') {
options.workbookPath = args[index + 1] || WORKBOOK_PATH
index += 1
} else if (arg === '--equipment-json') {
options.equipmentJsonPath = args[index + 1] || ''
index += 1
} else if (arg === '--asset-types-json') {
options.assetTypesJsonPath = args[index + 1] || ''
index += 1
} else if (arg === '--api-base') {
options.apiBase = args[index + 1] || ''
index += 1
} else if (arg === '--username') {
options.username = args[index + 1] || ''
index += 1
} else if (arg === '--password') {
options.password = args[index + 1] || ''
index += 1
}
}
return options
}
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const ensureOutputDir = () => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `parent_child_dry_run_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const resolvePathArg = (value, fallbackPath = '') => {
const target = normalizeText(value || fallbackPath)
if (!target) {
return ''
}
return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target)
}
const findLatestJsonByPrefix = (prefix) => {
if (!fs.existsSync(OUTPUT_ROOT)) {
return ''
}
const matches = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.json'))
.map((entry) => entry.name)
.sort()
if (!matches.length) {
return ''
}
return path.join(OUTPUT_ROOT, matches[matches.length - 1])
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const escapeCsv = (value) => {
const stringValue = String(value == null ? '' : value)
if (/[,"\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
const writeCsv = (filePath, headers, rows) => {
const lines = [headers.map(escapeCsv).join(',')]
for (const row of rows) {
lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(','))
}
fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`)
}
const writeWorkbook = async (filePath, sheets) => {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.created = new Date()
for (const sheet of sheets) {
const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}]
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
const worksheet = workbook.addWorksheet(sheet.name)
worksheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.min(Math.max(header.length + 4, 16), 40)
}))
rows.forEach((row) => worksheet.addRow(row))
worksheet.getRow(1).font = { bold: true }
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
}
await workbook.xlsx.writeFile(filePath)
}
const normalizeAssetTypeName = (value) => {
const normalized = normalizeText(value)
return ASSET_TYPE_ALIASES[normalized] || normalized
}
const loadWorkbookRows = (workbookPath) => {
const workbook = XLSX.readFile(workbookPath, { raw: false })
const worksheet = workbook.Sheets[SHEET_NAME]
if (!worksheet) {
throw new Error(`未找到工作表: ${SHEET_NAME}`)
}
const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1, raw: false, defval: '' })
if (!rows.length) {
return []
}
const headerIndex = {}
rows[0].forEach((header, index) => {
headerIndex[normalizeText(header)] = index
})
const records = []
for (let index = 1; index < rows.length; index += 1) {
const row = rows[index]
const sourceRow = index + 1
const record = {
sourceRow,
assetCodeRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCode]]),
currentStatusRaw: normalizeText(row[headerIndex[HEADER_MAP.currentStatus]]),
assetNameRaw: normalizeText(row[headerIndex[HEADER_MAP.assetName]]),
assetCategoryRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCategory]]),
departmentRaw: normalizeText(row[headerIndex[HEADER_MAP.department]]),
ownerRaw: normalizeText(row[headerIndex[HEADER_MAP.owner]]),
addressRaw: normalizeText(row[headerIndex[HEADER_MAP.address]]),
cpuRaw: normalizeText(row[headerIndex[HEADER_MAP.cpu]]),
motherboardRaw: normalizeText(row[headerIndex[HEADER_MAP.motherboard]]),
memoryRaw: normalizeText(row[headerIndex[HEADER_MAP.memory]]),
ssdRaw: normalizeText(row[headerIndex[HEADER_MAP.ssd]]),
hddRaw: normalizeText(row[headerIndex[HEADER_MAP.hdd]]),
gpuRaw: normalizeText(row[headerIndex[HEADER_MAP.gpu]]),
parentRecordRaw: normalizeText(row[headerIndex[HEADER_MAP.parentRecord]])
}
const hasAnyHardware = CHILD_FIELD_SPECS.some((item) => normalizeText(record[`${item.fieldKey}Raw`]))
if (!hasAnyHardware) {
continue
}
records.push(record)
}
return records
}
const readJsonFile = (filePath) => {
const raw = fs.readFileSync(filePath, 'utf8')
return JSON.parse(raw)
}
const unwrapListPayload = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (payload && Array.isArray(payload.list)) {
return payload.list
}
if (payload?.data && Array.isArray(payload.data.list)) {
return payload.data.list
}
throw new Error('JSON 结构不符合预期,未找到 list')
}
const fetchJson = async (url, options = {}) => {
const response = await fetch(url, options)
const text = await response.text()
if (!response.ok) {
throw new Error(`请求失败: ${url} status=${response.status} body=${text}`)
}
return { response, data: JSON.parse(text) }
}
const buildCookieHeader = (response) => {
if (typeof response.headers.getSetCookie === 'function') {
return response.headers.getSetCookie().map((item) => item.split(';')[0]).join('; ')
}
const setCookie = response.headers.get('set-cookie')
return setCookie ? setCookie.split(';')[0] : ''
}
const fetchLiveData = async (options, outputDir) => {
const apiBase = normalizeText(options.apiBase)
if (!apiBase || !normalizeText(options.username) || !normalizeText(options.password)) {
throw new Error('缺少 live API 登录参数')
}
const loginResult = await fetchJson(`${apiBase}/auth/local/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: options.username,
password: options.password
})
})
const cookieHeader = buildCookieHeader(loginResult.response)
if (!cookieHeader) {
throw new Error('登录成功但未获取到 session cookie')
}
const commonHeaders = { Cookie: cookieHeader }
const [equipmentResult, assetTypeResult] = await Promise.all([
fetchJson(`${apiBase}/equipment/list?page=1&pageSize=5000`, { headers: commonHeaders }),
fetchJson(`${apiBase}/asset-types/list?page=1&pageSize=200`, { headers: commonHeaders })
])
const equipmentJsonPath = path.join(outputDir, 'live_equipment_source.json')
const assetTypesJsonPath = path.join(outputDir, 'live_asset_types_source.json')
writeJson(equipmentJsonPath, equipmentResult.data)
writeJson(assetTypesJsonPath, assetTypeResult.data)
return {
equipmentList: unwrapListPayload(equipmentResult.data),
assetTypes: unwrapListPayload(assetTypeResult.data),
source: {
mode: 'live-api',
apiBase,
equipmentJsonPath,
assetTypesJsonPath
}
}
}
const loadLiveData = async (options, outputDir) => {
if (normalizeText(options.apiBase)) {
return fetchLiveData(options, outputDir)
}
const equipmentJsonPath = resolvePathArg(
options.equipmentJsonPath,
findLatestJsonByPrefix('live_equipment_list_parent_child_dry_run_')
)
const assetTypesJsonPath = resolvePathArg(
options.assetTypesJsonPath,
findLatestJsonByPrefix('live_asset_types_parent_child_dry_run_')
)
if (!equipmentJsonPath || !fs.existsSync(equipmentJsonPath)) {
throw new Error('未找到 live 设备快照,请传入 --equipment-json 或使用 --api-base 登录拉取')
}
if (!assetTypesJsonPath || !fs.existsSync(assetTypesJsonPath)) {
throw new Error('未找到 live 资产类型快照,请传入 --asset-types-json 或使用 --api-base 登录拉取')
}
return {
equipmentList: unwrapListPayload(readJsonFile(equipmentJsonPath)),
assetTypes: unwrapListPayload(readJsonFile(assetTypesJsonPath)),
source: {
mode: 'snapshot',
equipmentJsonPath,
assetTypesJsonPath
}
}
}
const buildReferenceMaps = (equipmentList, assetTypes) => {
const liveByCode = new Map()
for (const item of equipmentList) {
const code = normalizeLookupKey(item.equipment_code)
if (code) {
liveByCode.set(code, item)
}
}
const assetTypeByName = new Map()
for (const item of assetTypes) {
assetTypeByName.set(normalizeLookupKey(item.name), item)
}
return {
liveByCode,
assetTypeByName
}
}
const buildDuplicateCodeSet = (rows) => {
const counts = new Map()
for (const row of rows) {
const code = normalizeLookupKey(row.assetCodeRaw)
if (!code) {
continue
}
counts.set(code, (counts.get(code) || 0) + 1)
}
return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([code]) => code))
}
const normalizeChildModel = (value) => normalizeText(value)
const buildExistingChildKey = (child) =>
`${normalizeLookupKey(child.equipment_type)}||${normalizeLookupKey(child.equipment_model)}`
const pushCount = (counter, key, increment = 1) => {
counter[key] = (counter[key] || 0) + increment
}
const analyzeRows = (workbookRows, refs) => {
const duplicateCodes = buildDuplicateCodeSet(workbookRows)
const plannedParents = []
const plannedChildren = []
const skippedRows = []
const childSkips = []
const rowSkipReasonCounts = {}
const childSkipReasonCounts = {}
const plannedChildTypeCounts = {}
const plannedParentStatusCounts = {}
for (const row of workbookRows) {
const workbookCode = normalizeLookupKey(row.assetCodeRaw)
const workbookAssetType = normalizeAssetTypeName(row.assetNameRaw)
const hardwareValues = []
const manualSplitChildren = []
for (const spec of CHILD_FIELD_SPECS) {
const sourceModel = normalizeChildModel(row[`${spec.fieldKey}Raw`])
if (!sourceModel) {
continue
}
const expanded = expandHardwareModels(spec.childType, sourceModel)
if (expanded.isMultiUnit && !expanded.isAutoSplittable) {
manualSplitChildren.push({
spec,
sourceModel,
reason: expanded.reason || 'child_model_requires_manual_split',
splitPattern: expanded.pattern || ''
})
continue
}
if (!expanded.models.length) {
continue
}
expanded.models.forEach((model, index) => {
hardwareValues.push({
spec,
model,
sourceModel,
splitIndex: expanded.isMultiUnit ? index + 1 : 1,
splitQuantity: expanded.isMultiUnit ? expanded.quantity : 1,
splitPattern: expanded.pattern || 'single_unit'
})
})
}
const rowContext = {
source_row: row.sourceRow,
asset_code: row.assetCodeRaw,
asset_name: row.assetNameRaw,
mapped_asset_type: workbookAssetType,
current_status_raw: row.currentStatusRaw,
department: row.departmentRaw,
owner: row.ownerRaw,
address: row.addressRaw,
hardware_fields: hardwareValues.map((item) => `${item.spec.childName}:${item.model}`).join(' | ')
}
const skipRow = (reason, extra = {}) => {
pushCount(rowSkipReasonCounts, reason)
skippedRows.push({
...rowContext,
reason,
...extra
})
}
if (!workbookCode) {
skipRow('missing_workbook_asset_code')
continue
}
if (duplicateCodes.has(workbookCode)) {
skipRow('duplicate_workbook_asset_code')
continue
}
const workbookTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(workbookAssetType))
if (!workbookTypeConfig) {
skipRow('workbook_asset_type_not_found_in_live', { live_type_name: '' })
continue
}
if (!workbookTypeConfig.can_be_parent) {
skipRow('workbook_asset_type_not_parent_capable', { live_type_name: workbookTypeConfig.name })
continue
}
const liveParent = refs.liveByCode.get(workbookCode)
if (!liveParent) {
skipRow('live_parent_not_found', { live_type_name: '' })
continue
}
if (normalizeLookupKey(liveParent.equipment_type) !== normalizeLookupKey(workbookTypeConfig.name)) {
skipRow('live_parent_type_mismatch', { live_type_name: liveParent.equipment_type })
continue
}
const liveTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(liveParent.equipment_type))
if (!liveTypeConfig || !liveTypeConfig.can_be_parent) {
skipRow('live_parent_type_not_parent_capable', { live_type_name: liveParent.equipment_type })
continue
}
if (liveParent.parent_id != null) {
skipRow('live_parent_is_already_child', { live_type_name: liveParent.equipment_type })
continue
}
if (!PARENT_BUILDABLE_STATUSES.has(normalizeText(liveParent.status))) {
skipRow('live_parent_status_not_buildable', {
live_type_name: liveParent.equipment_type,
live_parent_status: liveParent.status
})
continue
}
const existingChildren = Array.isArray(liveParent.children) ? liveParent.children : []
const existingChildQueues = new Map()
for (const child of existingChildren) {
const key = buildExistingChildKey(child)
const queue = existingChildQueues.get(key) || []
queue.push(child)
existingChildQueues.set(key, queue)
}
const rowPlannedChildren = []
for (const manualItem of manualSplitChildren) {
pushCount(childSkipReasonCounts, manualItem.reason)
childSkips.push({
...rowContext,
child_type: manualItem.spec.childType,
child_name: manualItem.spec.childName,
child_model: manualItem.sourceModel,
source_child_model: manualItem.sourceModel,
split_index: '',
split_quantity: '',
split_pattern: manualItem.splitPattern,
reason: manualItem.reason
})
}
for (const { spec, model, sourceModel, splitIndex, splitQuantity, splitPattern } of hardwareValues) {
const childTypeConfig = refs.assetTypeByName.get(normalizeLookupKey(spec.childType))
if (!childTypeConfig || !childTypeConfig.can_be_child) {
pushCount(childSkipReasonCounts, 'child_asset_type_not_child_capable')
childSkips.push({
...rowContext,
child_type: spec.childType,
child_name: spec.childName,
child_model: model,
source_child_model: sourceModel,
split_index: splitIndex,
split_quantity: splitQuantity,
split_pattern: splitPattern,
reason: 'child_asset_type_not_child_capable'
})
continue
}
const existingKey = `${normalizeLookupKey(spec.childType)}||${normalizeLookupKey(model)}`
const existingQueue = existingChildQueues.get(existingKey) || []
if (existingQueue.length > 0) {
existingQueue.shift()
existingChildQueues.set(existingKey, existingQueue)
pushCount(childSkipReasonCounts, 'existing_child_same_type_and_model')
childSkips.push({
...rowContext,
child_type: spec.childType,
child_name: spec.childName,
child_model: model,
source_child_model: sourceModel,
split_index: splitIndex,
split_quantity: splitQuantity,
split_pattern: splitPattern,
reason: 'existing_child_same_type_and_model'
})
continue
}
const plannedChild = {
source_row: row.sourceRow,
parent_code: liveParent.equipment_code,
parent_id: liveParent.id,
parent_name: liveParent.equipment_name,
parent_type: liveParent.equipment_type,
parent_status: liveParent.status,
parent_department: liveParent.department?.name || '',
parent_location: liveParent.storageLocation?.name || '',
parent_owner: liveParent.current_borrow?.person_nickname || liveParent.current_borrow?.person_name || '',
workbook_department: row.departmentRaw,
workbook_owner: row.ownerRaw,
workbook_location: row.addressRaw,
field_key: spec.fieldKey,
child_type: spec.childType,
child_name: spec.childName,
child_model: model,
source_child_model: sourceModel,
split_index: splitIndex,
split_quantity: splitQuantity,
split_pattern: splitPattern,
planned_unit_price: 0,
planned_status: liveParent.status === '已借出' || liveParent.status === '维护中' ? liveParent.status : '可用',
planned_code_generation_mode: 'auto',
notes: `根据8.xlsx第${row.sourceRow}行补建`
}
rowPlannedChildren.push(plannedChild)
plannedChildren.push(plannedChild)
pushCount(plannedChildTypeCounts, spec.childType)
}
if (!rowPlannedChildren.length) {
skipRow('no_new_children_after_dedup', {
live_type_name: liveParent.equipment_type,
live_parent_status: liveParent.status
})
continue
}
plannedParents.push({
source_row: row.sourceRow,
parent_code: liveParent.equipment_code,
parent_id: liveParent.id,
parent_name: liveParent.equipment_name,
parent_type: liveParent.equipment_type,
parent_status: liveParent.status,
child_count: rowPlannedChildren.length,
child_types: rowPlannedChildren.map((item) => item.child_type).join(' | '),
workbook_department: row.departmentRaw,
live_department: liveParent.department?.name || '',
workbook_location: row.addressRaw,
live_location: liveParent.storageLocation?.name || '',
workbook_owner: row.ownerRaw,
live_owner: liveParent.current_borrow?.person_nickname || liveParent.current_borrow?.person_name || ''
})
pushCount(plannedParentStatusCounts, liveParent.status)
}
return {
plannedParents,
plannedChildren,
skippedRows,
childSkips,
rowSkipReasonCounts,
childSkipReasonCounts,
plannedChildTypeCounts,
plannedParentStatusCounts
}
}
const buildSummaryRows = (summary) =>
Object.entries(summary).map(([key, value]) => ({
key,
value: Array.isArray(value) || typeof value === 'object' ? JSON.stringify(value) : value
}))
const run = async () => {
const options = parseArgs()
const outputDir = ensureOutputDir()
const workbookPath = resolvePathArg(options.workbookPath, WORKBOOK_PATH)
if (!fs.existsSync(workbookPath)) {
throw new Error(`工作簿不存在: ${workbookPath}`)
}
const liveData = await loadLiveData(options, outputDir)
const workbookRows = loadWorkbookRows(workbookPath)
const refs = buildReferenceMaps(liveData.equipmentList, liveData.assetTypes)
const analysis = analyzeRows(workbookRows, refs)
const summary = {
generated_at: new Date().toISOString(),
workbook_path: workbookPath,
workbook_sheet: SHEET_NAME,
live_source_mode: liveData.source.mode,
live_equipment_source: liveData.source.equipmentJsonPath || '',
live_asset_types_source: liveData.source.assetTypesJsonPath || '',
workbook_rows_with_hardware_fields: workbookRows.length,
live_total_equipment: liveData.equipmentList.length,
live_total_asset_types: liveData.assetTypes.length,
planned_parent_count: analysis.plannedParents.length,
planned_child_count: analysis.plannedChildren.length,
skipped_row_count: analysis.skippedRows.length,
skipped_child_count: analysis.childSkips.length,
planned_parent_status_counts: analysis.plannedParentStatusCounts,
planned_child_type_counts: analysis.plannedChildTypeCounts,
skipped_row_reason_counts: analysis.rowSkipReasonCounts,
skipped_child_reason_counts: analysis.childSkipReasonCounts,
notes: [
'dry-run 只生成报告,不写入 live',
'子资产编码将在正式写入时自动生成,本次不预占编号',
'子资产价值按当前业务口径统一规划为 0'
]
}
const report = {
summary,
plannedParents: analysis.plannedParents,
plannedChildren: analysis.plannedChildren,
skippedRows: analysis.skippedRows,
skippedChildren: analysis.childSkips
}
writeJson(path.join(outputDir, 'summary.json'), summary)
writeJson(path.join(outputDir, 'report.json'), report)
writeCsv(
path.join(outputDir, 'planned_parents.csv'),
Object.keys(analysis.plannedParents[0] || { parent_code: '', child_count: '' }),
analysis.plannedParents
)
writeCsv(
path.join(outputDir, 'planned_children.csv'),
Object.keys(analysis.plannedChildren[0] || { parent_code: '', child_type: '', child_model: '' }),
analysis.plannedChildren
)
writeCsv(
path.join(outputDir, 'skipped_rows.csv'),
Object.keys(analysis.skippedRows[0] || { asset_code: '', reason: '' }),
analysis.skippedRows
)
writeCsv(
path.join(outputDir, 'skipped_children.csv'),
Object.keys(analysis.childSkips[0] || { parent_code: '', child_type: '', reason: '' }),
analysis.childSkips
)
await writeWorkbook(path.join(outputDir, 'parent_child_dry_run.xlsx'), [
{ name: 'Summary', rows: buildSummaryRows(summary) },
{ name: 'PlannedParents', rows: analysis.plannedParents },
{ name: 'PlannedChildren', rows: analysis.plannedChildren },
{ name: 'SkippedRows', rows: analysis.skippedRows },
{ name: 'SkippedChildren', rows: analysis.childSkips }
])
console.log(
JSON.stringify(
{
outputDir,
summary
},
null,
2
)
)
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@ -0,0 +1,611 @@
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs'))
const {
mergeCandidateRowsWithSourceCodes,
normalizeLookupKey,
normalizeText,
resolveSheet2LocationMatch
} = require('./sheet2PublicSupplementUtils')
const DEFAULT_SOURCE_WORKBOOK = path.resolve(__dirname, '../../docs/总表待导入.xlsx')
const DEFAULT_SOURCE_SHEET = 'Sheet2'
const DEFAULT_CANDIDATE_WORKBOOK = path.resolve(
__dirname,
'../../docs/总表待导入-Sheet2_严格可安全补导候选-2026-03-27.xlsx'
)
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DOCS_ROOT = path.resolve(__dirname, '../../docs')
const DEFAULT_OUTPUT_WORKBOOK_BASENAME = '总表待导入-Sheet2_公共设备补导分流-2026-03-27.xlsx'
const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node'
const DEPARTMENT_ALIASES = {
'3D视觉(广东)': '视觉中心',
'3D视觉(常州)': '视觉中心',
事业一部: '运营中心A',
事业二部: '运营中心A',
事业三部: '运营中心A',
事业四部: '运营中心A',
策划一部: '品牌策划中心',
策划二部: '品牌策划中心',
策划三部: '品牌策划中心',
策划四部: '品牌策划中心',
策划五部: '品牌策划中心',
策划六部: '品牌策划中心',
客服二部: '客服中心',
客服三部: '客服中心',
客服四部: '客服中心',
客服五部: '客服中心',
客服六部: '客服中心',
品牌营销策划中心: '品牌策划中心',
品牌创意视觉中心: '视觉中心',
品牌供应链中心: '供应链中心',
智管信息中心: '效率部(懂业务的技术部)',
总账组: '财务中心',
结算组: '财务中心',
视觉一部: '视觉中心',
视觉二部: '视觉中心',
视觉三部: '视觉中心',
视觉四部: '视觉中心',
视觉五部: '视觉中心',
视觉六部: '视觉中心',
'基本盘 采购部': '供应链中心',
'基本盘 核价组': '供应链中心',
产品视觉中心: '视觉中心',
分销外贸: '外贸部'
}
const PUBLIC_STATUS_MAP = {
在用: '可用',
闲置: '可用'
}
const buildTimestampLabel = () =>
new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
sourceWorkbook: DEFAULT_SOURCE_WORKBOOK,
sourceSheet: DEFAULT_SOURCE_SHEET,
candidateWorkbook: DEFAULT_CANDIDATE_WORKBOOK,
outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME),
assetCodes: [],
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--source-workbook') {
options.sourceWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--source-sheet') {
options.sourceSheet = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--candidate-workbook') {
options.candidateWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--output-workbook') {
options.outputWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--asset-codes') {
options.assetCodes = String(requireArgValue(args, index, arg))
.split(/[,\s]+/)
.map((item) => normalizeText(item).toUpperCase())
.filter(Boolean)
index += 1
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const resolvePathArg = (value) => {
const normalized = normalizeText(value)
if (!normalized) {
return ''
}
return path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized)
}
const ensureOutputDir = () => {
const timestamp = buildTimestampLabel()
const outputDir = path.join(OUTPUT_ROOT, `sheet2_public_dry_run_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const writeWorkbook = async (filePath, sheets) => {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.created = new Date()
for (const sheet of sheets) {
const worksheet = workbook.addWorksheet(sheet.name)
const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}]
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
worksheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.min(Math.max(header.length + 6, 14), 40)
}))
rows.forEach((row) => worksheet.addRow(row))
worksheet.getRow(1).font = { bold: true, name: 'Arial' }
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.eachRow((row) => {
row.font = { name: 'Arial' }
row.alignment = { vertical: 'middle', wrapText: true }
})
}
await workbook.xlsx.writeFile(filePath)
}
const resolveAvailableWorkbookPath = (preferredPath) => {
const resolvedPreferredPath = resolvePathArg(preferredPath)
if (!resolvedPreferredPath) {
throw new Error('输出工作簿路径不能为空')
}
const workbookDir = path.dirname(resolvedPreferredPath)
const extension = path.extname(resolvedPreferredPath) || '.xlsx'
const baseName = path.basename(resolvedPreferredPath, extension)
const timestampedPath = path.join(workbookDir, `${baseName}-${buildTimestampLabel()}${extension}`)
try {
const fd = fs.openSync(resolvedPreferredPath, 'a')
fs.closeSync(fd)
return resolvedPreferredPath
} catch (error) {
if (error && (error.code === 'EBUSY' || error.code === 'EPERM')) {
return timestampedPath
}
throw error
}
}
const loadSourceRowMap = (sourceWorkbookPath, sourceSheetName) => {
const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false })
const sheet = workbook.Sheets[sourceSheetName]
if (!sheet) {
throw new Error(`未找到源工作表: ${sourceSheetName}`)
}
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' })
if (!matrix.length) {
return new Map()
}
const headerIndex = new Map()
matrix[0].forEach((header, index) => {
headerIndex.set(normalizeText(header), index)
})
const codeColumnIndex = headerIndex.get('资产编码')
if (codeColumnIndex === undefined) {
throw new Error('源工作表缺少“资产编码”列')
}
const rowMap = new Map()
for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) {
const row = matrix[rowIndex]
const code = normalizeText(row[codeColumnIndex]).toUpperCase()
if (code && !rowMap.has(code)) {
rowMap.set(code, rowIndex + 1)
}
}
return rowMap
}
const loadSourceRows = (sourceWorkbookPath, sourceSheetName) => {
const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false })
const sheet = workbook.Sheets[sourceSheetName]
if (!sheet) {
throw new Error(`未找到源工作表: ${sourceSheetName}`)
}
return XLSX.utils.sheet_to_json(sheet, { defval: '' }).map((row) => ({
...row,
来源工作表: normalizeText(row.来源工作表) || sourceSheetName
}))
}
const loadCandidateRows = (candidateWorkbookPath) => {
const workbook = XLSX.readFile(candidateWorkbookPath, { raw: false })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet, { defval: '' })
}
const fetchLiveRefs = (connection) => {
const script = `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const [assetTypes, departments, locations] = await Promise.all([
prisma.assetType.findMany({
where: { status: 'active' },
select: { id: true, name: true, short_name: true },
orderBy: { name: 'asc' }
}),
prisma.department.findMany({
where: { status: 'active', source_system: 'ding_hr_business' },
select: { id: true, name: true },
orderBy: { name: 'asc' }
}),
prisma.storageLocation.findMany({
where: { status: 'active' },
select: { id: true, name: true },
orderBy: { name: 'asc' }
})
])
console.log(JSON.stringify({ assetTypes, departments, locations }))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const result = spawnSync(
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error('读取线上基础数据失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('线上基础数据返回为空')
}
try {
return JSON.parse(stdout)
} catch {
throw new Error('线上基础数据返回格式无效')
}
}
const buildImportDescription = (row, sourceRow) => {
const parts = [
['source_workbook', 'docs/总表待导入.xlsx'],
['source_sheet', 'Sheet2'],
['source_row', sourceRow || ''],
['source_status', normalizeText(row['当前状态'])],
['raw_department', normalizeText(row['部门'])],
['raw_location', normalizeText(row['地址'])],
['raw_asset_category', normalizeText(row['资产分类'])]
]
return parts.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`).join('; ')
}
const buildReadyImportPayload = (rows) =>
rows.map((row) => ({
legacyCode: row.资产编码,
equipmentName: row.资产名称,
equipmentModel: row.规格型号 || null,
equipmentType: row.资产名称,
departmentName: row.系统归并部门 || '',
storageLocationName: row.系统归并地址 || '',
systemStatus: row.系统建议状态,
purchaseDate: null,
supplier: '',
unitPrice: 0,
description: row.导入备注
}))
const createRowContext = (liveRefs) => {
const departmentByName = new Map(
liveRefs.departments.map((item) => [normalizeLookupKey(item.name), item])
)
const locationByName = new Map(
liveRefs.locations.map((item) => [normalizeLookupKey(item.name), item])
)
return { departmentByName, locationByName }
}
const classifySingleRow = (rawRow, sourceRowMap, rowContext) => {
const { departmentByName, locationByName } = rowContext
const assetCode = normalizeText(rawRow['资产编码']).toUpperCase()
const assetName = normalizeText(rawRow['资产名称'])
const rawDepartment = normalizeText(rawRow['部门'])
const resolvedDepartment = DEPARTMENT_ALIASES[rawDepartment] || rawDepartment
const departmentMatch = resolvedDepartment
? departmentByName.get(normalizeLookupKey(resolvedDepartment)) || null
: null
const rawLocation = normalizeText(rawRow['地址'])
const locationMatch = resolveSheet2LocationMatch(rawLocation, locationByName)
const rawStatus = normalizeText(rawRow['当前状态'])
const systemStatus = PUBLIC_STATUS_MAP[rawStatus] || ''
const sourceRow = sourceRowMap.get(assetCode) || ''
const baseRow = {
来源工作表: normalizeText(rawRow['来源工作表']) || 'Sheet2',
源数据行号: sourceRow,
资产编码: assetCode,
资产名称: assetName,
当前状态: rawStatus,
系统建议状态: systemStatus,
资产分类: normalizeText(rawRow['资产分类']),
部门: rawDepartment,
系统归并部门: departmentMatch?.name || resolvedDepartment,
地址: rawLocation,
系统归并地址: locationMatch.match?.name || locationMatch.normalizedValue,
地址归并规则: locationMatch.rule || '',
规格型号: normalizeText(rawRow['规格型号']),
导入备注: buildImportDescription(rawRow, sourceRow)
}
const reasons = []
if (!departmentMatch) {
reasons.push(`部门未匹配:${rawDepartment || '(空)'}`)
}
if (!locationMatch.match) {
reasons.push(`地址未匹配:${rawLocation || '(空)'}`)
}
if (!systemStatus) {
reasons.push(`状态无法自动映射:${rawStatus || '(空)'}`)
}
return { baseRow, reasons }
}
const classifyRows = (candidateRows, sourceRowMap, liveRefs) => {
const rowContext = createRowContext(liveRefs)
const readyRows = []
const pendingRows = []
for (const rawRow of candidateRows) {
const { baseRow, reasons } = classifySingleRow(rawRow, sourceRowMap, rowContext)
if (!reasons.length) {
readyRows.push(baseRow)
continue
}
pendingRows.push({
...baseRow,
待核对原因: reasons.join(';'),
建议处理: reasons.some((item) => item.startsWith('地址未匹配'))
? '先确认应归并到哪个系统地址,再导入'
: '先确认状态或部门后再导入'
})
}
return { readyRows, pendingRows }
}
const buildDuplicatePendingRows = (duplicateSourceCodes, sourceRowMap, liveRefs) => {
const rowContext = createRowContext(liveRefs)
return duplicateSourceCodes.map((item) => {
const firstRow = item.rows[0] || {}
const { baseRow } = classifySingleRow(firstRow, sourceRowMap, rowContext)
return {
...baseRow,
待核对原因: '同一资产编码在 Sheet2 内对应了不同设备信息,不能自动判断。',
建议处理: '同一编码对应了多条不同设备信息,先确认哪条才是真实记录'
}
})
}
const buildSummaryRows = ({
total,
ready,
pending,
addressPending,
otherPending,
liveRefs,
explicitCodeCount,
explicitMissingCodeCount,
duplicatePendingCount
}) => [
{ 项目: '候选总数', 说明: total },
{ 项目: '可直接补录', 说明: ready },
{ 项目: '待人工核对', 说明: pending },
{ 项目: '其中地址待核对', 说明: addressPending },
{ 项目: '其中其他待核对', 说明: otherPending },
{ 项目: '本次显式补回编码数', 说明: explicitCodeCount },
{ 项目: '显式编码仍未在源表找到', 说明: explicitMissingCodeCount },
{ 项目: '显式编码重复冲突数', 说明: duplicatePendingCount },
{ 项目: '状态映射规则', 说明: '在用/闲置 -> 系统“可用”;其余状态不自动导入' },
{ 项目: '人员处理规则', 说明: '本批当前使用员工全部为空,按公共设备处理,不自动建立借出记录' },
{ 项目: '线上部门数', 说明: liveRefs.departments.length },
{ 项目: '线上地址数', 说明: liveRefs.locations.length },
{ 项目: '线上资产类型数', 说明: liveRefs.assetTypes.length }
]
const run = async () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const sourceWorkbookPath = resolvePathArg(options.sourceWorkbook)
const candidateWorkbookPath = resolvePathArg(options.candidateWorkbook)
if (!fs.existsSync(sourceWorkbookPath)) {
throw new Error(`源工作簿不存在: ${sourceWorkbookPath}`)
}
if (!fs.existsSync(candidateWorkbookPath) && options.assetCodes.length === 0) {
throw new Error(`候选工作簿不存在: ${candidateWorkbookPath}`)
}
const outputDir = ensureOutputDir()
const workbookPath = resolveAvailableWorkbookPath(options.outputWorkbook)
const sourceRowMap = loadSourceRowMap(sourceWorkbookPath, options.sourceSheet)
const sourceRows = loadSourceRows(sourceWorkbookPath, options.sourceSheet)
const baseCandidateRows = fs.existsSync(candidateWorkbookPath)
? loadCandidateRows(candidateWorkbookPath)
: []
const {
rows: candidateRows,
missingCodes: missingSourceCodes,
duplicateSourceCodes
} = mergeCandidateRowsWithSourceCodes(baseCandidateRows, sourceRows, options.assetCodes)
const liveRefs = fetchLiveRefs(connection)
const { readyRows, pendingRows } = classifyRows(candidateRows, sourceRowMap, liveRefs)
const duplicatePendingRows = buildDuplicatePendingRows(duplicateSourceCodes, sourceRowMap, liveRefs)
const allPendingRows = [...pendingRows, ...duplicatePendingRows]
const addressPendingRows = allPendingRows.filter((row) => row.待核对原因.includes('地址未匹配'))
const otherPendingRows = allPendingRows.filter((row) => !row.待核对原因.includes('地址未匹配'))
const readyPayload = buildReadyImportPayload(readyRows)
const manifest = {
generatedAt: new Date().toISOString(),
sourceWorkbook: sourceWorkbookPath,
sourceSheet: options.sourceSheet,
candidateWorkbook: candidateWorkbookPath,
explicitAssetCodes: options.assetCodes,
explicitMissingCodes: missingSourceCodes,
duplicateSourceCodes: duplicateSourceCodes.map((item) => item.code),
totalCandidates: candidateRows.length + duplicatePendingRows.length,
readyCount: readyRows.length,
pendingCount: allPendingRows.length,
addressPendingCount: addressPendingRows.length,
otherPendingCount: otherPendingRows.length,
rulesVersion: 'sheet2-public-supplement-v1',
assumptions: [
'当前使用员工为空的记录按公共设备处理',
'在用/闲置统一映射为系统可用',
'地址不能 100% 确认时不自动导入'
]
}
await writeWorkbook(workbookPath, [
{
name: '汇总说明',
rows: buildSummaryRows({
total: candidateRows.length + duplicatePendingRows.length,
ready: readyRows.length,
pending: allPendingRows.length,
addressPending: addressPendingRows.length,
otherPending: otherPendingRows.length,
liveRefs,
explicitCodeCount: options.assetCodes.length,
explicitMissingCodeCount: missingSourceCodes.length,
duplicatePendingCount: duplicatePendingRows.length
})
},
{ name: '可直接补录', rows: readyRows },
{ name: '待人工核对', rows: allPendingRows },
{ name: '地址待核对', rows: addressPendingRows },
{ name: '其他待核对', rows: otherPendingRows.length ? otherPendingRows : [{ 说明: '当前无记录' }] }
])
writeJson(path.join(outputDir, 'sheet2_public_manifest.json'), manifest)
writeJson(path.join(outputDir, 'sheet2_public_ready_payload.json'), {
payloadCount: readyPayload.length,
payload: readyPayload
})
writeJson(path.join(outputDir, 'sheet2_public_pending_rows.json'), {
pendingCount: allPendingRows.length,
rows: allPendingRows
})
writeJson(path.join(outputDir, 'sheet2_live_refs.json'), liveRefs)
console.log(
JSON.stringify(
{
outputDir,
workbookPath,
manifest
},
null,
2
)
)
}
if (require.main === module) {
run().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
})
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -0,0 +1,893 @@
const fs = require('fs')
const path = require('path')
const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs'))
const { splitHardwareQuantity, expandHardwareModels, normalizeText } = require('./hardwareQuantitySplit')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DEFAULT_API_BASE = 'https://www.jsgdzc.cn/api'
const REPAIR_MARKER = 'multi_unit_split_20260323'
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
mode: 'preflight',
sourcePath: '',
apiBase: DEFAULT_API_BASE,
username: '',
password: '',
limit: 0,
parentCode: '',
sourceRow: 0
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--mode') {
options.mode = args[index + 1] || options.mode
index += 1
} else if (arg === '--source') {
options.sourcePath = args[index + 1] || ''
index += 1
} else if (arg === '--api-base') {
options.apiBase = args[index + 1] || DEFAULT_API_BASE
index += 1
} else if (arg === '--username') {
options.username = args[index + 1] || ''
index += 1
} else if (arg === '--password') {
options.password = args[index + 1] || ''
index += 1
} else if (arg === '--limit') {
options.limit = Number(args[index + 1] || 0) || 0
index += 1
} else if (arg === '--parent-code') {
options.parentCode = args[index + 1] || ''
index += 1
} else if (arg === '--source-row') {
options.sourceRow = Number(args[index + 1] || 0) || 0
index += 1
}
}
return options
}
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const sanitizeTokenValue = (value) => normalizeText(value).replace(/;/g, ',')
const findLatestApplyResultsPath = () => {
if (!fs.existsSync(OUTPUT_ROOT)) {
return ''
}
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('parent_child_live_apply_'))
.map((entry) => path.join(OUTPUT_ROOT, entry.name, 'apply_results.json'))
.filter((filePath) => fs.existsSync(filePath))
.sort()
return candidates[candidates.length - 1] || ''
}
const resolveSourcePath = (value) => {
const normalized = normalizeText(value)
if (normalized) {
const resolved = path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized)
if (!fs.existsSync(resolved)) {
throw new Error(`source 文件不存在: ${resolved}`)
}
return resolved
}
const latest = findLatestApplyResultsPath()
if (!latest) {
throw new Error(`未找到可用的 apply_results.json: ${OUTPUT_ROOT}`)
}
return latest
}
const ensureOutputDir = (prefix) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const escapeCsv = (value) => {
const stringValue = String(value == null ? '' : value)
if (/[,"\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
const writeCsv = (filePath, headers, rows) => {
const lines = [headers.map(escapeCsv).join(',')]
for (const row of rows) {
lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(','))
}
fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`)
}
const buildSummaryRows = (summary) =>
Object.entries(summary).map(([key, value]) => ({
key,
value: Array.isArray(value) || (value && typeof value === 'object') ? JSON.stringify(value) : value
}))
const writeWorkbook = async (filePath, sheets) => {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.created = new Date()
for (const sheet of sheets) {
const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}]
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
const worksheet = workbook.addWorksheet(sheet.name)
worksheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.min(Math.max(header.length + 4, 16), 60)
}))
rows.forEach((row) => worksheet.addRow(row))
worksheet.getRow(1).font = { bold: true }
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
}
await workbook.xlsx.writeFile(filePath)
}
const buildCookieHeader = (response) => {
if (typeof response.headers.getSetCookie === 'function') {
return response.headers.getSetCookie().map((item) => item.split(';')[0]).join('; ')
}
const setCookie = response.headers.get('set-cookie')
return setCookie ? setCookie.split(';')[0] : ''
}
class ApiSession {
constructor({ apiBase, username, password }) {
this.apiBase = normalizeText(apiBase).replace(/\/$/, '')
this.username = normalizeText(username)
this.password = String(password == null ? '' : password)
this.cookieHeader = ''
}
async login() {
if (!this.apiBase || !this.username || !this.password) {
throw new Error('缺少 API 登录参数')
}
const response = await fetch(`${this.apiBase}/auth/local/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: this.username,
password: this.password
})
})
const text = await response.text()
if (!response.ok) {
throw new Error(`登录失败: status=${response.status} body=${text}`)
}
this.cookieHeader = buildCookieHeader(response)
if (!this.cookieHeader) {
throw new Error('登录成功但未拿到 session cookie')
}
return JSON.parse(text)
}
async requestJson(endpoint, options = {}, retry = true) {
if (!this.cookieHeader) {
await this.login()
}
const headers = {
...(options.headers || {}),
Cookie: this.cookieHeader
}
const response = await fetch(`${this.apiBase}${endpoint}`, {
...options,
headers
})
const text = await response.text()
if (response.status === 401 && retry) {
await this.login()
return this.requestJson(endpoint, options, false)
}
if (!response.ok) {
throw new Error(`请求失败: ${endpoint} status=${response.status} body=${text}`)
}
return JSON.parse(text)
}
}
const unwrapPayload = (payload) => {
if (Array.isArray(payload)) {
return payload
}
if (payload?.data) {
return payload.data
}
if (payload?.list) {
return payload.list
}
return payload
}
const summarizeByReason = (rows, reasonKey = 'reason') => {
const counts = {}
for (const row of rows) {
const reason = normalizeText(row[reasonKey]) || 'unknown'
counts[reason] = (counts[reason] || 0) + 1
}
return counts
}
const stripRepairTokens = (description) =>
normalizeText(description)
.split(';')
.map((item) => item.trim())
.filter(
(item) =>
item &&
!/^repair_marker=/i.test(item) &&
!/^repair_source_row=/i.test(item) &&
!/^repair_parent_code=/i.test(item) &&
!/^repair_field_key=/i.test(item) &&
!/^repair_original_model=/i.test(item) &&
!/^repair_unit_model=/i.test(item) &&
!/^repair_unit_models=/i.test(item) &&
!/^repair_quantity=/i.test(item) &&
!/^repair_split_index=/i.test(item)
)
.join('; ')
const buildRepairDescription = (description, action, splitIndex, unitModel) => {
const base = stripRepairTokens(description)
const tokens = [
['repair_marker', REPAIR_MARKER],
['repair_source_row', action.source_row],
['repair_parent_code', action.parent_code],
['repair_field_key', action.field_key],
['repair_original_model', action.source_child_model],
['repair_unit_model', unitModel],
['repair_unit_models', action.desired_models.join(' | ')],
['repair_quantity', action.quantity],
['repair_split_index', splitIndex]
].map(([key, value]) => `${key}=${sanitizeTokenValue(value)}`)
return [base, ...tokens].filter(Boolean).join('; ')
}
const buildRepairMountNote = (action, splitIndex, unitModel) =>
`多件拆分修正:根据8.xlsx第${action.source_row}行,将${sanitizeTokenValue(action.source_child_model)}拆为第${splitIndex}/${action.quantity}件,型号=${sanitizeTokenValue(unitModel)}`
const loadSourceRows = (sourcePath, options) => {
let rows = readJsonFile(sourcePath)
if (!Array.isArray(rows)) {
throw new Error(`source 文件结构不正确,应为数组: ${sourcePath}`)
}
if (normalizeText(options.parentCode)) {
const parentCodeKey = normalizeLookupKey(options.parentCode)
rows = rows.filter((item) => normalizeLookupKey(item.parent_code) === parentCodeKey)
}
if (options.sourceRow > 0) {
rows = rows.filter((item) => Number(item.source_row) === Number(options.sourceRow))
}
if (options.limit > 0) {
rows = rows.slice(0, options.limit)
}
return rows
}
const countModelKeys = (models) => {
const counts = new Map()
for (const model of models) {
const key = normalizeLookupKey(model)
if (!key) {
continue
}
counts.set(key, {
key,
model,
count: (counts.get(key)?.count || 0) + 1
})
}
return counts
}
const findDesiredModelIndexes = (models, targetModel) =>
models
.map((model, index) => ({
model,
index: index + 1
}))
.filter((item) => normalizeLookupKey(item.model) === normalizeLookupKey(targetModel))
.map((item) => item.index)
const buildRepairCandidates = (rows) => {
const autoFixable = []
const manualReview = []
for (const row of rows) {
if (normalizeText(row.apply_status) && normalizeText(row.apply_status) !== 'success') {
continue
}
const split = splitHardwareQuantity(row.child_type, row.child_model)
const expanded = expandHardwareModels(row.child_type, row.child_model)
if (!split.isMultiUnit) {
continue
}
const base = {
source_row: row.source_row,
parent_code: row.parent_code,
parent_name: row.parent_name,
parent_type: row.parent_type,
field_key: row.field_key,
child_type: row.child_type,
child_name: row.child_name,
source_child_model: row.child_model,
current_child_id: row.child_id,
current_child_code: row.child_code,
live_parent_id: row.live_parent_id,
planned_description: row.planned_description || '',
parent_storage_location_id: row.parent_storage_location_id || '',
parent_department_id: row.parent_department_id || ''
}
if (!split.isAutoSplittable) {
manualReview.push({
...base,
quantity: split.quantity || '',
unit_model: '',
desired_models: '',
split_pattern: split.pattern || '',
reason: split.reason || 'multi_unit_model_requires_manual_review'
})
continue
}
if ((expanded.models || []).length !== 2) {
manualReview.push({
...base,
quantity: split.quantity,
unit_model: split.unitModel,
desired_models: (expanded.models || []).join(' | '),
split_pattern: split.pattern || '',
reason: 'quantity_greater_than_two_not_auto_applied'
})
continue
}
autoFixable.push({
...base,
quantity: expanded.models.length,
unit_model: split.unitModel,
desired_models: expanded.models,
desired_models_json: JSON.stringify(expanded.models),
split_pattern: split.pattern || ''
})
}
return {
autoFixable,
manualReview
}
}
const analyzeCurrentState = async (session, candidates) => {
const parentIds = [...new Set(candidates.map((item) => Number(item.live_parent_id)).filter((item) => item > 0))]
const childIds = [...new Set(candidates.map((item) => Number(item.current_child_id)).filter((item) => item > 0))]
const parentMap = new Map()
const childMap = new Map()
for (const parentId of parentIds) {
const payload = await session.requestJson(`/equipment/${parentId}`)
parentMap.set(parentId, unwrapPayload(payload))
}
for (const childId of childIds) {
const payload = await session.requestJson(`/equipment/${childId}`)
childMap.set(childId, unwrapPayload(payload))
}
const actionable = []
const blocked = []
const satisfied = []
for (const candidate of candidates) {
const desiredModels = Array.isArray(candidate.desired_models)
? candidate.desired_models
: JSON.parse(candidate.desired_models_json || '[]')
const desiredModelCounts = countModelKeys(desiredModels)
const parent = parentMap.get(Number(candidate.live_parent_id))
const currentChild = childMap.get(Number(candidate.current_child_id))
const base = {
...candidate,
desired_models: desiredModels,
current_child_status: currentChild?.status || '',
current_child_model: currentChild?.equipment_model || '',
current_child_parent_id: currentChild?.parent_id || ''
}
if (!parent) {
blocked.push({
...base,
reason: 'live_parent_not_found'
})
continue
}
if (!currentChild) {
blocked.push({
...base,
reason: 'current_child_not_found'
})
continue
}
if (Number(currentChild.parent_id) !== Number(candidate.live_parent_id)) {
blocked.push({
...base,
reason: 'current_child_not_mounted_to_expected_parent'
})
continue
}
const sameTypeChildren = (Array.isArray(parent.children) ? parent.children : []).filter(
(child) =>
normalizeLookupKey(child.equipment_type) === normalizeLookupKey(candidate.child_type)
)
const currentModelKey = normalizeLookupKey(currentChild.equipment_model)
const originalModelKey = normalizeLookupKey(candidate.source_child_model)
const siblingDesiredCounts = new Map()
for (const child of sameTypeChildren) {
if (Number(child.id) === Number(candidate.current_child_id)) {
continue
}
const key = normalizeLookupKey(child.equipment_model)
const desired = desiredModelCounts.get(key)
if (!desired) {
continue
}
siblingDesiredCounts.set(key, {
key,
model: desired.model,
count: (siblingDesiredCounts.get(key)?.count || 0) + 1
})
}
const totalSiblingDesiredCount = [...siblingDesiredCounts.values()].reduce((sum, item) => sum + item.count, 0)
const missingModelsForAssignment = (assignedModel) => {
const assignedKey = normalizeLookupKey(assignedModel)
const countsAfterAssign = new Map(siblingDesiredCounts)
if (desiredModelCounts.has(assignedKey)) {
countsAfterAssign.set(assignedKey, {
key: assignedKey,
model: desiredModelCounts.get(assignedKey).model,
count: (countsAfterAssign.get(assignedKey)?.count || 0) + 1
})
}
const totalDesiredAfterAssign = [...countsAfterAssign.values()].reduce((sum, item) => sum + item.count, 0)
if (totalDesiredAfterAssign > desiredModels.length) {
return null
}
const missing = []
for (const desired of desiredModelCounts.values()) {
const currentCount = countsAfterAssign.get(desired.key)?.count || 0
for (let index = currentCount; index < desired.count; index += 1) {
missing.push(desired.model)
}
}
return missing
}
const assignmentOptions = []
if (desiredModelCounts.has(currentModelKey)) {
assignmentOptions.push({
targetModel: currentChild.equipment_model,
updateExisting: false
})
}
if (currentModelKey === originalModelKey) {
for (const desiredModel of desiredModels) {
if (!assignmentOptions.some((item) => normalizeLookupKey(item.targetModel) === normalizeLookupKey(desiredModel))) {
assignmentOptions.push({
targetModel: desiredModel,
updateExisting: true
})
}
}
}
if (assignmentOptions.length === 0) {
blocked.push({
...base,
reason: 'current_child_model_is_not_repairable'
})
continue
}
const candidatePlans = assignmentOptions
.map((option) => {
const missingModels = missingModelsForAssignment(option.targetModel)
if (!missingModels || missingModels.length > 1) {
return null
}
return {
...option,
missingModels,
desiredMatchingSiblingCount: totalSiblingDesiredCount
}
})
.filter(Boolean)
.sort((left, right) => {
if (left.missingModels.length !== right.missingModels.length) {
return left.missingModels.length - right.missingModels.length
}
if (left.updateExisting !== right.updateExisting) {
return left.updateExisting ? -1 : 1
}
return desiredModels.findIndex((item) => normalizeLookupKey(item) === normalizeLookupKey(left.targetModel))
- desiredModels.findIndex((item) => normalizeLookupKey(item) === normalizeLookupKey(right.targetModel))
})
const selectedPlan = candidatePlans[0]
if (!selectedPlan) {
blocked.push({
...base,
reason: 'current_state_requires_manual_review'
})
continue
}
if (!selectedPlan.updateExisting && selectedPlan.missingModels.length === 0) {
satisfied.push({
...base,
reason: 'already_split_to_expected_quantity'
})
continue
}
const currentTargetIndexes = findDesiredModelIndexes(desiredModels, selectedPlan.targetModel)
const createIndexes = selectedPlan.missingModels[0]
? findDesiredModelIndexes(desiredModels, selectedPlan.missingModels[0])
: []
const currentTargetIndex = currentTargetIndexes[0] || 1
const createIndex = selectedPlan.missingModels[0]
? (
normalizeLookupKey(selectedPlan.missingModels[0]) === normalizeLookupKey(selectedPlan.targetModel) &&
createIndexes.length > 1
? createIndexes.find((item) => item !== currentTargetIndex) || createIndexes[0]
: createIndexes[0]
)
: ''
actionable.push({
...base,
action_type:
selectedPlan.updateExisting && selectedPlan.missingModels.length === 1
? 'update_existing_and_create_one'
: selectedPlan.updateExisting
? 'update_existing_only'
: 'create_one_missing_unit_child',
current_target_model: selectedPlan.targetModel,
create_model: selectedPlan.missingModels[0] || '',
current_target_index: currentTargetIndex,
create_index: createIndex
})
}
return {
parentMap,
childMap,
actionable,
blocked,
satisfied
}
}
const buildCreateChildPayload = (action, parent, sourceDescription, splitIndex, unitModel) => ({
entry_mode: 'child',
equipment_name: action.child_name,
equipment_model: unitModel,
equipment_type: action.child_type,
code_generation_mode: 'auto',
unit_price: 0,
total_quantity: 1,
remaining_quantity: 1,
status: '可用',
storage_location_id: parent.storage_location_id,
department_id: parent.department_id || null,
description: buildRepairDescription(sourceDescription, action, splitIndex, unitModel)
})
const applyRepairs = async (session, analysis) => {
const results = []
const errors = []
for (let index = 0; index < analysis.actionable.length; index += 1) {
const action = analysis.actionable[index]
const progress = `${index + 1}/${analysis.actionable.length}`
console.log(`[${progress}] repair ${action.parent_code} ${action.child_type} ${action.source_child_model}`)
let createdChildId = null
let createdChildCode = ''
let existingChildUpdated = false
let originalDescription = ''
let originalModel = ''
try {
const parentPayload = await session.requestJson(`/equipment/${action.live_parent_id}`)
const parent = unwrapPayload(parentPayload)
const childPayload = await session.requestJson(`/equipment/${action.current_child_id}`)
const currentChild = unwrapPayload(childPayload)
if (!parent || !currentChild) {
throw new Error('父资产或当前子资产不存在')
}
originalDescription = currentChild.description || ''
originalModel = currentChild.equipment_model || ''
if (action.action_type === 'update_existing_and_create_one' || action.action_type === 'update_existing_only') {
await session.requestJson(`/equipment/${action.current_child_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
equipment_model: action.current_target_model,
description: buildRepairDescription(
originalDescription,
action,
Number(action.current_target_index) || 1,
action.current_target_model
)
})
})
existingChildUpdated = true
}
if (action.create_model) {
const createResult = await session.requestJson('/equipment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
buildCreateChildPayload(
action,
parent,
originalDescription,
Number(action.create_index) || 2,
action.create_model
)
)
})
const createdChild = unwrapPayload(createResult)
createdChildId = Number(createdChild?.id) || null
createdChildCode = normalizeText(createdChild?.equipment_code)
if (!createdChildId || !createdChildCode) {
throw new Error('新建补充子资产返回结果不完整')
}
await session.requestJson(`/mount/${action.live_parent_id}/mount`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
child_id: createdChildId,
notes: buildRepairMountNote(
action,
Number(action.create_index) || 2,
action.create_model
)
})
})
}
results.push({
...action,
apply_status: 'success',
created_child_id: createdChildId || '',
created_child_code: createdChildCode || ''
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (createdChildId) {
try {
await session.requestJson(`/equipment/${createdChildId}`, {
method: 'DELETE'
})
} catch (cleanupError) {
console.error(`[cleanup] 删除新建子资产失败 id=${createdChildId}`, cleanupError)
}
}
if (existingChildUpdated) {
try {
await session.requestJson(`/equipment/${action.current_child_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
equipment_model: originalModel,
description: originalDescription
})
})
} catch (rollbackError) {
console.error(`[rollback] 回滚原子资产失败 id=${action.current_child_id}`, rollbackError)
}
}
errors.push({
...action,
apply_status: 'error',
error_message: errorMessage,
created_child_id: createdChildId || '',
created_child_code: createdChildCode || ''
})
}
}
return {
results,
errors
}
}
const run = async () => {
const options = parseArgs()
const sourcePath = resolveSourcePath(options.sourcePath)
const sourceRows = loadSourceRows(sourcePath, options)
const outputDir = ensureOutputDir('multi_unit_child_repair')
const sourceCandidates = buildRepairCandidates(sourceRows)
const summaryBase = {
generated_at: new Date().toISOString(),
mode: options.mode,
source_path: sourcePath,
api_base: options.apiBase,
source_total_rows: sourceRows.length,
detected_multi_unit_rows: sourceCandidates.autoFixable.length + sourceCandidates.manualReview.length,
auto_fixable_candidate_count: sourceCandidates.autoFixable.length,
manual_review_candidate_count: sourceCandidates.manualReview.length
}
let analysis = {
actionable: [],
blocked: [],
satisfied: []
}
let applyResult = {
results: [],
errors: []
}
if (sourceCandidates.autoFixable.length > 0) {
const session = new ApiSession({
apiBase: options.apiBase,
username: options.username,
password: options.password
})
analysis = await analyzeCurrentState(session, sourceCandidates.autoFixable)
if (normalizeText(options.mode).toLowerCase() === 'apply' && analysis.actionable.length > 0) {
applyResult = await applyRepairs(session, analysis)
}
}
const summary = {
...summaryBase,
actionable_count: analysis.actionable.length,
blocked_count: analysis.blocked.length,
satisfied_count: analysis.satisfied.length,
manual_review_reason_counts: summarizeByReason(sourceCandidates.manualReview),
blocked_reason_counts: summarizeByReason(analysis.blocked),
satisfied_reason_counts: summarizeByReason(analysis.satisfied),
apply_success_count: applyResult.results.length,
apply_error_count: applyResult.errors.length
}
const report = {
summary,
autoFixableCandidates: sourceCandidates.autoFixable,
manualReviewCandidates: sourceCandidates.manualReview,
actionable: analysis.actionable,
blocked: analysis.blocked,
satisfied: analysis.satisfied,
applyResults: applyResult.results,
applyErrors: applyResult.errors
}
writeJson(path.join(outputDir, 'summary.json'), summary)
writeJson(path.join(outputDir, 'report.json'), report)
writeCsv(
path.join(outputDir, 'manual_review_candidates.csv'),
Object.keys(sourceCandidates.manualReview[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }),
sourceCandidates.manualReview
)
writeCsv(
path.join(outputDir, 'actionable.csv'),
Object.keys(analysis.actionable[0] || { parent_code: '', child_type: '', source_child_model: '', unit_model: '', action_type: '' }),
analysis.actionable
)
writeCsv(
path.join(outputDir, 'blocked.csv'),
Object.keys(analysis.blocked[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }),
analysis.blocked
)
writeCsv(
path.join(outputDir, 'satisfied.csv'),
Object.keys(analysis.satisfied[0] || { parent_code: '', child_type: '', source_child_model: '', reason: '' }),
analysis.satisfied
)
writeCsv(
path.join(outputDir, 'apply_results.csv'),
Object.keys(applyResult.results[0] || { parent_code: '', child_type: '', source_child_model: '', created_child_code: '', apply_status: '' }),
applyResult.results
)
writeCsv(
path.join(outputDir, 'apply_errors.csv'),
Object.keys(applyResult.errors[0] || { parent_code: '', child_type: '', source_child_model: '', error_message: '' }),
applyResult.errors
)
await writeWorkbook(path.join(outputDir, 'multi_unit_child_repair.xlsx'), [
{ name: 'Summary', rows: buildSummaryRows(summary) },
{ name: 'ManualReview', rows: sourceCandidates.manualReview },
{ name: 'Actionable', rows: analysis.actionable },
{ name: 'Blocked', rows: analysis.blocked },
{ name: 'Satisfied', rows: analysis.satisfied },
{ name: 'ApplyResults', rows: applyResult.results },
{ name: 'ApplyErrors', rows: applyResult.errors }
])
console.log(
JSON.stringify(
{
outputDir,
summary
},
null,
2
)
)
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@ -0,0 +1,51 @@
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES = ['可用']
const isSheet2PublicEquipmentDescription = (description) => {
const normalized = normalizeText(description)
if (!normalized) {
return false
}
return (
normalized.includes('source_workbook=docs/总表待导入.xlsx') &&
normalized.includes('source_sheet=Sheet2')
)
}
const buildPublicAssetBorrowState = (unitPrice) => {
const normalizedUnitPrice = Number(unitPrice || 0)
return {
equipmentStatus: '已借出',
remainingQuantity: 0,
totalValue: normalizedUnitPrice,
borrowedValue: normalizedUnitPrice,
availableValue: 0
}
}
const buildAssignableExistingSheet2PublicEquipmentWhere = (
assignableStatuses = PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES
) => ({
AND: [
{ description: { contains: 'source_workbook=docs/总表待导入.xlsx' } },
{ description: { contains: 'source_sheet=Sheet2' } }
],
status: { in: assignableStatuses },
borrow_records: { none: { status: '已借出' } }
})
module.exports = {
buildAssignableExistingSheet2PublicEquipmentWhere,
PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES,
buildPublicAssetBorrowState,
isSheet2PublicEquipmentDescription,
normalizeText
}

View File

@ -0,0 +1,231 @@
const { resolveStorageLocationMatch } = require('./locationNormalization')
const SHEET2_LOCATION_OVERRIDES = {
山东: '济南',
运营中心: '常州-天安',
客服中心: '常州-天安',
人力行政中心: '常州-天安',
庙西宿舍: '常州-仓库',
财务中心: '常州-天安',
天安17楼: '常州-天安',
运营中心B: '常州-天安',
供应链中心: '常州-天安',
'2楼': '常州-天安',
湖塘摄影棚: '影棚',
睢宁仓: '苏北仓库',
横林二仓仓库: '常州-仓库',
横山桥: '常州-仓库',
客服二部: '常州-天安',
苏北仓: '苏北仓库',
仓储中心: '常州-仓库',
横林二仓二楼会议室: '常州-仓库',
横林二仓二楼张总办公室: '常州-仓库',
红星美凯龙: '常州-美凯龙',
天安三楼: '常州-天安',
'1楼 客服四部': '常州-天安',
'3楼 智管信息中心': '常州-天安',
常州运c: '常州-天安',
二楼: '常州-天安',
分销外贸: '常州-天安',
佛山顺德仓: '佛山-顺德仓库',
河北仓: '河北-仓库',
横林二仓采购办公室: '常州-仓库',
横林二仓仓库办公室: '常州-仓库',
横林二仓二楼顾总办公室: '常州-仓库',
横林二仓二楼贾总办公室: '常州-仓库',
横林二仓二楼朱总办公室: '常州-仓库',
横林二仓一楼仓储办公室: '常州-仓库',
客服六部: '常州-天安',
三楼: '常州-天安',
山东曹县: '山东-曹县',
事业一部: '常州-天安',
天安10号楼三楼机房: '常州-天安',
天安10号楼四楼厨房: '常州-天安',
天安10号楼一楼机房: '常州-天安',
天安四楼会议室: '常州-天安',
运营八部: '常州-天安',
运营十部: '常州-天安',
总经理室: '常州-天安'
}
const SHEET2_LOCATION_PREFIXES = ['仓储一部', '仓储二部', '仓储三部']
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const stripSheet2LocationPrefix = (rawValue) => {
const normalized = normalizeText(rawValue)
if (!normalized) {
return {
rawValue: '',
strippedValue: '',
prefix: '',
changed: false
}
}
for (const prefix of SHEET2_LOCATION_PREFIXES) {
const marker = `${prefix} `
if (normalized.startsWith(marker)) {
const strippedValue = normalizeText(normalized.slice(marker.length))
return {
rawValue: normalized,
strippedValue,
prefix,
changed: strippedValue !== normalized
}
}
}
return {
rawValue: normalized,
strippedValue: normalized,
prefix: '',
changed: false
}
}
const resolveSheet2LocationMatch = (rawValue, locationByName) => {
const normalized = normalizeText(rawValue)
if (!normalized) {
return {
rawValue: '',
normalizedValue: '',
match: null,
rule: ''
}
}
const directOverride = SHEET2_LOCATION_OVERRIDES[normalized]
if (directOverride) {
const match = locationByName.get(normalizeLookupKey(directOverride)) || null
return {
rawValue: normalized,
normalizedValue: directOverride,
match,
rule: 'sheet2-confirmed-override'
}
}
const directMatch = resolveStorageLocationMatch(normalized, locationByName)
if (directMatch.match) {
return directMatch
}
const stripped = stripSheet2LocationPrefix(normalized)
if (!stripped.changed) {
return directMatch
}
const strippedOverride = SHEET2_LOCATION_OVERRIDES[stripped.strippedValue]
if (strippedOverride) {
const match = locationByName.get(normalizeLookupKey(strippedOverride)) || null
return {
rawValue: normalized,
normalizedValue: strippedOverride,
match,
rule: 'sheet2-prefix-override'
}
}
const strippedMatch = resolveStorageLocationMatch(stripped.strippedValue, locationByName)
if (strippedMatch.match) {
return {
rawValue: normalized,
normalizedValue: strippedMatch.normalizedValue,
match: strippedMatch.match,
rule: 'sheet2-prefix-alias'
}
}
return {
rawValue: normalized,
normalizedValue: stripped.strippedValue,
match: null,
rule: 'sheet2-prefix-stripped'
}
}
const mergeCandidateRowsWithSourceCodes = (candidateRows, sourceRows, assetCodes) => {
const normalizedCodes = [...new Set((assetCodes || []).map((code) => normalizeText(code).toUpperCase()).filter(Boolean))]
if (!normalizedCodes.length) {
return {
rows: [...candidateRows],
missingCodes: [],
duplicateSourceCodes: []
}
}
const sourceRowsByCode = new Map()
for (const row of sourceRows || []) {
const code = normalizeText(row.资产编码).toUpperCase()
if (!code) {
continue
}
if (!sourceRowsByCode.has(code)) {
sourceRowsByCode.set(code, [])
}
sourceRowsByCode.get(code).push(row)
}
const existingCodes = new Set(
(candidateRows || []).map((row) => normalizeText(row.资产编码).toUpperCase()).filter(Boolean)
)
const rows = [...candidateRows]
const missingCodes = []
const duplicateSourceCodes = []
for (const code of normalizedCodes) {
if (existingCodes.has(code)) {
continue
}
const sourceCodeRows = sourceRowsByCode.get(code) || []
if (!sourceCodeRows.length) {
missingCodes.push(code)
continue
}
if (sourceCodeRows.length > 1) {
duplicateSourceCodes.push({
code,
rows: sourceCodeRows.map((row) => ({
...row,
来源工作表: normalizeText(row.来源工作表) || 'Sheet2'
}))
})
continue
}
const sourceRow = sourceCodeRows[0]
rows.push({
...sourceRow,
来源工作表: normalizeText(sourceRow.来源工作表) || 'Sheet2'
})
existingCodes.add(code)
}
return {
rows,
missingCodes,
duplicateSourceCodes
}
}
module.exports = {
SHEET2_LOCATION_OVERRIDES,
SHEET2_LOCATION_PREFIXES,
mergeCandidateRowsWithSourceCodes,
normalizeText,
normalizeLookupKey,
resolveSheet2LocationMatch,
stripSheet2LocationPrefix
}

View File

@ -0,0 +1,307 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { batchReturn } from '../batchController'
const initialUpdatedAt = new Date('2026-07-14T05:00:00.000Z')
const mountedUpdatedAt = new Date('2026-07-14T05:00:01.000Z')
const borrowedRoot = {
id: 30,
equipment_code: 'JS-PC-0030',
equipment_name: '借出中主机',
parent_id: null,
department_id: 3,
storage_location_id: 1,
status: '已借出',
mount_status: 'none',
total_quantity: 1,
remaining_quantity: 0,
unit_price: 1000,
updated_at: initialUpdatedAt
}
// 父节点快照故意保持不变,确保测试必须靠 current read 发现新子件。
const currentRoot = { ...borrowedRoot }
const newlyMountedChild = {
id: 31,
equipment_code: 'JS-RAM-0031',
equipment_name: '新挂载内存',
parent_id: borrowedRoot.id,
department_id: 3,
storage_location_id: 1,
status: '已借出',
mount_status: 'mounted',
total_quantity: 1,
remaining_quantity: 0,
unit_price: 0,
updated_at: mountedUpdatedAt
}
test('RR 旧快照漏掉并发新配件时,归还用 current read 中止且零业务/状态写入', async () => {
const originalEquipmentFindUnique = prisma.equipment.findUnique
const originalBorrowFindMany = prisma.borrowRecord.findMany
const originalTransaction = prisma.$transaction
let mountedAfterSnapshot = false
let movementWriteCount = 0
let borrowWriteCount = 0
let equipmentWriteCount = 0
const lockQueries: any[] = []
const currentReadQueries: any[] = []
;(prisma.equipment.findUnique as any) = async () => borrowedRoot
;(prisma.borrowRecord.findMany as any) = async () => [{
id: 301,
equipment_id: borrowedRoot.id,
status: '已借出'
}]
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) {
lockQueries.push(query)
mountedAfterSnapshot = true
return []
}
currentReadQueries.push(query)
if (query.sql.includes('WHERE equipment.id = ?')) return [currentRoot]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return mountedAfterSnapshot && query.values.includes(borrowedRoot.id)
? [newlyMountedChild]
: []
}
return []
},
equipment: {
findUnique: async () => borrowedRoot,
// 故意固定为 RR 事务初始快照,普通复读仍看不到新配件。
findMany: async () => [],
updateMany: async () => {
equipmentWriteCount += 1
return { count: 1 }
},
update: async () => {
equipmentWriteCount += 1
return borrowedRoot
}
},
borrowRecord: {
updateMany: async () => {
borrowWriteCount += 1
return { count: 1 }
}
},
assetTransferRecord: {
create: async () => {
movementWriteCount += 1
return { id: 1, items: [] }
}
}
})
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 6, role: 'it', name: '测试 IT' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await batchReturn(
{
body: {
equipment_ids: [borrowedRoot.id],
return_condition: '正常'
}
} as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(state.body.data.success_count, 0)
assert.equal(state.body.data.failed_count, 1)
assert.match(state.body.data.results[0].message, /资产树状态或挂载关系已变化/)
assert.match(lockQueries[0].sql, /IN \(\?\) ORDER BY id FOR UPDATE/)
assert.deepEqual(lockQueries[0].values, [borrowedRoot.id])
assert.match(currentReadQueries[0].sql, /WHERE equipment\.id = \?/)
assert.deepEqual(currentReadQueries[0].values, [borrowedRoot.id])
assert.match(currentReadQueries[1].sql, /WHERE equipment\.parent_id IN \(\?\)/)
assert.deepEqual(currentReadQueries[1].values, [borrowedRoot.id])
assert.equal(movementWriteCount, 0)
assert.equal(borrowWriteCount, 0)
assert.equal(equipmentWriteCount, 0)
} finally {
;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique
;(prisma.borrowRecord.findMany as any) = originalBorrowFindMany
;(prisma.$transaction as any) = originalTransaction
}
})
test('总量 2 并发借出 B 先提交后归还 A,使用锁后库存恢复为 1 且配件仍保持已借出', async () => {
const originalEquipmentFindUnique = prisma.equipment.findUnique
const originalBorrowFindMany = prisma.borrowRecord.findMany
const originalTransaction = prisma.$transaction
const originalOperationLogCreate = prisma.operationLog.create
const outsideEquipment = {
...borrowedRoot,
id: 40,
equipment_code: 'JS-PC-0040',
total_quantity: 2,
remaining_quantity: 1,
status: '可用',
department_id: 9,
updated_at: initialUpdatedAt
}
const lockedRoot = {
...outsideEquipment,
remaining_quantity: 0,
status: '已借出',
updated_at: mountedUpdatedAt
}
const mountedChild = {
...newlyMountedChild,
id: 41,
equipment_code: 'JS-RAM-0041',
parent_id: lockedRoot.id,
department_id: 9,
unit_price: 100
}
const borrowRecords = [
{ id: 401, equipment_id: lockedRoot.id, status: '已借出', person_id: 1, children_snapshot: JSON.stringify([{ id: mountedChild.id, parent_id: lockedRoot.id }]) },
{ id: 402, equipment_id: lockedRoot.id, status: '已借出', person_id: 2 }
]
const rootUpdates: any[] = []
const childUpdates: any[] = []
// 事务外查询时 B 还未提交,因此页面只选中归还 A。
;(prisma.equipment.findUnique as any) = async () => outsideEquipment
;(prisma.borrowRecord.findMany as any) = async () => [borrowRecords[0]]
;(prisma.operationLog.create as any) = async () => ({ id: 1 })
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) return []
if (query.sql.includes('WHERE equipment.id = ?')) return [lockedRoot]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return query.values.includes(lockedRoot.id) ? [mountedChild] : []
}
return []
},
equipment: {
findUnique: async () => lockedRoot,
findMany: async (args: any) => {
const parentIds = args.where.parent_id.in as number[]
return parentIds.includes(lockedRoot.id) ? [mountedChild] : []
},
updateMany: async (args: any) => {
rootUpdates.push(args)
return { count: 1 }
},
update: async (args: any) => {
childUpdates.push(args)
return mountedChild
}
},
borrowRecord: {
updateMany: async (args: any) => {
const target = borrowRecords.find((record) =>
record.id === args.where.id &&
record.equipment_id === args.where.equipment_id &&
record.status === args.where.status
)
if (!target) return { count: 0 }
target.status = args.data.status
return { count: 1 }
}
},
department: { findUnique: async () => ({ name: '在用部门' }) },
assetTransferRecord: {
create: async () => ({ id: 1, items: [] })
}
})
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 6, role: 'it', name: '测试 IT', username: 'tester' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await batchReturn(
{
body: { equipment_ids: [lockedRoot.id], return_condition: '正常' },
ip: '127.0.0.1'
} as any,
res as any
)
assert.equal(state.body.data.success_count, 1)
assert.equal(borrowRecords.filter((record) => record.status === '已借出').length, 1)
assert.equal(rootUpdates.length, 1)
assert.equal(rootUpdates[0].where.remaining_quantity, 0)
assert.equal(rootUpdates[0].where.total_quantity, 2)
assert.equal(rootUpdates[0].where.updated_at, mountedUpdatedAt)
assert.equal(rootUpdates[0].data.remaining_quantity, 1)
assert.equal(rootUpdates[0].data.status, '可用')
assert.equal(rootUpdates[0].data.department_id, 9)
assert.equal(childUpdates.length, 1)
assert.equal(childUpdates[0].data.status, '已借出')
assert.equal(childUpdates[0].data.remaining_quantity, 0)
assert.equal(Object.prototype.hasOwnProperty.call(childUpdates[0].data, 'department_id'), false)
} finally {
;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique
;(prisma.borrowRecord.findMany as any) = originalBorrowFindMany
;(prisma.$transaction as any) = originalTransaction
;(prisma.operationLog.create as any) = originalOperationLogCreate
}
})
test('批量归还接口也拒绝客户端自选部门处理', async () => {
const originalEquipmentFindUnique = prisma.equipment.findUnique
let equipmentLookupCount = 0
;(prisma.equipment.findUnique as any) = async () => {
equipmentLookupCount += 1
return borrowedRoot
}
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 6, role: 'it' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await batchReturn(
{
body: {
equipment_ids: [borrowedRoot.id],
department_action: 'keep'
}
} as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /归还入仓统一清空部门/)
assert.equal(equipmentLookupCount, 0)
} finally {
;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique
}
})

View File

@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { batchImportEquipments } from '../batchImportController'
test('Excel import preserves source row, rejects invalid quantities/prices without creating equipment, and retains legacy row fallback', async () => {
const originals = [prisma.assetType.findMany, prisma.department.findMany, prisma.storageLocation.findMany, prisma.equipment.create, prisma.operationLog.create]
let creates = 0
;(prisma.assetType.findMany as any) = async () => [{ id: 1, name: '电脑', can_be_child: false }]
;(prisma.department.findMany as any) = async () => []
;(prisma.storageLocation.findMany as any) = async () => []
;(prisma.equipment.create as any) = async () => { creates++; return {} }
;(prisma.operationLog.create as any) = async () => ({})
const state = { code: 200, body: null as any }
const res = { locals: { user: { id: 9, role: 'it' } }, status(code: number) { state.code = code; return this }, json(body: any) { state.body = body; return this } }
try {
const quantities = [null, 0, -1, 1.5, 'abc', '', true]
const equipments = quantities.map((quantity, index) => ({ equipment_name: '电脑', equipment_type: '电脑', total_quantity: quantity, source_row: index + 10 }))
equipments.push({ equipment_name: '电脑', equipment_type: '电脑', total_quantity: 1, unit_price: -2, source_row: 27 } as any)
equipments.push({ equipment_name: '电脑', equipment_type: '电脑', total_quantity: 1, unit_price: 'bad-price', source_row: 28 } as any)
equipments.push({ equipment_name: '电脑', equipment_type: '电脑', total_quantity: 1, unit_price: 'Infinity', source_row: 29 } as any)
equipments.push({ equipment_name: '', equipment_type: '电脑', total_quantity: 1, source_row: 2.5 } as any)
await batchImportEquipments({ body: { equipments }, ip: 'mock' } as any, res as any)
assert.equal(state.code, 200)
assert.equal(creates, 0)
assert.equal(state.body.data.failed, 11)
assert.deepEqual(state.body.data.errors.map((item: any) => item.row), [10, 11, 12, 13, 14, 15, 16, 27, 28, 29, 12])
assert.match(state.body.data.errors[0].message, /数量.*整数/)
assert.match(state.body.data.errors[7].message, /单价/)
} finally {
;[prisma.assetType.findMany, prisma.department.findMany, prisma.storageLocation.findMany, prisma.equipment.create, prisma.operationLog.create] = originals as any
}
})
test('valid positive quantity and zero price retain exact stock/value while child types still require one item', async () => {
const targets: Array<[any, string, any]> = [
[prisma.assetType, 'findMany', async () => [{ id: 1, name: '电脑', can_be_child: false }, { id: 2, name: '内存', can_be_child: true }]],
[prisma.assetType, 'findFirst', async () => ({ id: 1, short_name: 'PC' })],
[prisma.department, 'findMany', async () => []],
[prisma.storageLocation, 'findMany', async () => []],
[prisma.equipment, 'findMany', async () => []],
[prisma.equipment, 'findUnique', async () => null],
[prisma.operationLog, 'create', async () => ({})]
]
const captured: any[] = []
targets.push([prisma.equipment, 'create', async (args: any) => { captured.push(args.data); return args.data }])
const original = targets.map(([object, key]) => object[key])
targets.forEach(([object, key, fn]) => { object[key] = fn })
const state = { body: null as any }
const res = { locals: { user: { id: 9, role: 'it' } }, json(body: any) { state.body = body; return this }, status() { return this } }
try {
await batchImportEquipments({ ip: 'mock', body: { equipments: [
{ equipment_name: '办公电脑', equipment_type: '电脑', total_quantity: '2', unit_price: 0, source_row: 6 },
{ equipment_name: '多数量配件', equipment_type: '内存', total_quantity: 2, source_row: 19 }
] } } as any, res as any)
assert.equal(state.body.data.success, 1)
assert.equal(state.body.data.failed, 1)
assert.equal(state.body.data.errors[0].row, 19)
assert.match(state.body.data.errors[0].message, /子资产.*数量必须为 1/)
assert.equal(captured.length, 1)
assert.equal(captured[0].total_quantity, 2)
assert.equal(captured[0].remaining_quantity, 2)
assert.equal(captured[0].available_value, 0)
} finally {
targets.forEach(([object, key], index) => { object[key] = original[index] })
}
})

View File

@ -0,0 +1,116 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { getBorrowRecordList, searchReturnAssets } from '../borrowController'
const createMockResponse = () => {
const responseBody: { statusCode: number; body: any } = { statusCode: 200, body: null }
const res = {
locals: { user: { id: 1, role: 'it' } },
status(code: number) {
responseBody.statusCode = code
return this
},
json(payload: any) {
responseBody.body = payload
return this
}
}
return { res, responseBody }
}
test('借用记录列表不按历史 person_identifier 搜索且不返回原始值', async () => {
const originalFindMany = prisma.borrowRecord.findMany
const originalCount = prisma.borrowRecord.count
let capturedWhere: any = null
;(prisma.borrowRecord.findMany as any) = async (args: any) => {
capturedWhere = args.where
return [{
id: 1,
equipment_id: 20,
equipment_code: 'JS-PC-0020',
person_id: 8,
person_name: '测试员工',
person_nickname: '员工',
person_identifier: '320311199001011234',
borrow_time: new Date('2026-07-01T00:00:00Z'),
expected_return_time: null,
is_long_term: false,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: null,
children_snapshot: null,
person: null,
equipment: {
equipment_name: '主机',
storage_location_id: 1,
storageLocation: null
}
}]
}
;(prisma.borrowRecord.count as any) = async () => 1
try {
const { res, responseBody } = createMockResponse()
await getBorrowRecordList({
query: { person_name: '320311', page: '1', pageSize: '10' }
} as any, res as any)
assert.equal(responseBody.statusCode, 200)
assert.equal(JSON.stringify(capturedWhere).includes('person_identifier'), false)
assert.equal(JSON.stringify(responseBody.body).includes('320311199001011234'), false)
assert.equal(
Object.prototype.hasOwnProperty.call(responseBody.body.data.list[0], 'person_identifier'),
false
)
} finally {
;(prisma.borrowRecord.findMany as any) = originalFindMany
;(prisma.borrowRecord.count as any) = originalCount
}
})
test('归还资产搜索不读取或返回历史借用人标识', async () => {
const originalFindMany = prisma.equipment.findMany
let capturedArgs: any = null
;(prisma.equipment.findMany as any) = async (args: any) => {
capturedArgs = args
return [{
id: 20,
equipment_code: 'JS-PC-0020',
equipment_name: '主机',
equipment_model: 'M720',
equipment_type: '主机',
status: '已借出',
total_quantity: 1,
parent_id: null,
storage_location_id: 1,
storageLocation: null,
borrow_records: [{
id: 1,
person_name: '测试员工',
person_nickname: '员工',
person_identifier: '320311199001011234',
person: { department: '测试部', mainDepartment: { name: '测试部' } }
}]
}]
}
try {
const { res, responseBody } = createMockResponse()
await searchReturnAssets({ query: { keyword: 'JS-PC' } } as any, res as any)
assert.equal(responseBody.statusCode, 200)
assert.equal(JSON.stringify(capturedArgs).includes('person_identifier'), false)
assert.equal(JSON.stringify(responseBody.body).includes('320311199001011234'), false)
assert.equal(
Object.prototype.hasOwnProperty.call(responseBody.body.data[0], 'borrower_identifiers'),
false
)
} finally {
;(prisma.equipment.findMany as any) = originalFindMany
}
})

View File

@ -0,0 +1,503 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { returnEquipment, returnEquipmentBeforeDisposal } from '../borrowController'
const now = new Date('2026-07-14T06:00:00.000Z')
const root = {
id: 50,
equipment_code: 'JS-PC-0050',
equipment_name: '双数量主机',
parent_id: null,
department_id: 7,
storage_location_id: 1,
status: '可用',
mount_status: 'none',
total_quantity: 2,
remaining_quantity: 1,
unit_price: 1000,
updated_at: now,
storageLocation: null
}
const child = {
id: 51,
equipment_code: 'JS-RAM-0051',
equipment_name: '挂载内存',
parent_id: root.id,
department_id: 7,
storage_location_id: 1,
status: '已借出',
mount_status: 'mounted',
total_quantity: 1,
remaining_quantity: 0,
unit_price: 100,
updated_at: now
}
test('单笔归还省略部门动作或显式 clear 均按入仓规则清空部门', async () => {
const originalEquipmentFindUnique = prisma.equipment.findUnique
const originalBorrowFindFirst = prisma.borrowRecord.findFirst
const originalTransaction = prisma.$transaction
const borrowRecord = {
id: 501,
equipment_id: root.id,
person_id: 5,
status: '已借出',
children_snapshot: JSON.stringify([{ id: child.id, parent_id: root.id }])
}
const rootUpdates: any[] = []
const childUpdates: any[] = []
;(prisma.equipment.findUnique as any) = async () => root
;(prisma.borrowRecord.findFirst as any) = async () => borrowRecord
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) return []
if (query.sql.includes('WHERE equipment.id = ?')) return [root]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return query.values.includes(root.id) ? [child] : []
}
return []
},
equipment: {
findUnique: async () => root,
findMany: async (args: any) => {
const parentIds = args.where.parent_id.in as number[]
return parentIds.includes(root.id) ? [child] : []
},
updateMany: async (args: any) => {
rootUpdates.push(args)
return { count: 1 }
},
update: async (args: any) => {
childUpdates.push(args)
return child
}
},
borrowRecord: {
updateMany: async (args: any) => {
if (args.where.id !== borrowRecord.id ||
args.where.equipment_id !== root.id ||
borrowRecord.status !== args.where.status) {
return { count: 0 }
}
borrowRecord.status = args.data.status
return { count: 1 }
}
},
department: { findUnique: async () => ({ name: '原部门' }) },
assetTransferRecord: { create: async () => ({ id: 1, items: [] }) }
})
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 6, role: 'it', name: '测试 IT' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
for (const contractInput of [{}, { department_action: 'clear' }]) {
borrowRecord.status = '已借出'
state.statusCode = 200
state.body = null
await returnEquipment(
{
body: {
equipment_id: root.id,
borrow_record_id: borrowRecord.id,
return_condition: '正常',
...contractInput
}
} as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(borrowRecord.status, '已归还')
}
assert.equal(rootUpdates.length, 2)
for (const update of rootUpdates) {
assert.equal(update.data.remaining_quantity, 2)
assert.equal(update.data.status, '可用')
assert.equal(update.data.department_id, null)
}
assert.equal(childUpdates.length, 2)
for (const update of childUpdates) {
assert.equal(update.data.status, '可用')
assert.equal(update.data.remaining_quantity, 1)
assert.equal(update.data.department_id, null)
}
} finally {
;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique
;(prisma.borrowRecord.findFirst as any) = originalBorrowFindFirst
;(prisma.$transaction as any) = originalTransaction
}
})
test('单笔归还接口拒绝 keep、set 或单独 department_id,且不访问资产', async () => {
const originalEquipmentFindUnique = prisma.equipment.findUnique
let equipmentLookupCount = 0
;(prisma.equipment.findUnique as any) = async () => {
equipmentLookupCount += 1
return root
}
try {
for (const invalidInput of [
{ department_action: 'keep' },
{ department_action: 'set', department_id: 8 },
{ department_id: 8 }
]) {
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 6, role: 'it' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
await returnEquipment(
{
body: {
equipment_id: root.id,
borrow_record_id: 501,
...invalidInput
}
} as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /归还入仓统一清空部门/)
}
assert.equal(equipmentLookupCount, 0)
} finally {
;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique
}
})
test('行政可在报废前将唯一未归还资产快捷入库,并保留正式流转记录', async () => {
const originalTransaction = prisma.$transaction
const borrowedRoot = {
...root,
equipment_name: '待报废显示器',
status: '已借出',
total_quantity: 1,
remaining_quantity: 0
}
const borrowRecord = {
id: 601,
equipment_id: borrowedRoot.id,
person_id: 15,
status: '已借出',
children_snapshot: null,
updated_at: now
}
const rootUpdates: any[] = []
const movementReasons: string[] = []
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql)
if (sql.includes('FROM borrow_record')) return [borrowRecord]
if (sql.startsWith('SELECT id FROM equipment')) return []
if (sql.includes('WHERE equipment.id =')) return [borrowedRoot]
if (sql.includes('WHERE equipment.parent_id IN')) return []
return []
},
equipment: {
findUnique: async () => borrowedRoot,
findMany: async () => [],
updateMany: async (args: any) => {
rootUpdates.push(args)
return { count: 1 }
},
update: async () => borrowedRoot
},
borrowRecord: {
updateMany: async (args: any) => {
if (args.where.id !== borrowRecord.id || borrowRecord.status !== args.where.status) {
return { count: 0 }
}
borrowRecord.status = args.data.status
return { count: 1 }
}
},
department: { findUnique: async () => ({ name: '领用部门' }) },
assetTransferRecord: {
create: async (args: any) => {
movementReasons.push(args.data.reason)
return { id: 2, items: [] }
}
}
})
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'administrative', name: '行政人员' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await returnEquipmentBeforeDisposal(
{
params: { equipment_id: String(borrowedRoot.id) },
body: { return_condition: '损坏' }
} as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(state.body.message, '资产已入库,可以继续申请报废')
assert.equal(state.body.data.return_condition, '损坏')
assert.equal(borrowRecord.status, '已归还')
assert.equal(rootUpdates.at(-1)?.data.status, '已损坏')
assert.equal(rootUpdates.at(-1)?.data.department_id, null)
assert.deepEqual(movementReasons, ['报废前快捷入库'])
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('报废前快捷入库遇到多条未归还记录时拒绝自动处理', async () => {
const originalTransaction = prisma.$transaction
const borrowedRoot = { ...root, status: '已借出', total_quantity: 1, remaining_quantity: 0 }
let transactionCount = 0
let businessWriteCount = 0
const activeRecords = [
{ id: 701, equipment_id: root.id, person_id: 1, status: '已借出', children_snapshot: null, updated_at: now },
{ id: 702, equipment_id: root.id, person_id: 2, status: '已借出', children_snapshot: null, updated_at: now }
]
;(prisma.$transaction as any) = async (callback: any) => {
transactionCount += 1
return callback({
$queryRaw: async (query: any) => {
const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql)
if (sql.includes('FROM borrow_record')) return activeRecords
if (sql.startsWith('SELECT id FROM equipment')) return []
if (sql.includes('WHERE equipment.id =')) return [borrowedRoot]
if (sql.includes('WHERE equipment.parent_id IN')) return []
return []
},
equipment: {
findUnique: async () => borrowedRoot,
findMany: async () => [],
updateMany: async () => { businessWriteCount += 1; return { count: 1 } },
update: async () => { businessWriteCount += 1; return borrowedRoot }
},
borrowRecord: {
updateMany: async () => { businessWriteCount += 1; return { count: 1 } }
},
department: { findUnique: async () => ({ name: '领用部门' }) },
assetTransferRecord: {
create: async () => { businessWriteCount += 1; return { id: 2, items: [] } }
}
})
}
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'administrative' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await returnEquipmentBeforeDisposal(
{ params: { equipment_id: String(root.id) }, body: {} } as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /多条未归还记录/)
assert.equal(transactionCount, 1)
assert.equal(businessWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('报废前快捷入库拒绝未收回或多数量资产,且不会产生业务写入', async () => {
const originalTransaction = prisma.$transaction
let transactionCount = 0
let businessWriteCount = 0
const multiQuantityRoot = {
...root,
status: '已借出',
total_quantity: 2,
remaining_quantity: 1
}
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'administrative' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
;(prisma.$transaction as any) = async (callback: any) => {
transactionCount += 1
return callback({
$queryRaw: async (query: any) => {
const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql)
if (sql.startsWith('SELECT id FROM equipment')) return []
if (sql.includes('WHERE equipment.id =')) return [multiQuantityRoot]
if (sql.includes('WHERE equipment.parent_id IN')) return []
return []
},
equipment: {
findUnique: async () => multiQuantityRoot,
findMany: async () => [],
updateMany: async () => { businessWriteCount += 1; return { count: 1 } },
update: async () => { businessWriteCount += 1; return multiQuantityRoot }
},
borrowRecord: { updateMany: async () => { businessWriteCount += 1; return { count: 1 } } },
department: { findUnique: async () => ({ name: '领用部门' }) },
assetTransferRecord: {
create: async () => { businessWriteCount += 1; return { id: 2, items: [] } }
}
})
}
try {
await returnEquipmentBeforeDisposal(
{ params: { equipment_id: String(root.id) }, body: { return_condition: '丢失' } } as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /正常或损坏资产/)
state.statusCode = 200
state.body = null
await returnEquipmentBeforeDisposal(
{ params: { equipment_id: String(root.id) }, body: { return_condition: '正常' } } as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /仅支持单件资产/)
assert.equal(transactionCount, 1)
assert.equal(businessWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('借出时无配件但当前新增配件时进入版本确认,配件再变后旧确认不会入库', async () => {
const originalTransaction = prisma.$transaction
const borrowedRoot = { ...root, status: '已借出', total_quantity: 1, remaining_quantity: 0 }
let currentChild = { ...child, id: 802, equipment_code: 'JS-RAM-0802', parent_id: borrowedRoot.id }
const borrowRecord = {
id: 803,
equipment_id: borrowedRoot.id,
person_id: 15,
children_snapshot: null,
updated_at: now
}
let businessWriteCount = 0
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql)
if (sql.includes('FROM borrow_record')) return [borrowRecord]
if (sql.startsWith('SELECT id FROM equipment')) return []
if (sql.includes('WHERE equipment.id =')) return [borrowedRoot]
if (sql.includes('WHERE equipment.parent_id IN')) {
return query.values.includes(borrowedRoot.id) ? [currentChild] : []
}
return []
},
equipment: {
findUnique: async () => borrowedRoot,
findMany: async (args: any) => {
const parentIds = args.where.parent_id.in as number[]
return parentIds.includes(borrowedRoot.id) ? [currentChild] : []
},
updateMany: async () => { businessWriteCount += 1; return { count: 1 } },
update: async () => { businessWriteCount += 1; return currentChild }
},
borrowRecord: { updateMany: async () => { businessWriteCount += 1; return { count: 1 } } },
department: { findUnique: async () => ({ name: '领用部门' }) },
assetTransferRecord: {
create: async () => { businessWriteCount += 1; return { id: 2, items: [] } }
}
})
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'administrative' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await returnEquipmentBeforeDisposal(
{ params: { equipment_id: String(root.id) }, body: { return_condition: '正常' } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(state.body.data.need_confirm, true)
const firstToken = state.body.data.confirmation_token
assert.match(firstToken, /^[a-f0-9]{64}$/)
assert.equal(businessWriteCount, 0)
currentChild = {
...currentChild,
id: 804,
equipment_code: 'JS-RAM-0804',
updated_at: new Date(now.getTime() + 1000)
}
state.body = null
await returnEquipmentBeforeDisposal(
{
params: { equipment_id: String(root.id) },
body: { return_condition: '正常', confirmation_token: firstToken }
} as any,
res as any
)
assert.equal(state.body.data.need_confirm, true)
assert.notEqual(state.body.data.confirmation_token, firstToken)
assert.equal(businessWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})

View File

@ -0,0 +1,129 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { returnEquipment, scanReturn, scanReturnBatch } from '../borrowController'
import { batchReturn } from '../batchController'
const now = new Date('2026-10-09T02:00:00Z')
const root = {
id: 10, equipment_code: 'ROOT', equipment_name: '主机', equipment_model: null,
equipment_type: '电脑', parent_id: null, department_id: 3, storage_location_id: null,
status: '已借出', mount_status: 'none', total_quantity: 1, remaining_quantity: 0,
unit_price: 100, updated_at: now
}
const child = { ...root, id: 11, equipment_code: 'CHILD', parent_id: 10, mount_status: 'mounted', unit_price: 20 }
const record = { id: 5, equipment_id: 10, equipment_code: 'ROOT', person_id: 8, status: '已借出', children_snapshot: null, updated_at: now }
const run = async (handler: any, tree: any[], snapshot: string | null, body: Record<string, unknown>) => {
const originals = [prisma.equipment.findUnique, prisma.borrowRecord.findFirst, prisma.borrowRecord.findMany, prisma.$transaction, prisma.operationLog.create]
const borrowed: any = { ...record, children_snapshot: snapshot }
let committed = tree.map((item) => ({ ...item }))
const movements: any[] = []
let writes = 0
;(prisma.equipment.findUnique as any) = async () => ({ ...committed[0] })
;(prisma.borrowRecord.findFirst as any) = async () => ({ ...borrowed })
;(prisma.borrowRecord.findMany as any) = async () => [{ ...borrowed }]
;(prisma.operationLog.create as any) = async () => ({})
;(prisma.$transaction as any) = async (callback: any) => {
const working = committed.map((item) => ({ ...item }))
const tx = {
$queryRaw: async (query: any) => {
if (query.sql.includes('WHERE equipment.id = ?')) return working.filter((item) => item.id === query.values[0])
if (query.sql.includes('WHERE equipment.parent_id IN')) return working.filter((item) => query.values.includes(item.parent_id))
return []
},
equipment: {
findUnique: async ({ where }: any) => ({ ...working.find((item) => item.id === where.id) }),
findMany: async ({ where }: any) => working.filter((item) => where.parent_id.in.includes(item.parent_id)).map((item) => ({ ...item })),
updateMany: async ({ where, data }: any) => { writes++; Object.assign(working.find((item) => item.id === where.id)!, data); return { count: 1 } },
update: async ({ where, data }: any) => { writes++; Object.assign(working.find((item) => item.id === where.id)!, data); return {} }
},
department: { findUnique: async () => ({ name: '部门' }) },
borrowRecord: { updateMany: async ({ data }: any) => { writes++; Object.assign(borrowed, data); return { count: 1 } } },
assetTransferRecord: { create: async ({ data }: any) => { movements.push(data); return { id: 1, items: [] } } }
}
const result = await callback(tx)
committed = working
return result
}
const state = { code: 200, body: null as any }
const res = {
locals: { user: { id: 9, role: 'it', username: 'it' } },
status(code: number) { state.code = code; return this },
json(payload: any) { state.body = payload; return this }
}
try {
await handler({ body: { equipment_id: 10, equipment_code: 'ROOT', equipment_ids: [10], equipment_codes: ['ROOT'], borrow_record_id: 5, ...body }, ip: 'mock' }, res)
return { state, committed, borrowed, movements, writes }
} finally {
;[prisma.equipment.findUnique, prisma.borrowRecord.findFirst, prisma.borrowRecord.findMany, prisma.$transaction, prisma.operationLog.create] = originals as any
}
}
test('single, scan and batch damaged/lost returns preserve physical stock and never create available value', async () => {
for (const handler of [returnEquipment, scanReturn, scanReturnBatch, batchReturn]) {
for (const condition of ['损坏', '丢失']) {
const result = await run(handler, [root], null, { return_condition: condition })
assert.equal(result.state.code, 200)
if (handler === scanReturnBatch || handler === batchReturn) assert.equal(result.state.body.data.success_count, 1)
assert.equal(result.borrowed.status, '已归还')
assert.equal(result.borrowed.return_condition, condition)
assert.equal(result.committed[0].status, condition === '损坏' ? '已损坏' : '已丢失')
assert.equal(result.committed[0].remaining_quantity, condition === '损坏' ? 1 : 0)
assert.equal(result.committed[0].available_value, 0)
assert.equal(result.committed[0].borrowed_value, 0)
assert.equal(result.movements[0].action_type, condition === '丢失' ? 'loss' : 'return')
if (condition === '丢失') assert.match(result.movements[0].reason, /未入仓/)
}
}
})
test('non-normal return of multi-quantity asset preserves active borrow and inventory with explicit conflict', async () => {
for (const handler of [returnEquipment, scanReturn, scanReturnBatch, batchReturn]) {
for (const condition of ['损坏', '丢失']) {
const multi = { ...root, total_quantity: 2 }
const result = await run(handler, [multi], null, { return_condition: condition })
if (handler === returnEquipment || handler === scanReturn) {
assert.equal(result.state.code, 409)
assert.match(result.state.body.message, /多数量.*原借用单保持未归还/)
} else {
assert.equal(result.state.body.data.failed_count, 1)
assert.match(result.state.body.data.results[0].message, /多数量/)
}
assert.equal(result.borrowed.status, '已借出')
assert.deepEqual(result.committed, [multi])
assert.equal(result.writes, 0)
assert.equal(result.movements.length, 0)
}
}
})
test('empty borrowed-child snapshot detects new child; boolean or stale token cannot bypass confirmation', async () => {
for (const handler of [returnEquipment, scanReturn]) {
const first = await run(handler, [root, child], null, { return_condition: '正常', confirm_children_diff: true })
assert.equal(first.state.body.data.need_confirm, true)
assert.equal(first.state.body.data.children_diff.added[0].id, child.id)
assert.equal(first.writes, 0)
assert.equal(first.movements.length, 0)
const token = first.state.body.data.confirmation_token
const changed = await run(handler, [root, { ...child, updated_at: new Date(now.getTime() + 1000) }], null, { confirmation_token: token })
assert.equal(changed.state.body.data.need_confirm, true)
assert.equal(changed.writes, 0)
assert.notEqual(changed.state.body.data.confirmation_token, token)
const accepted = await run(handler, [root, child], null, { confirmation_token: token, return_condition: '损坏' })
assert.equal(accepted.borrowed.status, '已归还')
assert.deepEqual(accepted.committed.map((item) => item.status), ['已损坏', '已损坏'])
assert.ok(accepted.committed.every((item) => item.available_value === 0))
}
})
test('batch paths reject added child without writes and direct operator to single return', async () => {
for (const handler of [scanReturnBatch, batchReturn]) {
const result = await run(handler, [root, child], null, { return_condition: '正常' })
assert.equal(result.state.body.data.failed_count, 1)
assert.match(result.state.body.data.results[0].message, /单个.*归还确认/)
assert.equal(result.writes, 0)
assert.equal(result.movements.length, 0)
assert.equal(result.borrowed.status, '已借出')
}
})

View File

@ -0,0 +1,235 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { loginWithYouyidianTicket } from '../dingtalkController'
const originalFetch = global.fetch
const originalFindFirst = prisma.user.findFirst
const originalFindUnique = prisma.user.findUnique
const originalUserUpdate = prisma.user.update
const originalPersonFindUnique = prisma.person.findUnique
const envKeys = [
'YYD_NOTIFICATION_CENTER_URL',
'YYD_NOTIFICATION_PROJECT_CODE',
'YYD_NOTIFICATION_PROJECT_SECRET',
'YYD_NOTIFICATION_LOGIN_ENABLED',
'YYD_NOTIFICATION_LOGIN_TEST_USER_IDS',
'YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT'
] as const
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]))
const configureLogin = () => {
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://notify.example.com'
process.env.YYD_NOTIFICATION_PROJECT_CODE = 'fixed_asset'
process.env.YYD_NOTIFICATION_PROJECT_SECRET = '123456789012345678901234'
process.env.YYD_NOTIFICATION_LOGIN_ENABLED = 'true'
process.env.YYD_NOTIFICATION_LOGIN_TEST_USER_IDS = 'ding-user-1'
process.env.YYD_NOTIFICATION_LOGIN_FULL_ROLLOUT = 'false'
}
const createRequest = (ticket = 'A'.repeat(43)) => {
const session: any = {
regenerate(callback: (error?: Error) => void) {
callback()
},
save(callback: (error?: Error) => void) {
callback()
}
}
return { body: { ticket }, session }
}
const createResponse = () => {
const state = {
statusCode: 200,
headers: {} as Record<string, string>,
type: '',
body: '',
redirect: null as null | { status: number; location: string }
}
const res = {
set(headers: Record<string, string>) {
Object.assign(state.headers, headers)
return this
},
status(code: number) {
state.statusCode = code
return this
},
type(value: string) {
state.type = value
return this
},
send(value: string) {
state.body = value
return this
},
redirect(status: number, location: string) {
state.statusCode = status
state.redirect = { status, location }
return this
}
}
return { res, state }
}
test.after(() => {
global.fetch = originalFetch
;(prisma.user.findFirst as any) = originalFindFirst
;(prisma.user.findUnique as any) = originalFindUnique
;(prisma.user.update as any) = originalUserUpdate
;(prisma.person.findUnique as any) = originalPersonFindUnique
for (const key of envKeys) {
if (originalEnv[key] === undefined) delete process.env[key]
else process.env[key] = originalEnv[key]
}
})
;(prisma.user.update as any) = async (args: any) => ({ id: args.where.id })
;(prisma.person.findUnique as any) = async () => null
test('一次性票据兑换成功后建立绑定userid的会话并303进入内部完成页', async () => {
configureLogin()
let capturedUrl = ''
global.fetch = (async (url: string | URL | Request) => {
capturedUrl = String(url)
return new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: '/workflows/disposals/53'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
}) as typeof fetch
;(prisma.user.findFirst as any) = async () => ({ id: 3 })
;(prisma.user.findUnique as any) = async () => ({
id: 3,
username: 'magic-red',
name: '魔礼红',
email: null,
phone: null,
role: 'admin',
status: 'active',
person_id: 108,
person: {
id: 108,
real_name: '吴永杰',
nickname: '魔礼红',
status: 'active',
source_employee_status: '3'
}
})
const req = createRequest()
const { res, state } = createResponse()
await loginWithYouyidianTicket(req as any, res as any)
assert.deepEqual(req.session.user, {
id: 3,
auth_source: 'dingtalk',
dingtalk_userid: 'ding-user-1'
})
assert.deepEqual(state.redirect, {
status: 303,
location: '/dingtalk-entry?broker=complete&redirect=%2Fworkflows%2Fdisposals%2F53'
})
assert.equal(state.headers['Cache-Control'], 'no-store')
assert.equal(capturedUrl, 'https://notify.example.com/v1/auth/tickets/redeem')
assert.equal(capturedUrl.includes('A'.repeat(43)), false)
})
test('票据用户不在启用花名册时不建立会话', async () => {
configureLogin()
global.fetch = (async () => new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: '/dashboard'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch
let userLookups = 0
;(prisma.user.findFirst as any) = async () => {
userLookups += 1
return null
}
const req = createRequest('B'.repeat(43))
const { res, state } = createResponse()
await loginWithYouyidianTicket(req as any, res as any)
assert.equal(userLookups, 1)
assert.equal(req.session.user, undefined)
assert.equal(state.statusCode, 403)
assert.match(state.body, /未匹配到启用且在职/)
})
test('通知中心返回外部跳转地址时按服务异常拒绝建立会话', async () => {
configureLogin()
global.fetch = (async () => new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: 'https://evil.example/path'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch
const req = createRequest('C'.repeat(43))
const { res, state } = createResponse()
await loginWithYouyidianTicket(req as any, res as any)
assert.equal(req.session.user, undefined)
assert.equal(state.statusCode, 502)
assert.match(state.body, /服务暂时异常/)
})
test('通知中心服务端异常不会误报成用户票据无效', async () => {
configureLogin()
global.fetch = (async () => new Response(JSON.stringify({ error: 'INTERNAL_ERROR' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})) as typeof fetch
const req = createRequest('D'.repeat(43))
const { res, state } = createResponse()
await loginWithYouyidianTicket(req as any, res as any)
assert.equal(req.session.user, undefined)
assert.equal(state.statusCode, 502)
assert.match(state.body, /服务暂时异常/)
assert.doesNotMatch(state.body, /票据无效/)
})
test('票据兑换遇到nonce重放时使用新nonce立即重试一次', async () => {
configureLogin()
let calls = 0
global.fetch = (async () => {
calls += 1
if (calls === 1) {
return new Response(JSON.stringify({ error: 'NONCE_REPLAYED' }), {
status: 409,
headers: { 'Content-Type': 'application/json' }
})
}
return new Response(JSON.stringify({
dingtalkUserId: 'ding-user-1',
redirectPath: '/dashboard'
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
}) as typeof fetch
;(prisma.user.findFirst as any) = async () => ({ id: 3 })
;(prisma.user.findUnique as any) = async () => ({
id: 3,
username: 'magic-red',
name: '魔礼红',
email: null,
phone: null,
role: 'admin',
status: 'active',
person_id: 108,
person: {
id: 108,
real_name: '吴永杰',
nickname: '魔礼红',
status: 'active',
source_employee_status: '3',
source_userid: 'ding-user-1'
}
})
const req = createRequest('E'.repeat(43))
const { res, state } = createResponse()
await loginWithYouyidianTicket(req as any, res as any)
assert.equal(calls, 2)
assert.equal(state.statusCode, 303)
})

View File

@ -0,0 +1,263 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { retryDingTalkDelivery } from '../dingtalkController'
const createResponse = () => {
const state = { status: 200, body: null as any }
const response = {
locals: { user: { id: 99 } },
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
return { response, state }
}
test('人工补发必须由管理员显式确认', async () => {
const originalFindUnique = prisma.dingTalkDelivery.findUnique
let lookupCount = 0
;(prisma.dingTalkDelivery.findUnique as any) = async () => {
lookupCount += 1
return null
}
try {
const { response, state } = createResponse()
await retryDingTalkDelivery({ params: { id: '1' }, body: {} } as any, response as any)
assert.equal(state.status, 422)
assert.match(state.body.message, /必须明确确认/)
assert.equal(lookupCount, 0)
} finally {
;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique
}
})
test('历史工作通知只读,不能通过统一通知中心重新补发', async () => {
const originalFindUnique = prisma.dingTalkDelivery.findUnique
;(prisma.dingTalkDelivery.findUnique as any) = async () => ({
id: 1,
status: 'failed',
provider_type: 'work_notice',
recipient: { status: 'active', person: { status: 'active', source_userid: 'ding-user-1' } }
})
try {
const { response, state } = createResponse()
await retryDingTalkDelivery({
params: { id: '1' },
body: { confirmed: true }
} as any, response as any)
assert.equal(state.status, 409)
assert.match(state.body.message, /历史工作通知仅供审计/)
} finally {
;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique
}
})
const failedDelivery = (overrides: Record<string, unknown> = {}) => ({
id: 5,
status: 'failed',
provider_type: 'notification_center',
event_type: 'disposal_applied',
business_type: 'disposal',
business_id: 53,
recipient_user_id: 7,
recipient_dingtalk_userid: 'ding-user-7',
idempotency_key: 'disposal_applied:53:ding-user-7:v1',
payload_json: JSON.stringify({
title: '资产报废待审批',
content: '### 待你审批',
action_title: '立即审批',
action_url: '/workflows/disposals/53'
}),
attempt_count: 5,
max_attempts: 5,
last_error_code: 'TIMEOUT',
last_error_message: '响应超时',
dingtalk_task_id: null,
recipient: { status: 'active', role: 'administrative', person: { status: 'active', source_userid: 'ding-user-7' } },
...overrides
})
test('未取得中心任务ID时复用原行和原幂等键恢复投递', async () => {
const originalFindUnique = prisma.dingTalkDelivery.findUnique
const originalUpdateMany = prisma.dingTalkDelivery.updateMany
const originalCreate = prisma.dingTalkDelivery.create
let updateArgs: any
let createCount = 0
;(prisma.dingTalkDelivery.findUnique as any) = async () => failedDelivery()
;(prisma.dingTalkDelivery.updateMany as any) = async (args: any) => {
updateArgs = args
return { count: 1 }
}
;(prisma.dingTalkDelivery.create as any) = async () => {
createCount += 1
return { id: 6 }
}
try {
const { response, state } = createResponse()
await retryDingTalkDelivery({
params: { id: '5' },
body: { confirmed: true }
} as any, response as any)
assert.equal(state.status, 200)
assert.equal(createCount, 0)
assert.equal(updateArgs.where.dingtalk_task_id, null)
assert.equal(updateArgs.data.status, 'pending')
assert.equal(updateArgs.data.failure_notified_at, null)
assert.equal(updateArgs.data.last_manual_action_by, 99)
assert.equal(updateArgs.data.last_manual_action_type, 'resume_submit')
const resumePayload = JSON.parse(updateArgs.data.payload_json)
assert.equal(resumePayload.notification_center_resume_count, 1)
assert.equal(resumePayload.notification_center_resume_history[0].operator_user_id, 99)
} finally {
;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique
;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany
;(prisma.dingTalkDelivery.create as any) = originalCreate
}
})
test('中心明确失败时保留旧行并创建幂等键一致的新补发记录', async () => {
const originalFindUnique = prisma.dingTalkDelivery.findUnique
const originalUpdateMany = prisma.dingTalkDelivery.updateMany
const originalCreate = prisma.dingTalkDelivery.create
let updateCount = 0
let createArgs: any
;(prisma.dingTalkDelivery.findUnique as any) = async () => failedDelivery({
dingtalk_task_id: 'center-task-5',
last_error_code: 'DELIVERY_OUTCOME_UNKNOWN',
payload_json: JSON.stringify({
title: '资产报废待审批',
content: '### 待你审批',
action_title: '立即审批',
action_url: '/workflows/disposals/53',
notification_center_last_status: 'failed'
})
})
;(prisma.dingTalkDelivery.updateMany as any) = async () => {
updateCount += 1
return { count: 1 }
}
;(prisma.dingTalkDelivery.create as any) = async (args: any) => {
createArgs = args
return { id: 6 }
}
try {
const { response, state } = createResponse()
await retryDingTalkDelivery({
params: { id: '5' },
body: { confirmed: true }
} as any, response as any)
assert.equal(state.status, 200)
assert.equal(updateCount, 0)
assert.equal(createArgs.data.idempotency_key, 'disposal_applied:53:ding-user-7:legacy:manual-v1')
assert.equal(createArgs.data.retry_of_delivery_id, 5)
assert.equal(createArgs.data.manual_created_by, 99)
const manualPayload = JSON.parse(createArgs.data.payload_json)
assert.equal(manualPayload.notification_center_manual_retry_source_delivery_id, 5)
assert.equal(manualPayload.notification_center_manual_retry_by, 99)
assert.equal(
JSON.parse(createArgs.data.payload_json).notification_center_idempotency_key,
createArgs.data.idempotency_key
)
} finally {
;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique
;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany
;(prisma.dingTalkDelivery.create as any) = originalCreate
}
})
test('状态查询错误时恢复原中心任务回查且绝不新建补发消息', async () => {
const originalFindUnique = prisma.dingTalkDelivery.findUnique
const originalUpdateMany = prisma.dingTalkDelivery.updateMany
const originalCreate = prisma.dingTalkDelivery.create
let createCount = 0
let updateArgs: any
;(prisma.dingTalkDelivery.findUnique as any) = async () => failedDelivery({
dingtalk_task_id: 'center-task-5',
last_error_code: 'NETWORK',
payload_json: JSON.stringify({
title: '资产报废待审批',
content: '### 待你审批',
action_title: '立即审批',
action_url: '/workflows/disposals/53',
notification_center_status_query_error: 'NETWORK'
})
})
;(prisma.dingTalkDelivery.create as any) = async () => {
createCount += 1
return { id: 6 }
}
;(prisma.dingTalkDelivery.updateMany as any) = async (args: any) => {
updateArgs = args
return { count: 1 }
}
try {
const { response, state } = createResponse()
await retryDingTalkDelivery({
params: { id: '5' },
body: { confirmed: true }
} as any, response as any)
assert.equal(state.status, 200)
assert.match(state.body.message, /恢复原任务状态回查/)
assert.equal(createCount, 0)
assert.equal(updateArgs.where.dingtalk_task_id, 'center-task-5')
assert.equal(updateArgs.data.status, 'pending')
assert.equal(updateArgs.data.failure_notified_at, null)
assert.equal(updateArgs.data.last_manual_action_by, 99)
assert.equal(updateArgs.data.last_manual_action_type, 'resume_status_query')
assert.equal(
JSON.parse(updateArgs.data.payload_json).notification_center_status_query_error,
null
)
assert.equal(
JSON.parse(updateArgs.data.payload_json).notification_center_query_resume_history[0].operator_user_id,
99
)
} finally {
;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique
;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany
;(prisma.dingTalkDelivery.create as any) = originalCreate
}
})
test('待审批通知收件人已降为普通用户时禁止人工恢复或补发', async () => {
const originalFindUnique = prisma.dingTalkDelivery.findUnique
const originalUpdateMany = prisma.dingTalkDelivery.updateMany
const originalCreate = prisma.dingTalkDelivery.create
let writeCount = 0
;(prisma.dingTalkDelivery.findUnique as any) = async () => failedDelivery({
recipient: {
status: 'active',
role: 'user',
person: { status: 'active', source_userid: 'ding-user-7' }
}
})
;(prisma.dingTalkDelivery.updateMany as any) = async () => {
writeCount += 1
return { count: 1 }
}
;(prisma.dingTalkDelivery.create as any) = async () => {
writeCount += 1
return { id: 6 }
}
try {
const { response, state } = createResponse()
await retryDingTalkDelivery({
params: { id: '5' },
body: { confirmed: true }
} as any, response as any)
assert.equal(state.status, 422)
assert.match(state.body.message, /不具备报废审批权限/)
assert.equal(writeCount, 0)
} finally {
;(prisma.dingTalkDelivery.findUnique as any) = originalFindUnique
;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany
;(prisma.dingTalkDelivery.create as any) = originalCreate
}
})

View File

@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildDingTalkBrokerCompletionPath,
normalizeInternalRedirect
} from '../../utils/dingtalkRedirect'
test('钉钉免登只接受白名单内的系统路径', () => {
assert.equal(
normalizeInternalRedirect('/workflows/disposals/123?source=ding'),
'/workflows/disposals/123?source=ding'
)
assert.equal(normalizeInternalRedirect('/my-assets'), '/my-assets')
assert.equal(
normalizeInternalRedirect('/inventory/task/secure-public-token'),
'/inventory/task/secure-public-token'
)
})
test('钉钉免登拒绝外链、协议相对地址和非白名单路径', () => {
assert.throws(() => normalizeInternalRedirect('https://evil.example/path'), /允许范围/)
assert.throws(() => normalizeInternalRedirect('//evil.example/path'), /允许范围/)
assert.throws(() => normalizeInternalRedirect('/admin/secrets'), /允许范围/)
assert.throws(() => normalizeInternalRedirect('/workflows/disposals-evil'), /允许范围/)
})
test('票据兑换后只通过无敏感信息的内部完成页恢复前端会话', () => {
assert.equal(
buildDingTalkBrokerCompletionPath('/workflows/disposals/123?source=ding'),
'/dingtalk-entry?broker=complete&redirect=%2Fworkflows%2Fdisposals%2F123%3Fsource%3Dding'
)
assert.throws(
() => buildDingTalkBrokerCompletionPath('https://evil.example/path'),
/允许范围/
)
})

View File

@ -0,0 +1,42 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { getDingTalkDeliveryStatus } from '../dingtalkController'
test('统一通知概览不把历史work_notice记录计入当前待发送和失败数量', async () => {
const originalCount = prisma.dingTalkDelivery.count
const originalGroupBy = prisma.dingTalkDelivery.groupBy
const countWhere: any[] = []
let groupWhere: any
;(prisma.dingTalkDelivery.count as any) = async (args: any) => {
countWhere.push(args.where)
return 0
}
;(prisma.dingTalkDelivery.groupBy as any) = async (args: any) => {
groupWhere = args.where
return []
}
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
try {
await getDingTalkDeliveryStatus({} as any, response as any)
assert.equal(state.status, 200)
assert.equal(countWhere.length, 5)
assert.ok(countWhere.every((where) => where.provider_type === 'notification_center'))
assert.equal(groupWhere.provider_type, 'notification_center')
} finally {
;(prisma.dingTalkDelivery.count as any) = originalCount
;(prisma.dingTalkDelivery.groupBy as any) = originalGroupBy
}
})

View File

@ -0,0 +1,562 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import {
approveDisposal,
buildDisposalAppliedDingTalkContent,
buildDisposalApprovedDingTalkContent,
buildDisposalCompletedDingTalkContent,
buildDisposalRejectedDingTalkContent,
createDisposal,
getDisposalDetail,
getDisposalList
} from '../disposalController'
const now = new Date('2026-07-14T03:00:00.000Z')
const rootEquipment = {
id: 10,
equipment_code: 'JS-PC-0010',
equipment_name: '整机',
parent_id: null,
department_id: 3,
storage_location_id: 1,
status: '可用',
mount_status: 'none',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 1000,
updated_at: now
}
const childEquipment = {
id: 11,
equipment_code: 'JS-RAM-0011',
equipment_name: '内存',
parent_id: 10,
department_id: 3,
storage_location_id: 1,
status: '可用',
mount_status: 'mounted',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 200,
updated_at: now
}
const createResponse = (role = 'it') => {
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 6, role, name: '测试用户', username: 'tester' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
return { res, state }
}
test('已丢失主资产或配件不能申请报废,写入前拒绝且不改待报废状态', async () => {
const originalTransaction = prisma.$transaction
try {
for (const rootIsLost of [true, false]) {
const root = rootIsLost ? { ...rootEquipment, status: '已丢失', remaining_quantity: 0 } : rootEquipment
const child = rootIsLost ? childEquipment : { ...childEquipment, status: '已丢失', remaining_quantity: 0 }
let writes = 0
const children = (ids: number[]) => ids.includes(root.id) ? [child] : []
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => query.sql.includes('WHERE equipment.id = ?') ? [root]
: query.sql.includes('WHERE equipment.parent_id IN') ? children(query.values) : [],
equipment: {
findUnique: async () => root,
findMany: async (args: any) => children(args.where.parent_id.in),
updateMany: async () => { writes++; return { count: 1 } }
},
disposalRecord: { create: async () => { writes++; return { id: 1 } } }
})
const { res, state } = createResponse()
await createDisposal({ body: { equipment_id: root.id, item_ids: [root.id, child.id], disposal_reason: '丢失后报废', disposal_method: '报废' } } as any, res as any)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /已丢失.*不能申请报废/)
assert.equal(writes, 0)
}
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('报废流程钉钉正文不重复项目标题、时间优先且字段间保留空行', () => {
const cases = [
{
body: buildDisposalAppliedDingTalkContent({
appliedAt: now,
equipmentName: '整机',
equipmentCode: 'JS-PC-0010',
applicantName: '申请人',
reason: '设备老化'
}),
labels: ['申请时间', '资产', '申请人', '报废原因']
},
{
body: buildDisposalApprovedDingTalkContent({
approvedAt: now,
equipmentName: '整机',
equipmentCode: 'JS-PC-0010',
approverName: '审批人'
}),
labels: ['审批时间', '资产', '审批人', '审批结果']
},
{
body: buildDisposalRejectedDingTalkContent({
rejectedAt: now,
equipmentName: '整机',
equipmentCode: 'JS-PC-0010',
rejectorName: '驳回人',
reason: '尚可继续使用'
}),
labels: ['驳回时间', '资产', '驳回人', '驳回原因']
},
{
body: buildDisposalCompletedDingTalkContent({
completedAt: now,
equipmentName: '整机',
equipmentCode: 'JS-PC-0010',
completerName: '完成人',
notes: '已环保处置'
}),
labels: ['完成时间', '资产', '完成人', '处置结果', '完成说明']
}
]
for (const item of cases) {
assert.doesNotMatch(item.body, /(^|\n)###\s/)
assert.doesNotMatch(item.body, /【固定资产】/)
const paragraphs = item.body.split('\n\n')
assert.equal(paragraphs.length, item.labels.length + 1)
item.labels.forEach((label, index) => {
assert.match(paragraphs[index], new RegExp(`^\\*\\*${label}:\\*\\* `))
assert.doesNotMatch(paragraphs[index], /\n/)
})
assert.match(paragraphs.at(-1) || '', /^> /)
}
})
test('主资产仍挂载未勾选配件时申请报废返回 409 且不写状态', async () => {
const originalTransaction = prisma.$transaction
let stateWriteCount = 0
let disposalCreateCount = 0
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
if (query.sql.includes('WHERE equipment.id = ?')) return [rootEquipment]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return query.values.includes(rootEquipment.id) ? [childEquipment] : []
}
return []
},
equipment: {
findUnique: async () => rootEquipment,
findMany: async (args: any) => {
const parentIds = args.where.parent_id.in as number[]
return parentIds.includes(10) ? [childEquipment] : []
},
updateMany: async () => {
stateWriteCount += 1
return { count: 1 }
}
},
disposalRecord: {
create: async () => {
disposalCreateCount += 1
return { id: 1 }
}
}
})
try {
const { res, state } = createResponse()
await createDisposal(
{
params: {},
body: {
equipment_id: 10,
item_ids: [10],
disposal_reason: '设备老化',
disposal_method: '报废',
estimated_value: 0
}
} as any,
res as any
)
assert.equal(state.statusCode, 409)
assert.match(state.body.message, /请先卸载未勾选配件/)
assert.equal(disposalCreateCount, 0)
assert.equal(stateWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('初读资产树后并发挂载新配件时报废申请中止且不写数据', async () => {
const originalTransaction = prisma.$transaction
let mountedAfterInitialRead = false
let stateWriteCount = 0
let disposalCreateCount = 0
const lockQueries: any[] = []
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) {
lockQueries.push(query)
mountedAfterInitialRead = true
return []
}
if (query.sql.includes('WHERE equipment.id = ?')) return [rootEquipment]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return mountedAfterInitialRead && query.values.includes(rootEquipment.id)
? [childEquipment]
: []
}
return []
},
equipment: {
findUnique: async () => rootEquipment,
// 模拟 RR 旧快照:锁后再做 ORM 普通读仍会漏掉并发新配件。
findMany: async () => [],
updateMany: async () => {
stateWriteCount += 1
return { count: 1 }
}
},
disposalRecord: {
create: async () => {
disposalCreateCount += 1
return { id: 1 }
}
}
})
try {
const { res, state } = createResponse()
await createDisposal(
{
params: {},
body: {
equipment_id: rootEquipment.id,
item_ids: [rootEquipment.id],
disposal_reason: '设备老化',
disposal_method: '报废',
estimated_value: 0
}
} as any,
res as any
)
assert.equal(state.statusCode, 409)
assert.match(state.body.message, /资产树状态或挂载关系已变化/)
assert.match(lockQueries[0].sql, /IN \(\?\) ORDER BY id FOR UPDATE/)
assert.deepEqual(lockQueries[0].values, [10])
assert.equal(disposalCreateCount, 0)
assert.equal(stateWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('并发审批 CAS 失败时返回 409,不追加动作也不改资产', async () => {
const originalTransaction = prisma.$transaction
let actionCount = 0
let equipmentWriteCount = 0
const disposal = {
id: 91,
equipment_id: 10,
equipment_code: rootEquipment.equipment_code,
applied_by: 4,
status: 'pending',
items: [{
equipment_id: 10,
equipment_code: rootEquipment.equipment_code,
parent_equipment_id: null,
pre_status: '可用',
equipment: { ...rootEquipment, status: '待报废' }
}]
}
;(prisma.$transaction as any) = async (callback: any) => callback({
disposalRecord: {
findUnique: async () => disposal,
updateMany: async () => ({ count: 0 })
},
disposalAction: {
create: async () => {
actionCount += 1
}
},
equipment: {
updateMany: async () => {
equipmentWriteCount += 1
return { count: 1 }
}
}
})
try {
const { res, state } = createResponse('administrative')
await approveDisposal(
{ params: { id: '91' }, body: { approved: true } } as any,
res as any
)
assert.equal(state.statusCode, 409)
assert.equal(actionCount, 0)
assert.equal(equipmentWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('行政可以审批自己发起的报废申请', async () => {
const originalTransaction = prisma.$transaction
const originalLogCreate = prisma.operationLog.create
let updatedData: Record<string, any> | undefined
let actionData: Record<string, any> | undefined
;(prisma.$transaction as any) = async (callback: any) => callback({
disposalRecord: {
findUnique: async () => ({
id: 94,
equipment_id: 10,
equipment_code: rootEquipment.equipment_code,
applied_by: 6,
status: 'pending',
items: []
}),
updateMany: async (args: any) => {
updatedData = args.data
return { count: 1 }
}
},
disposalAction: {
create: async (args: any) => {
actionData = args.data
return { id: 1 }
}
},
user: { findMany: async () => [] }
})
;(prisma.operationLog.create as any) = async () => ({ id: 1 })
try {
const { res, state } = createResponse('administrative')
await approveDisposal(
{ params: { id: '94' }, body: { approved: true } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(state.body.message, '审批通过')
assert.equal(updatedData?.status, 'approved')
assert.equal(updatedData?.approved_by, 6)
assert.equal(actionData?.action_type, 'approved')
assert.equal(actionData?.operator_id, 6)
} finally {
;(prisma.$transaction as any) = originalTransaction
;(prisma.operationLog.create as any) = originalLogCreate
}
})
test('驳回报废时按每个 item.pre_status 分别恢复', async () => {
const originalTransaction = prisma.$transaction
const originalLogCreate = prisma.operationLog.create
const restoredStatuses: string[] = []
const disposal = {
id: 92,
equipment_id: 10,
equipment_code: rootEquipment.equipment_code,
applied_by: 4,
status: 'pending',
items: [
{
equipment_id: 10,
equipment_code: rootEquipment.equipment_code,
parent_equipment_id: null,
pre_status: '可用',
equipment: { ...rootEquipment, status: '待报废' }
},
{
equipment_id: 11,
equipment_code: childEquipment.equipment_code,
parent_equipment_id: 10,
pre_status: '已损坏',
equipment: { ...childEquipment, status: '待报废' }
}
]
}
;(prisma.$transaction as any) = async (callback: any) => callback({
disposalRecord: {
findUnique: async () => disposal,
updateMany: async () => ({ count: 1 })
},
equipment: {
updateMany: async (args: any) => {
restoredStatuses.push(args.data.status)
return { count: 1 }
}
},
disposalAction: { create: async () => ({ id: 1 }) },
user: { findMany: async () => [] }
})
;(prisma.operationLog.create as any) = async () => ({ id: 1 })
try {
const { res, state } = createResponse('administrative')
await approveDisposal(
{
params: { id: '92' },
body: { approved: false, reject_reason: '尚可继续使用' }
} as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.deepEqual(restoredStatuses, ['可用', '已损坏'])
} finally {
;(prisma.$transaction as any) = originalTransaction
;(prisma.operationLog.create as any) = originalLogCreate
}
})
test('行政已处理列表包含全部已审批、已驳回和已完成记录,不按处理人收窄', async () => {
const originalFindMany = prisma.disposalRecord.findMany
const originalCount = prisma.disposalRecord.count
const originalGroupBy = prisma.disposalRecord.groupBy
let capturedWhere: any = null
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
capturedWhere = args.where
return [{
id: 119,
status: 'completed',
notes: '旧版处置备注',
completion_notes: null
}]
}
;(prisma.disposalRecord.count as any) = async () => 1
;(prisma.disposalRecord.groupBy as any) = async () => [
{ status: 'completed', _count: { _all: 1 } }
]
try {
const { res, state } = createResponse('administrative')
await getDisposalList(
{ query: { scope: 'processed', page: '1', pageSize: '10' } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.deepEqual(capturedWhere.AND[1], {
status: { in: ['approved', 'rejected', 'completed'] }
})
assert.doesNotMatch(JSON.stringify(capturedWhere), /approved_by|completed_by/)
assert.equal(state.body.data.list[0].notes, '旧版处置备注')
assert.equal(state.body.data.list[0].historical_notes, '旧版处置备注')
} finally {
;(prisma.disposalRecord.findMany as any) = originalFindMany
;(prisma.disposalRecord.count as any) = originalCount
;(prisma.disposalRecord.groupBy as any) = originalGroupBy
}
})
test('行政可以查询自己发起的报废流程', async () => {
const originalFindMany = prisma.disposalRecord.findMany
const originalCount = prisma.disposalRecord.count
const originalGroupBy = prisma.disposalRecord.groupBy
let capturedWhere: any = null
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
capturedWhere = args.where
return []
}
;(prisma.disposalRecord.count as any) = async () => 0
;(prisma.disposalRecord.groupBy as any) = async () => []
try {
const { res, state } = createResponse('administrative')
await getDisposalList(
{ query: { scope: 'mine', page: '1', pageSize: '10' } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.ok(capturedWhere.AND.some((condition: any) => condition.applied_by === 6))
} finally {
;(prisma.disposalRecord.findMany as any) = originalFindMany
;(prisma.disposalRecord.count as any) = originalCount
;(prisma.disposalRecord.groupBy as any) = originalGroupBy
}
})
test('IT即使请求all范围也只能查询本人发起的报废记录', async () => {
const originalFindMany = prisma.disposalRecord.findMany
const originalCount = prisma.disposalRecord.count
const originalGroupBy = prisma.disposalRecord.groupBy
let capturedWhere: any = null
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
capturedWhere = args.where
return []
}
;(prisma.disposalRecord.count as any) = async () => 0
;(prisma.disposalRecord.groupBy as any) = async () => []
try {
const { res, state } = createResponse('it')
await getDisposalList(
{ query: { scope: 'all', page: '1', pageSize: '10' } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.ok(capturedWhere.AND.some((condition: any) => condition.applied_by === 6))
} finally {
;(prisma.disposalRecord.findMany as any) = originalFindMany
;(prisma.disposalRecord.count as any) = originalCount
;(prisma.disposalRecord.groupBy as any) = originalGroupBy
}
})
test('IT不能通过详情接口查看他人申请,行政可以查看全部流程详情', async () => {
const originalFindUnique = prisma.disposalRecord.findUnique
;(prisma.disposalRecord.findUnique as any) = async () => ({
id: 120,
applied_by: 99,
status: 'completed',
notes: '历史备注',
completion_notes: null
})
try {
const itResponse = createResponse('it')
await getDisposalDetail(
{ params: { id: '120' } } as any,
itResponse.res as any
)
assert.equal(itResponse.state.statusCode, 403)
const administrativeResponse = createResponse('administrative')
await getDisposalDetail(
{ params: { id: '120' } } as any,
administrativeResponse.res as any
)
assert.equal(administrativeResponse.state.statusCode, 200)
assert.equal(administrativeResponse.state.body.data.notes, '历史备注')
assert.equal(administrativeResponse.state.body.data.historical_notes, '历史备注')
} finally {
;(prisma.disposalRecord.findUnique as any) = originalFindUnique
}
})

View File

@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { getDisposalDetail, getDisposalList, getEquipmentDisposalHistory } from '../disposalController'
const response = (role: string) => {
const state = { code: 200, body: null as any }
const res = {
locals: { user: { id: 6, role } },
status(code: number) { state.code = code; return this },
json(body: any) { state.body = body; return this }
}
return { state, res }
}
test('disposal history limits IT to applicant across root and child equipment records', async () => {
const original = prisma.disposalRecord.findMany
const records = [{ id: 1, applied_by: 6 }, { id: 2, applied_by: 99 }]
const queries: any[] = []
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
queries.push(args.where)
const userId = args.where.AND[0].applied_by
return records.filter((item) => !userId || item.applied_by === userId)
}
try {
for (const role of ['it', 'administrative', 'admin']) {
const { state, res } = response(role)
await getEquipmentDisposalHistory({ params: { equipment_id: '10' } } as any, res as any)
assert.equal(state.code, 200)
assert.deepEqual(state.body.data.map((item: any) => item.id), role === 'it' ? [1] : [1, 2])
}
assert.deepEqual(queries[0].AND, [
{ applied_by: 6 },
{ OR: [{ equipment_id: 10 }, { items: { some: { equipment_id: 10 } } }] }
])
} finally {
;(prisma.disposalRecord.findMany as any) = original
}
})
test('disposal detail permits IT own record and rejects another applicant', async () => {
const original = prisma.disposalRecord.findUnique
;(prisma.disposalRecord.findUnique as any) = async (args: any) => ({
id: args.where.id, applied_by: args.where.id === 1 ? 6 : 99, status: 'pending'
})
try {
for (const [role, id, code] of [['it', '1', 200], ['it', '2', 403], ['admin', '2', 200], ['administrative', '2', 200]] as const) {
const { res, state } = response(role)
await getDisposalDetail({ params: { id } } as any, res as any)
assert.equal(state.code, code)
if (code === 403) assert.equal(state.body.data, null)
}
} finally {
;(prisma.disposalRecord.findUnique as any) = original
}
})
test('disposal read endpoints reject unsupported roles and malformed IDs before DB access', async () => {
for (const handler of [getDisposalList, getDisposalDetail, getEquipmentDisposalHistory]) {
const { res, state } = response('user')
await handler({ query: {}, params: { id: '1', equipment_id: '1' } } as any, res as any)
assert.equal(state.code, 403)
}
for (const handler of [getDisposalDetail, getEquipmentDisposalHistory]) {
for (const value of ['0', '-1', 'NaN', '9007199254740992', ['1', '2']]) {
const { res, state } = response('it')
await handler({ params: { id: value, equipment_id: value } } as any, res as any)
assert.equal(state.code, 422)
}
}
const { res, state } = response('administrative')
await getDisposalList({ query: { scope: 'all' } } as any, res as any)
assert.equal(state.code, 403)
})

View File

@ -0,0 +1,174 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { getEquipmentList } from '../equipmentController'
const createMockResponse = () => {
const responseBody: { statusCode: number; body: any } = {
statusCode: 200,
body: null
}
const res = {
status(code: number) {
responseBody.statusCode = code
return this
},
json(payload: any) {
responseBody.body = payload
return this
}
}
return { res, responseBody }
}
test('资产列表搜索不应再按借用人身份证号或历史 person_identifier 命中结果', async () => {
const originalFindMany = prisma.equipment.findMany
const originalCount = prisma.equipment.count
let capturedWhere: any = null
let capturedArgs: any = null
;(prisma.equipment.findMany as any) = async (args: any) => {
capturedArgs = args
capturedWhere = args.where
return []
}
;(prisma.equipment.count as any) = async () => 0
try {
const { res, responseBody } = createMockResponse()
await getEquipmentList(
{
query: {
keyword: '2227',
page: '1',
pageSize: '10'
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.ok(capturedWhere)
const serializedWhere = JSON.stringify(capturedWhere)
assert.equal(
serializedWhere.includes('id_number'),
false,
'资产列表搜索不应包含 person.id_number 的模糊搜索'
)
assert.equal(
serializedWhere.includes('person_identifier'),
false,
'资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中'
)
assert.equal(
JSON.stringify(capturedArgs).includes('person_identifier'),
false,
'资产接口也不应读取历史 person_identifier 后再返回给行政或 IT'
)
assert.equal(
serializedWhere.includes('job_number'),
true,
'需要保留按人员真实工号搜索借用资产的能力'
)
} finally {
;(prisma.equipment.findMany as any) = originalFindMany
;(prisma.equipment.count as any) = originalCount
}
})
test('资产列表最近变动排序会把有最新挂载记录的父资产排在前面', async () => {
const originalFindMany = prisma.equipment.findMany
const originalCount = prisma.equipment.count
const originalGroupBy = prisma.mountHistory.groupBy
const olderParent = {
id: 1,
equipment_code: 'JS-PC-0001',
equipment_name: '旧主机',
equipment_type: '主机',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 0,
updated_at: new Date('2026-03-01T00:00:00Z'),
children: [],
borrow_records: []
}
const recentlyMountedParent = {
id: 2,
equipment_code: 'JS-PC-0002',
equipment_name: '刚换内存的主机',
equipment_type: '主机',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 0,
updated_at: new Date('2026-03-01T00:00:00Z'),
children: [],
borrow_records: []
}
const candidates = [
{ id: olderParent.id, updated_at: olderParent.updated_at, children: [] },
{ id: recentlyMountedParent.id, updated_at: recentlyMountedParent.updated_at, children: [] }
]
const equipmentById = new Map([
[olderParent.id, olderParent],
[recentlyMountedParent.id, recentlyMountedParent]
])
;(prisma.equipment.findMany as any) = async (args: any) => {
if (args.select) {
return candidates
}
const ids = args.where.id.in as number[]
return ids.map((id) => equipmentById.get(id))
}
;(prisma.equipment.count as any) = async () => candidates.length
;(prisma.mountHistory.groupBy as any) = async () => [
{
parent_id: recentlyMountedParent.id,
_max: {
operated_at: new Date('2026-04-01T00:00:00Z')
}
}
]
try {
const { res, responseBody } = createMockResponse()
await getEquipmentList(
{
query: {
sort: 'recent_change',
page: '1',
pageSize: '10'
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.equal(responseBody.body.data.total, 2)
assert.equal(responseBody.body.data.list[0].id, recentlyMountedParent.id)
assert.equal(
responseBody.body.data.list[0].recent_change_at.getTime(),
new Date('2026-04-01T00:00:00Z').getTime()
)
} finally {
;(prisma.equipment.findMany as any) = originalFindMany
;(prisma.equipment.count as any) = originalCount
;(prisma.mountHistory.groupBy as any) = originalGroupBy
}
})

View File

@ -0,0 +1,373 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { updateEquipment } from '../equipmentController'
const createMockResponse = (role = 'admin') => {
const responseBody: { statusCode: number; body: any } = {
statusCode: 200,
body: null
}
const res = {
locals: { user: { id: 1, role } },
status(code: number) {
responseBody.statusCode = code
return this
},
json(payload: any) {
responseBody.body = payload
return this
}
}
return { res, responseBody }
}
const currentEquipment = {
id: 903,
equipment_code: 'JS-PC-0119',
code_generation_mode: 'auto',
equipment_name: '主机',
equipment_model: '',
equipment_type: '主机',
unit_price: 0,
total_quantity: 1,
remaining_quantity: 1,
total_value: 0,
borrowed_value: 0,
available_value: 0,
purchase_date: null,
supplier: '',
description: '',
status: '待报废',
storage_location_id: null,
department_id: null,
asset_type_id: null,
parent_id: null,
updated_at: new Date('2026-07-14T01:00:00.000Z')
}
test('已丢失资产基础编辑保留状态与零库存,禁止增总数量生成虚假库存', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalTransaction = prisma.$transaction
const lostEquipment = { ...currentEquipment, status: '已丢失', remaining_quantity: 0, unit_price: 100, total_value: 100 }
let updateData: any
let writes = 0
;(prisma.equipment.findUnique as any) = async () => lostEquipment
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRawUnsafe: async () => [],
operationLog: { create: async () => ({ id: 1 }) },
equipment: {
updateMany: async (args: any) => { writes++; updateData = args.data; return { count: 1 } },
count: async () => 0
}
})
try {
const metadata = createMockResponse()
await updateEquipment({ params: { id: '903' }, body: { equipment_name: '丢失主机', total_quantity: 1, unit_price: 120 } } as any, metadata.res as any)
assert.equal(metadata.responseBody.statusCode, 200)
assert.equal(updateData.status, '已丢失')
assert.equal(updateData.remaining_quantity, 0)
assert.equal(updateData.available_value, 0)
assert.equal(updateData.borrowed_value, 0)
const quantity = createMockResponse()
await updateEquipment({ params: { id: '903' }, body: { total_quantity: 2 } } as any, quantity.res as any)
assert.equal(quantity.responseBody.statusCode, 409)
assert.equal(writes, 1)
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
}
})
test('普通编辑资产时不应把待报废资产冲回可用', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalTransaction = prisma.$transaction
let capturedUpdateData: any = null
;(prisma.equipment.findUnique as any) = async () => currentEquipment
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRawUnsafe: async () => [],
operationLog: { create: async () => ({ id: 1 }) },
equipment: {
updateMany: async (args: any) => {
capturedUpdateData = args.data
return { count: 1 }
},
count: async () => 0
}
})
try {
const { res, responseBody } = createMockResponse()
await updateEquipment(
{
params: { id: '903' },
body: {
equipment_name: '主机',
equipment_model: '',
unit_price: 0,
total_quantity: 1,
purchase_date: '',
supplier: '',
description: ''
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.equal(capturedUpdateData.status, '待报废')
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
}
})
test('管理员也不能通过基础资料接口直接改部门或状态', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalTransaction = prisma.$transaction
let transactionCount = 0
;(prisma.equipment.findUnique as any) = async () => ({ ...currentEquipment, status: '可用' })
;(prisma.$transaction as any) = async () => {
transactionCount += 1
}
try {
for (const body of [{ department_id: 8 }, { status: '维护中' }]) {
const { res, responseBody } = createMockResponse('admin')
await updateEquipment({ params: { id: '903' }, body } as any, res as any)
assert.equal(responseBody.statusCode, 422)
}
assert.equal(transactionCount, 0)
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
}
})
test('资产状态在保存前变化时 CAS 失败并返回 409', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalTransaction = prisma.$transaction
let capturedWhere: any = null
let findUniqueCount = 0
;(prisma.equipment.findUnique as any) = async () => {
findUniqueCount += 1
return currentEquipment
}
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRawUnsafe: async () => [],
equipment: {
updateMany: async (args: any) => {
capturedWhere = args.where
return { count: 0 }
}
}
})
try {
const { res, responseBody } = createMockResponse()
await updateEquipment(
{ params: { id: '903' }, body: { equipment_name: '新名称' } } as any,
res as any
)
assert.equal(responseBody.statusCode, 409)
assert.equal(findUniqueCount, 1, '冲突后不应读取并返回伪成功数据')
assert.equal(capturedWhere.status, currentEquipment.status)
assert.equal(capturedWhere.department_id, currentEquipment.department_id)
assert.equal(capturedWhere.parent_id, currentEquipment.parent_id)
assert.equal(capturedWhere.remaining_quantity, currentEquipment.remaining_quantity)
assert.equal(capturedWhere.updated_at, currentEquipment.updated_at)
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
}
})
test('行政纠错只写入本次提交的基础字段', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalTransaction = prisma.$transaction
const originalBorrowCount = prisma.borrowRecord.count
const originalMaintenanceCount = prisma.maintenanceRecord.count
const originalDisposalItemCount = prisma.disposalItem.count
const originalDepartmentFindFirst = prisma.department.findFirst
const administrativeEquipment = {
...currentEquipment,
status: '可用',
department_id: 7,
parent_id: null
}
let capturedUpdateData: any = null
;(prisma.equipment.findUnique as any) = async () => administrativeEquipment
;(prisma.borrowRecord.count as any) = async () => 0
;(prisma.maintenanceRecord.count as any) = async () => 0
;(prisma.disposalItem.count as any) = async () => 0
;(prisma.department.findFirst as any) = async () => ({ id: 7, name: '行政部' })
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRawUnsafe: async () => [],
operationLog: { create: async () => ({ id: 1 }) },
borrowRecord: { count: async () => 0 },
maintenanceRecord: { count: async () => 0 },
disposalItem: { count: async () => 0 },
equipment: {
updateMany: async (args: any) => {
capturedUpdateData = args.data
return { count: 1 }
},
count: async () => 0
}
})
try {
const { res, responseBody } = createMockResponse('administrative')
await updateEquipment(
{ params: { id: '903' }, body: { equipment_name: '更正后的主机名称' } } as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.deepEqual(capturedUpdateData, { equipment_name: '更正后的主机名称' })
assert.equal(Object.prototype.hasOwnProperty.call(capturedUpdateData, 'status'), false)
assert.equal(Object.prototype.hasOwnProperty.call(capturedUpdateData, 'department_id'), false)
assert.equal(Object.prototype.hasOwnProperty.call(capturedUpdateData, 'parent_id'), false)
assert.equal(Object.prototype.hasOwnProperty.call(capturedUpdateData, 'remaining_quantity'), false)
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
;(prisma.borrowRecord.count as any) = originalBorrowCount
;(prisma.maintenanceRecord.count as any) = originalMaintenanceCount
;(prisma.disposalItem.count as any) = originalDisposalItemCount
;(prisma.department.findFirst as any) = originalDepartmentFindFirst
}
})
test('行政不能用非正整数数量纠错间接改变资产状态', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalTransaction = prisma.$transaction
const originalBorrowCount = prisma.borrowRecord.count
const originalMaintenanceCount = prisma.maintenanceRecord.count
const originalDisposalItemCount = prisma.disposalItem.count
let transactionCount = 0
;(prisma.equipment.findUnique as any) = async () => ({ ...currentEquipment, status: '可用' })
;(prisma.borrowRecord.count as any) = async () => 0
;(prisma.maintenanceRecord.count as any) = async () => 0
;(prisma.disposalItem.count as any) = async () => 0
;(prisma.$transaction as any) = async () => {
transactionCount += 1
}
try {
for (const invalidQuantity of [0, -1, 1.5]) {
const { res, responseBody } = createMockResponse('administrative')
await updateEquipment(
{ params: { id: '903' }, body: { total_quantity: invalidQuantity } } as any,
res as any
)
assert.equal(responseBody.statusCode, 422)
assert.match(responseBody.body.message, /总数量必须/)
}
assert.equal(transactionCount, 0)
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
;(prisma.borrowRecord.count as any) = originalBorrowCount
;(prisma.maintenanceRecord.count as any) = originalMaintenanceCount
;(prisma.disposalItem.count as any) = originalDisposalItemCount
}
})
test('修改资产类型名称时同步更新 asset_type_id', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalCount = prisma.equipment.count
const originalAssetTypeFindFirst = prisma.assetType.findFirst
const originalTransaction = prisma.$transaction
const equipment = { ...currentEquipment, status: '可用', asset_type_id: 1 }
const newType = {
id: 42,
name: '配件',
status: 'active',
can_be_parent: true,
can_be_child: true
}
let capturedUpdateData: any = null
;(prisma.equipment.findUnique as any) = async () => equipment
;(prisma.equipment.count as any) = async () => 0
;(prisma.assetType.findFirst as any) = async () => newType
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRawUnsafe: async () => [],
operationLog: { create: async () => ({ id: 1 }) },
assetType: { findUnique: async () => newType },
equipment: {
count: async () => 0,
updateMany: async (args: any) => {
capturedUpdateData = args.data
return { count: 1 }
}
}
})
try {
const { res, responseBody } = createMockResponse('admin')
await updateEquipment(
{ params: { id: '903' }, body: { equipment_type: '配件' } } as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.deepEqual(capturedUpdateData, {
equipment_type: '配件',
asset_type_id: 42
})
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.equipment.count as any) = originalCount
;(prisma.assetType.findFirst as any) = originalAssetTypeFindFirst
;(prisma.$transaction as any) = originalTransaction
}
})
test('有子资产的设备不能改成不支持父资产的类型', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalCount = prisma.equipment.count
const originalAssetTypeFindFirst = prisma.assetType.findFirst
const originalTransaction = prisma.$transaction
let transactionCount = 0
;(prisma.equipment.findUnique as any) = async () => ({ ...currentEquipment, status: '可用' })
;(prisma.equipment.count as any) = async () => 1
;(prisma.assetType.findFirst as any) = async () => ({
id: 43,
name: '普通配件',
status: 'active',
can_be_parent: false,
can_be_child: true
})
;(prisma.$transaction as any) = async () => {
transactionCount += 1
}
try {
const { res, responseBody } = createMockResponse('admin')
await updateEquipment(
{ params: { id: '903' }, body: { equipment_type: '普通配件' } } as any,
res as any
)
assert.equal(responseBody.statusCode, 422)
assert.match(responseBody.body.message, /必须允许作为父资产/)
assert.equal(transactionCount, 0)
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.equipment.count as any) = originalCount
;(prisma.assetType.findFirst as any) = originalAssetTypeFindFirst
;(prisma.$transaction as any) = originalTransaction
}
})

View File

@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { createHealthController } from '../healthController'
const response = () => {
const state = { code: 0, body: null as any }
const res = {
status(code: number) { state.code = code; return this },
json(body: any) { state.body = body; return this }
}
return { state, res }
}
test('health verifies business tables and maintenance migration even with empty tables', async () => {
const original = prisma.$queryRaw
let queryText = ''
;(prisma.$queryRaw as any) = async (parts: any) => {
queryText = parts.join('')
return []
}
try {
const { res, state } = response()
await createHealthController()({} as any, res as any)
assert.equal(state.code, 200)
assert.equal(state.body.status, 'ok')
for (const table of ['equipment', 'maintenance_record', 'web_session', 'original_tree_snapshot',
'claim_token', 'lease_expires_at', 'result_confirmed_by', 'result_confirmed_at', 'protocol_version']) {
assert.ok(queryText.includes(table))
}
assert.match(queryText, /WHERE 1 = 0/)
} finally {
;(prisma.$queryRaw as any) = original
}
})
test('health database failure returns sanitized 503', async () => {
const { res, state } = response()
await createHealthController(async () => { throw new Error('mysql://secret-password@host/db') })({} as any, res as any)
assert.equal(state.code, 503)
assert.equal(state.body.status, 'error')
assert.doesNotMatch(JSON.stringify(state.body), /secret|mysql|host/)
})
test('timed out health probe is shared until DB query finishes, then retries', async () => {
let calls = 0
let finish!: () => void
const pending = new Promise<void>((resolve) => { finish = resolve })
const handler = createHealthController(async () => { calls += 1; if (calls === 1) await pending }, 10)
const responses = [response(), response(), response()]
await Promise.all(responses.map(({ res }) => handler({} as any, res as any)))
assert.equal(calls, 1)
assert.ok(responses.every(({ state }) => state.code === 503))
const subsequent = response()
await handler({} as any, subsequent.res as any)
assert.equal(calls, 1)
assert.equal(subsequent.state.code, 503)
finish()
await new Promise((resolve) => setImmediate(resolve))
const recovered = response()
await handler({} as any, recovered.res as any)
assert.equal(calls, 2)
assert.equal(recovered.state.code, 200)
})

View File

@ -0,0 +1,200 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { completeMaintenance, createMaintenance, updateMaintenance } from '../maintenanceController'
const createMockResponse = () => {
const responseBody = { statusCode: 200, body: null as any }
const res = {
locals: {
user: { id: 9, username: 'it-user', name: 'IT用户' }
},
status(code: number) {
responseBody.statusCode = code
return this
},
json(payload: any) {
responseBody.body = payload
return this
}
}
return { res, responseBody }
}
const equipment = {
id: 101,
equipment_code: 'JS-PC-0101',
equipment_name: '主机',
parent_id: null,
department_id: 3,
storage_location_id: 2,
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 1000,
mount_status: 'none',
updated_at: new Date('2026-10-07T00:00:00Z')
}
test('并发送修未抢到资产状态时不创建第二条维修单', async () => {
const originalTransaction = prisma.$transaction
let maintenanceCreated = false
let acquireWhere: any = null
const tx = {
$queryRaw: async (query: any) => query.sql.includes('WHERE equipment.id = ?') ? [equipment] : [],
equipment: {
findUnique: async () => equipment,
findMany: async () => [],
updateMany: async (args: any) => {
acquireWhere = args.where
return { count: 0 }
}
},
borrowRecord: { count: async () => 0 },
disposalItem: { count: async () => 0 },
maintenanceRecord: {
count: async () => 0,
create: async () => {
maintenanceCreated = true
return { id: 1 }
}
}
}
;(prisma.$transaction as any) = async (callback: any) => callback(tx)
try {
const { res, responseBody } = createMockResponse()
await createMaintenance(
{
body: {
equipment_id: equipment.id,
issue_description: '无法开机',
estimated_cost: 100
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 409)
assert.equal(maintenanceCreated, false)
assert.deepEqual(acquireWhere, {
id: equipment.id,
status: '可用',
parent_id: null,
remaining_quantity: 1,
department_id: 3,
mount_status: 'none',
updated_at: equipment.updated_at
})
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', async () => {
const originalTransaction = prisma.$transaction
let equipmentUpdated = false
let maintenanceWhere: any = null
const tx = {
$queryRaw: async (query: any) => query.sql.includes('WHERE equipment.id = ?') ? [{ ...equipment, status: '维护中' }] : [],
maintenanceRecord: {
findUnique: async () => ({
id: 20,
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
status: 'in_progress',
estimated_cost: 100
}),
updateMany: async (args: any) => {
maintenanceWhere = args.where
return { count: 0 }
}
},
equipment: {
findUnique: async () => ({ ...equipment, status: '维护中' }),
findMany: async () => [],
updateMany: async () => {
equipmentUpdated = true
return { count: 1 }
}
}
}
;(prisma.$transaction as any) = async (callback: any) => callback(tx)
try {
const { res, responseBody } = createMockResponse()
await completeMaintenance(
{ params: { id: '20' }, body: { repair_result: '已修复' } } as any,
res as any
)
assert.equal(responseBody.statusCode, 409)
assert.deepEqual(maintenanceWhere, { id: 20, status: 'in_progress', updated_at: undefined })
assert.equal(equipmentUpdated, false)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('维修资料更新只命中 in_progress,且不再接受未落库的 notes 字段', async () => {
const originalUpdateMany = prisma.maintenanceRecord.updateMany
const originalFindUnique = prisma.maintenanceRecord.findUnique
let updateArgs: any = null
;(prisma.maintenanceRecord.updateMany as any) = async (args: any) => {
updateArgs = args
return { count: 1 }
}
;(prisma.maintenanceRecord.findUnique as any) = async () => ({
id: 20,
status: 'in_progress',
repair_vendor: '测试维修商'
})
try {
const { res, responseBody } = createMockResponse()
await updateMaintenance(
{
params: { id: '20' },
body: {
repair_vendor: '测试维修商',
estimated_cost: 200,
notes: '这个字段不属于维修表,不能静默接收'
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.deepEqual(updateArgs.where, { id: 20, status: 'in_progress' })
assert.equal(updateArgs.data.repair_vendor, '测试维修商')
assert.equal(updateArgs.data.estimated_cost, 200)
assert.equal(Object.prototype.hasOwnProperty.call(updateArgs.data, 'notes'), false)
} finally {
;(prisma.maintenanceRecord.updateMany as any) = originalUpdateMany
;(prisma.maintenanceRecord.findUnique as any) = originalFindUnique
}
})
test('维修资料更新与完成并发时条件更新失败并返回409', async () => {
const originalUpdateMany = prisma.maintenanceRecord.updateMany
const originalFindUnique = prisma.maintenanceRecord.findUnique
;(prisma.maintenanceRecord.updateMany as any) = async () => ({ count: 0 })
;(prisma.maintenanceRecord.findUnique as any) = async () => ({ id: 20, status: 'completed' })
try {
const { res, responseBody } = createMockResponse()
await updateMaintenance(
{ params: { id: '20' }, body: { repair_vendor: '并发写入' } } as any,
res as any
)
assert.equal(responseBody.statusCode, 409)
assert.match(responseBody.body.message, /已被处理/)
} finally {
;(prisma.maintenanceRecord.updateMany as any) = originalUpdateMany
;(prisma.maintenanceRecord.findUnique as any) = originalFindUnique
}
})

View File

@ -0,0 +1,187 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { cancelMaintenance, completeMaintenance, createMaintenance } from '../maintenanceController'
import { buildMaintenanceSnapshot } from '../../utils/maintenanceSnapshot'
const root = {
id: 10, equipment_code: 'ROOT', equipment_name: '主机', equipment_model: null,
equipment_type: '电脑', parent_id: null, department_id: 1, storage_location_id: null,
status: '已损坏', mount_status: 'none', total_quantity: 2, remaining_quantity: 1,
unit_price: 100, updated_at: new Date('2026-10-07T00:00:00Z')
}
const child = { ...root, id: 11, equipment_code: 'CHILD', status: '可用', parent_id: 10, mount_status: 'mounted' }
const grandchild = { ...child, id: 12, equipment_code: 'GRANDCHILD', parent_id: 11, status: '已损坏' }
const originalTree = [root, child, grandchild]
const snapshot = buildMaintenanceSnapshot(originalTree as any)
const response = () => {
const state = { code: 200, body: null as any }
const res = {
locals: { user: { id: 6, username: 'test' } },
status(code: number) { state.code = code; return this },
json(body: any) { state.body = body; return this }
}
return { state, res }
}
const harness = async (
tree: any[],
record: any,
operation: 'create' | 'cancel' | 'complete',
failedEquipmentId?: number
) => {
const originalTransaction = prisma.$transaction
const originalLog = prisma.operationLog.create
let committedTree = tree.map((item) => ({ ...item }))
let committedRecord = { ...record }
let options: any
const writes: any[] = []
const locks: string[] = []
let created: any
;(prisma.$transaction as any) = async (callback: any, txOptions: any) => {
options = txOptions
const working = tree.map((item) => ({ ...item }))
let workingRecord = { ...record }
const tx = {
$queryRaw: async (query: any) => {
locks.push(query.sql)
if (query.sql.includes('WHERE equipment.id = ?')) return working.filter((item) => item.id === query.values[0])
if (query.sql.includes('WHERE equipment.parent_id IN')) return working.filter((item) => query.values.includes(item.parent_id))
return []
},
equipment: {
findUnique: async ({ where }: any) => working.find((item) => item.id === where.id),
findMany: async ({ where }: any) => working.filter((item) => where.parent_id.in.includes(item.parent_id)),
updateMany: async (args: any) => {
writes.push(args)
if (args.where.id === failedEquipmentId) return { count: 0 }
Object.assign(working.find((item) => item.id === args.where.id), args.data)
return { count: 1 }
}
},
borrowRecord: { count: async () => 0 },
disposalItem: { count: async () => 0 },
maintenanceRecord: {
count: async () => 0,
findUnique: async () => workingRecord,
create: async ({ data }: any) => { created = data; return { id: 20, ...data } },
updateMany: async ({ data }: any) => { workingRecord = { ...workingRecord, ...data }; return { count: 1 } }
}
}
const result = await callback(tx)
committedTree = working
committedRecord = workingRecord
return result
}
;(prisma.operationLog.create as any) = async () => ({})
try {
const { state, res } = response()
if (operation === 'create') {
await createMaintenance({ body: { equipment_id: 10, issue_description: '故障' } } as any, res as any)
} else if (operation === 'cancel') {
await cancelMaintenance({ params: { id: '20' }, body: { reason: '取消' } } as any, res as any)
} else {
await completeMaintenance({ params: { id: '20' }, body: { actual_cost: 0, repair_result: '已修复' } } as any, res as any)
}
return { state, committedTree, committedRecord, writes, options, created, locks }
} finally {
;(prisma.$transaction as any) = originalTransaction
;(prisma.operationLog.create as any) = originalLog
}
}
const record = { id: 20, equipment_id: 10, equipment_code: 'ROOT', status: 'in_progress', original_tree_snapshot: snapshot, updated_at: root.updated_at }
const maintenanceTree = originalTree.map((item) => ({ ...item, status: '维护中' }))
test('sending entire asset tree to maintenance preserves each original status and inventory', async () => {
const result = await harness(originalTree, {}, 'create')
assert.equal(result.state.code, 200)
assert.equal(result.options.isolationLevel, 'ReadCommitted')
assert.deepEqual(JSON.parse(result.created.original_tree_snapshot).assets, JSON.parse(snapshot).assets)
assert.ok(result.committedTree.every((item) => item.status === '维护中' && item.remaining_quantity === 1))
assert.equal(result.writes.length, 3)
})
test('cancel restores mixed damaged/available states for every descendant and recomputes values', async () => {
const result = await harness(maintenanceTree, record, 'cancel')
assert.equal(result.state.code, 200)
assert.equal(result.committedRecord.status, 'cancelled')
assert.equal(result.options.isolationLevel, 'ReadCommitted')
assert.deepEqual(result.committedTree.map((item) => item.status), ['已损坏', '可用', '已损坏'])
assert.match(result.locks[0], /FROM maintenance_record.*FOR UPDATE/)
assert.match(result.locks[1], /FROM equipment.*FOR UPDATE/)
assert.deepEqual(result.committedTree.map((item) => item.available_value), [0, 100, 0])
assert.deepEqual(result.committedTree.map((item) => item.borrowed_value), [0, 100, 0])
assert.deepEqual(result.writes.map((item) => item.where.parent_id), [null, 10, 11])
assert.ok(result.writes.every((item) => item.where.status === '维护中' && item.where.updated_at))
})
test('legacy or corrupt snapshots return explicit manual verification conflict without writes', async () => {
for (const value of [null, '', '{', '{}', JSON.stringify({ version: 1, assets: [JSON.parse(snapshot).assets[0], JSON.parse(snapshot).assets[0]] })]) {
const result = await harness(maintenanceTree, { ...record, original_tree_snapshot: value }, 'cancel')
assert.equal(result.state.code, 409)
assert.match(result.state.body.message, /人工核实/)
assert.equal(result.writes.length, 0)
assert.equal(result.committedRecord.status, 'in_progress')
}
})
test('changed asset tree, inventory or maintenance status prevents cancellation', async () => {
const cases = [
[...maintenanceTree, { ...child, id: 13, status: '维护中' }],
maintenanceTree.slice(0, 2),
maintenanceTree.map((item) => item.id === 12 ? { ...item, parent_id: 10 } : item),
maintenanceTree.map((item) => item.id === 11 ? { ...item, status: '可用' } : item),
maintenanceTree.map((item) => item.id === 11 ? { ...item, remaining_quantity: 2 } : item)
]
for (const tree of cases) {
const result = await harness(tree, record, 'cancel')
assert.equal(result.state.code, 409)
assert.equal(result.writes.length, 0)
}
const completed = await harness(maintenanceTree, { ...record, status: 'completed' }, 'cancel')
assert.equal(completed.state.code, 409)
assert.equal(completed.writes.length, 0)
})
test('conditional descendant write conflict rolls back both asset tree and maintenance record', async () => {
const result = await harness(maintenanceTree, record, 'cancel', 11)
assert.equal(result.state.code, 409)
assert.deepEqual(result.committedTree, maintenanceTree)
assert.equal(result.committedRecord.status, 'in_progress')
})
test('completion locks whole asset tree after maintenance row and conditionally repairs all descendants', async () => {
const result = await harness(maintenanceTree, record, 'complete')
assert.equal(result.state.code, 200)
assert.equal(result.options.isolationLevel, 'ReadCommitted')
assert.match(result.locks[0], /FROM maintenance_record.*FOR UPDATE/)
assert.match(result.locks[1], /FROM equipment.*FOR UPDATE/)
assert.deepEqual(result.committedTree.map((item) => item.status), ['可用', '可用', '可用'])
assert.equal(result.committedRecord.status, 'completed')
assert.equal(result.writes.length, 3)
assert.deepEqual(result.writes.map((item) => item.where.parent_id), [null, 10, 11])
assert.ok(result.writes.every((item) => item.where.status === '维护中' && item.where.updated_at && item.where.unit_price))
})
test('completion rejects removed, reparented, independently borrowed or changed-stock descendants without overwrite', async () => {
for (const tree of [
maintenanceTree.slice(0, 2),
maintenanceTree.map((item) => item.id === 12 ? { ...item, parent_id: 10 } : item),
maintenanceTree.map((item) => item.id === 11 ? { ...item, status: '已借出' } : item),
maintenanceTree.map((item) => item.id === 11 ? { ...item, remaining_quantity: 0 } : item)
]) {
const result = await harness(tree, record, 'complete')
assert.equal(result.state.code, 409)
assert.equal(result.writes.length, 0)
assert.equal(result.committedRecord.status, 'in_progress')
assert.deepEqual(result.committedTree, tree)
}
})
test('completion descendant CAS failure rolls back earlier repaired rows and completed record', async () => {
const result = await harness(maintenanceTree, record, 'complete', 11)
assert.equal(result.state.code, 409)
assert.deepEqual(result.committedTree, maintenanceTree)
assert.equal(result.committedRecord.status, 'in_progress')
})

View File

@ -0,0 +1,317 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { mountChild, unmountChild } from '../mountController'
const now = new Date('2026-07-14T02:00:00.000Z')
const parent = {
id: 1,
equipment_code: 'JS-PC-0001',
equipment_name: '主机',
parent_id: null,
department_id: 5,
storage_location_id: 1,
status: '可用',
mount_status: 'none',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 1000,
updated_at: now,
assetType: { can_be_parent: true, can_be_child: false }
}
const child = {
id: 2,
equipment_code: 'JS-RAM-0002',
equipment_name: '内存组',
parent_id: null,
department_id: null,
storage_location_id: 1,
status: '可用',
mount_status: 'unmounted',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 300,
updated_at: now,
assetType: { can_be_parent: true, can_be_child: true }
}
const grandchild = {
id: 3,
equipment_code: 'JS-CHIP-0003',
equipment_name: '内存颗粒',
parent_id: 2,
department_id: null,
storage_location_id: 1,
status: '可用',
mount_status: 'mounted',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 50,
updated_at: now,
assetType: { can_be_parent: false, can_be_child: true }
}
const readCurrentTree = (query: any, items: any[]) => {
if (query.sql.includes('WHERE equipment.id = ?')) {
return items.filter((item) => item.id === Number(query.values[0]))
}
if (query.sql.includes('WHERE equipment.parent_id IN')) {
const parentIds = new Set(query.values.map(Number))
return items
.filter((item) => item.parent_id !== null && parentIds.has(item.parent_id))
.sort((left, right) => left.id - right.id)
}
return []
}
const createResponse = () => {
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'it' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
return { res, state }
}
test('同一配件被并发挂载时 CAS 失败返回 409,不记挂载历史', async () => {
const originalTransaction = prisma.$transaction
let historyCount = 0
const lockQueries: any[] = []
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) lockQueries.push(query)
return readCurrentTree(query, [parent, child])
},
equipment: {
findUnique: async (args: any) => args.where.id === 1 ? parent : child,
findMany: async () => [],
updateMany: async () => ({ count: 0 })
},
borrowRecord: { count: async () => 0 },
mountHistory: {
create: async () => {
historyCount += 1
}
}
})
try {
const { res, state } = createResponse()
await mountChild(
{ params: { parentId: '1' }, body: { child_id: 2 } } as any,
res as any
)
assert.equal(state.statusCode, 409)
assert.equal(historyCount, 0)
assert.match(lockQueries[0].sql, /IN \(\?,\?\) ORDER BY id FOR UPDATE/)
assert.deepEqual(lockQueries[0].values, [1, 2])
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('A-B / B-A 交叉挂载会被祖先链校验拦截', async () => {
const originalTransaction = prisma.$transaction
let updateCount = 0
const parentUnderChild = { ...parent, parent_id: 2 }
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => readCurrentTree(query, [parentUnderChild, child]),
equipment: {
findUnique: async (args: any) => args.where.id === 1 ? parentUnderChild : child,
updateMany: async () => {
updateCount += 1
return { count: 1 }
}
},
borrowRecord: { count: async () => 0 }
})
try {
const { res, state } = createResponse()
await mountChild(
{ params: { parentId: '1' }, body: { child_id: 2 } } as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /不能把资产挂载到自己的下级/)
assert.equal(updateCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('挂载带后代的配件树时同步父资产部门和借出状态', async () => {
const originalTransaction = prisma.$transaction
const borrowedParent = { ...parent, status: '已借出', remaining_quantity: 0 }
const updates: any[] = []
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => readCurrentTree(
query,
[borrowedParent, child, grandchild]
),
equipment: {
findUnique: async (args: any) => {
if (args.where.id === 1) {
return args.include?.children
? { ...borrowedParent, children: [{ unit_price: child.unit_price }] }
: borrowedParent
}
return args.where.id === 2 ? child : grandchild
},
findMany: async (args: any) => {
const parentIds = args.where.parent_id.in as number[]
return parentIds.includes(2) ? [grandchild] : []
},
updateMany: async (args: any) => {
updates.push(args)
return { count: 1 }
},
update: async () => borrowedParent
},
borrowRecord: { count: async () => 0 },
mountHistory: { create: async () => ({ id: 1 }) }
})
try {
const { res, state } = createResponse()
await mountChild(
{ params: { parentId: '1' }, body: { child_id: 2 } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(updates.length, 2)
assert.equal(updates[0].data.parent_id, 1)
assert.equal(updates[0].data.mount_status, 'mounted')
assert.equal(updates[1].data.parent_id, undefined)
for (const update of updates) {
assert.equal(update.data.department_id, 5)
assert.equal(update.data.status, '已借出')
assert.equal(update.data.remaining_quantity, 0)
}
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('卸载配件树时整树恢复可用并清空部门', async () => {
const originalTransaction = prisma.$transaction
const mountedChild = { ...child, parent_id: 1, department_id: 5, mount_status: 'mounted' }
const mountedGrandchild = { ...grandchild, department_id: 5 }
const updates: any[] = []
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => readCurrentTree(
query,
[parent, mountedChild, mountedGrandchild]
),
equipment: {
findUnique: async (args: any) => {
if (args.where.id === 1) {
return args.include?.children
? { ...parent, children: [] }
: parent
}
return args.where.id === 2 ? mountedChild : mountedGrandchild
},
findMany: async (args: any) => {
const parentIds = args.where.parent_id.in as number[]
return parentIds.includes(2) ? [mountedGrandchild] : []
},
updateMany: async (args: any) => {
updates.push(args)
return { count: 1 }
},
update: async () => parent
},
mountHistory: { create: async () => ({ id: 1 }) }
})
try {
const { res, state } = createResponse()
await unmountChild(
{
params: { parentId: '1' },
body: { child_id: 2, released_child_status: '可用' }
} as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(updates.length, 2)
assert.equal(updates[0].data.parent_id, null)
assert.equal(updates[0].data.mount_status, 'unmounted')
for (const update of updates) {
assert.equal(update.data.department_id, null)
assert.equal(update.data.status, '可用')
assert.equal(update.data.remaining_quantity, 1)
}
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('卸载接口拒绝直接把配件设为维修中或待报废', async () => {
const originalTransaction = prisma.$transaction
let transactionCount = 0
;(prisma.$transaction as any) = async () => {
transactionCount += 1
}
try {
for (const releasedStatus of ['维护中', '待报废', '已报废']) {
const { res, state } = createResponse()
await unmountChild(
{
params: { parentId: '1' },
body: { child_id: 2, released_child_status: releasedStatus }
} as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /正式业务流程/)
}
assert.equal(transactionCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('maintenance parent rejects mounting and unmounting before any child or history writes', async () => {
const originalTransaction = prisma.$transaction
let writes = 0
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async () => [],
equipment: {
findUnique: async ({ where }: any) => where.id === parent.id
? { ...parent, status: '维护中' }
: { ...child, parent_id: parent.id, status: '维护中' },
updateMany: async () => { writes++; return { count: 1 } },
update: async () => { writes++; return {} }
},
mountHistory: { create: async () => { writes++; return {} } }
})
try {
for (const handler of [mountChild, unmountChild]) {
const { res, state } = createResponse()
await handler({ params: { parentId: String(parent.id) }, body: { child_id: child.id } } as any, res as any)
assert.equal(state.statusCode, 409)
assert.match(state.body.message, /先完成或取消维修/)
}
assert.equal(writes, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})

View File

@ -0,0 +1,121 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { getMyAssets } from '../myAssetController'
const createResponse = () => {
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 1, role: 'user', person_id: 8 } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
return { res, state }
}
test('本人资产同时返回当前递归配件和借出时递归快照并标识关系变化', async () => {
const originalPersonFindUnique = prisma.person.findUnique
const originalBorrowFindMany = prisma.borrowRecord.findMany
const originalEquipmentFindMany = prisma.equipment.findMany
const child = {
id: 21,
equipment_code: 'JS-RAM-0021',
equipment_name: '内存',
equipment_model: '32G',
equipment_type: '内存条',
status: '已借出',
parent_id: 20,
mount_status: 'mounted',
department_id: 3,
storage_location_id: 1
}
const grandchild = {
...child,
id: 22,
equipment_code: 'JS-SSD-0022',
equipment_name: '固态硬盘',
equipment_model: '1T',
equipment_type: '固态硬盘',
parent_id: 21
}
;(prisma.person.findUnique as any) = async () => ({
id: 8,
real_name: '测试员工',
nickname: '魔礼红',
job_number: 'E0008',
status: 'active',
source_employee_status: '在职',
mainDepartment: { id: 3, name: '信息部' }
})
;(prisma.borrowRecord.findMany as any) = async () => [{
id: 701,
equipment_id: 20,
borrow_time: new Date('2026-07-01T00:00:00Z'),
expected_return_time: null,
is_long_term: true,
notes: null,
children_snapshot: JSON.stringify([{
id: 21,
equipment_code: child.equipment_code,
equipment_name: child.equipment_name,
equipment_model: child.equipment_model,
equipment_type: child.equipment_type,
parent_id: 20,
depth: 1
}]),
equipment: {
id: 20,
equipment_code: 'JS-PC-0020',
equipment_name: '主机',
children: [child],
storageLocation: { id: 1, name: '仓库', code: 'WH' },
department: { id: 3, name: '信息部' }
}
}]
;(prisma.equipment.findMany as any) = async (args: any) => {
const parentIds = args.where.parent_id.in as number[]
if (parentIds.includes(20)) return [child]
if (parentIds.includes(21)) return [grandchild]
return []
}
try {
const { res, state } = createResponse()
await getMyAssets({ query: {} } as any, res as any)
assert.equal(state.statusCode, 200)
const asset = state.body.data.assets[0]
assert.deepEqual(
asset.current_accessories.map((item: any) => ({ id: item.id, parent_id: item.parent_id, depth: item.depth })),
[
{ id: 21, parent_id: 20, depth: 1 },
{ id: 22, parent_id: 21, depth: 2 }
]
)
assert.deepEqual(
asset.borrowed_accessories_snapshot.map((item: any) => ({
id: item.id,
parent_id: item.parent_id,
root_equipment_id: item.root_equipment_id,
depth: item.depth
})),
[{ id: 21, parent_id: 20, root_equipment_id: 20, depth: 1 }]
)
assert.equal(asset.accessory_relationship_changed, true)
assert.equal(asset.borrowed_accessories_snapshot_valid, true)
assert.equal(asset.children.length, 1)
assert.equal(asset.equipment.children.length, 1)
} finally {
;(prisma.person.findUnique as any) = originalPersonFindUnique
;(prisma.borrowRecord.findMany as any) = originalBorrowFindMany
;(prisma.equipment.findMany as any) = originalEquipmentFindMany
}
})

View File

@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { getPersonOptions } from '../personController'
const createResponse = () => {
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
return { response, state }
}
test('业务人员选项只返回工作所需字段,不泄露联系方式和钉钉标识', async () => {
const originalFindMany = prisma.person.findMany
const originalCount = prisma.person.count
let capturedArgs: any = null
;(prisma.person.findMany as any) = async (args: any) => {
capturedArgs = args
return [{
id: 9,
nickname: '魔礼红',
real_name: '测试人员',
job_number: 'JS009',
id_number: '320000000000000000',
department: '信息部',
main_department_id: 3,
mainDepartment: { id: 3, name: '信息部', status: 'active' },
personDepartments: [],
position: 'IT',
phone: '13800000000',
email: 'private@example.com',
status: 'active',
source_userid: 'ding-private-user',
manager_source_userid: 'ding-manager'
}]
}
;(prisma.person.count as any) = async () => 1
try {
const { response, state } = createResponse()
await getPersonOptions({ query: { page: '2', page_size: '20', keyword: '魔礼红' } } as any, response as any)
assert.equal(state.status, 200)
assert.equal(state.body.data.total, 1)
assert.equal(state.body.data.page, 2)
assert.equal(state.body.data.page_size, 20)
assert.equal(capturedArgs.skip, 20)
assert.equal(capturedArgs.take, 20)
assert.equal(JSON.stringify(capturedArgs.where).includes('魔礼红'), true)
assert.equal(JSON.stringify(capturedArgs.where).includes('__public_asset__'), true)
const option = state.body.data.list[0]
assert.equal(option.dingtalk_ready, true)
for (const sensitiveField of [
'id_number',
'phone',
'email',
'source_userid',
'manager_source_userid',
'remarks'
]) {
assert.equal(sensitiveField in option, false, `不应返回 ${sensitiveField}`)
}
} finally {
;(prisma.person.findMany as any) = originalFindMany
;(prisma.person.count as any) = originalCount
}
})

View File

@ -0,0 +1,189 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { getDashboardStats, getValueStatistics } from '../statisticsController'
test('首页统计应排除已报废库存、完整覆盖在册状态并使用上海自然日', async () => {
const originalAssetTypeFindMany = prisma.assetType.findMany
const originalEquipmentGroupBy = prisma.equipment.groupBy
const originalEquipmentFindMany = prisma.equipment.findMany
const originalEquipmentAggregate = prisma.equipment.aggregate
const originalEquipmentCount = prisma.equipment.count
const originalBorrowRecordFindMany = prisma.borrowRecord.findMany
const borrowRanges: any[] = []
;(prisma.assetType.findMany as any) = async () => [{ name: '内存条' }]
;(prisma.equipment.groupBy as any) = async (args: any) => {
if (args.by.length === 1 && args.by[0] === 'status') {
return [
{ status: '可用', _sum: { total_quantity: 1122, remaining_quantity: 1122, total_value: 424805, borrowed_value: 0, available_value: 424805 } },
{ status: '已借出', _sum: { total_quantity: 4507, remaining_quantity: 0, total_value: 2848258.44, borrowed_value: 2848258.44, available_value: 0 } },
{ status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1, total_value: 0, borrowed_value: 0, available_value: 0 } },
{ status: '已丢失', _sum: { total_quantity: 3, remaining_quantity: 0, total_value: 300, borrowed_value: 0, available_value: 0 } },
{ status: '已报废', _sum: { total_quantity: 127, remaining_quantity: 127, total_value: 83257, borrowed_value: 0, available_value: 0 } }
]
}
if (args.by.includes('status')) {
return [
{ equipment_type: '显示器', status: '可用', _sum: { total_quantity: 10, remaining_quantity: 10 } },
{ equipment_type: '显示器', status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1 } },
{ equipment_type: '仅丢失资产类型', status: '已丢失', _sum: { total_quantity: 3, remaining_quantity: 0 } }
]
}
return [{ equipment_type: '显示器', _sum: { remaining_quantity: 8 } }]
}
;(prisma.equipment.findMany as any) = async () => [
{
status: '可用',
total_value: 424805,
borrowed_value: 0,
available_value: 424805,
total_value_with_children: 0
},
{
status: '已借出',
total_value: 2848998.44,
borrowed_value: 2848998.44,
available_value: 0,
total_value_with_children: 0
}
]
;(prisma.equipment.aggregate as any) = async (args: any) => {
assert.deepEqual(args.where.status.notIn, ['已报废', '已丢失'])
return { _count: { id: 2057 }, _sum: { total_quantity: 2057 } }
}
;(prisma.equipment.count as any) = async () => 3
;(prisma.borrowRecord.findMany as any) = async (args: any) => {
borrowRanges.push(args.where.borrow_time || args.where.return_time)
if (args.where.return_time) {
assert.deepEqual(args.where.return_condition, { not: '丢失' })
}
return args.where.borrow_time
? [{ children_snapshot: '[{"unit_price":20}]', equipment: { unit_price: 100 } }]
: []
}
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
try {
await getDashboardStats({} as any, response as any)
assert.equal(state.status, 200)
assert.equal(state.body.data.total_equipment, 5630)
assert.equal(state.body.data.root_asset_records, 2057)
assert.equal(state.body.data.accessory_equipment, 3573)
assert.equal(state.body.data.available_equipment, 1122)
assert.equal(state.body.data.borrowed_equipment, 4507)
assert.equal(state.body.data.pending_disposal_equipment, 1)
assert.equal(state.body.data.disposed_equipment, 127)
assert.equal(state.body.data.lost_equipment, 3)
assert.equal(state.body.data.other_equipment, 0)
const lostOnlyType = state.body.data.inventory_by_type.find((row: any) => row.equipment_type === '仅丢失资产类型')
assert.equal(lostOnlyType.total_quantity, 0)
assert.equal(lostOnlyType.lost_quantity, 3)
assert.equal(lostOnlyType.other_quantity, 0)
assert.equal(state.body.data.borrowed_without_active_record, 3)
assert.equal(state.body.data.today_borrow, 1)
assert.equal(state.body.data.today_return, 0)
assert.equal(state.body.data.today_borrow_value, 120)
assert.equal(state.body.data.business_timezone, 'Asia/Shanghai')
assert.equal(state.body.data.inventory_scope, 'current_excluding_disposed_and_lost')
assert.equal(state.body.data.value_scope, 'book_value_excluding_disposed_including_lost')
assert.equal(state.body.data.total_value, 3273363.44)
assert.equal(state.body.data.borrowed_value, 2848258.44)
assert.equal(
state.body.data.available_equipment +
state.body.data.borrowed_equipment +
state.body.data.maintenance_equipment +
state.body.data.pending_disposal_equipment +
state.body.data.damaged_equipment +
state.body.data.other_equipment,
state.body.data.total_equipment
)
assert.ok(Math.abs(state.body.data.usage_rate - 80.06750755018653) < 0.000001)
assert.ok(Math.abs(state.body.data.value_usage_rate - 87.021180377732) < 0.000001)
assert.equal(borrowRanges.length, 2)
for (const range of borrowRanges) {
assert.equal(range.gte.getUTCHours(), 16)
assert.equal(range.lt.getTime() - range.gte.getTime(), 24 * 60 * 60 * 1000)
}
} finally {
;(prisma.assetType.findMany as any) = originalAssetTypeFindMany
;(prisma.equipment.groupBy as any) = originalEquipmentGroupBy
;(prisma.equipment.findMany as any) = originalEquipmentFindMany
;(prisma.equipment.aggregate as any) = originalEquipmentAggregate
;(prisma.equipment.count as any) = originalEquipmentCount
;(prisma.borrowRecord.findMany as any) = originalBorrowRecordFindMany
}
})
test('价值统计应按当前资产本体汇总并忽略已报废和过期组合金额缓存', async () => {
const originalEquipmentFindMany = prisma.equipment.findMany
let capturedWhere: any
;(prisma.equipment.findMany as any) = async (args: any) => {
capturedWhere = args.where
return [
{
equipment_name: '显示器',
equipment_type: '显示器',
unit_price: 1500,
total_quantity: 1,
total_value: 1500,
borrowed_value: 1500,
available_value: 0,
total_value_with_children: 2240,
status: '已借出'
},
{
equipment_name: '扩展坞',
equipment_type: '扩展坞',
unit_price: 740,
total_quantity: 1,
total_value: 740,
borrowed_value: 0,
available_value: 740,
total_value_with_children: 0,
status: '可用'
}
]
}
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
try {
await getValueStatistics({ query: { start_date: '2026-07-17', end_date: '2026-07-17' } } as any, response as any)
assert.equal(capturedWhere.status.not, '已报废')
assert.equal(capturedWhere.created_at.gte.toISOString(), '2026-07-16T16:00:00.000Z')
assert.equal(capturedWhere.created_at.lt.toISOString(), '2026-07-17T16:00:00.000Z')
assert.equal(state.body.data.total_value, 2240)
assert.equal(state.body.data.borrowed_value, 1500)
assert.equal(state.body.data.available_value, 740)
assert.equal(state.body.data.value_by_type.length, 2)
assert.equal(state.body.data.top_equipment[0].item_value, 1500)
assert.equal(state.body.data.top_equipment[0].stock_total_value, 1500)
} finally {
;(prisma.equipment.findMany as any) = originalEquipmentFindMany
}
})

View File

@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { updateUser, updateUserStatus } from '../userController'
const createResponse = (currentUserId: number) => {
const state = { status: 200, body: null as any }
const response = {
locals: { user: { id: currentUserId } },
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
return { response, state }
}
test('管理员不能停用或降级当前登录的自己', async () => {
const originalFindUnique = prisma.user.findUnique
const originalUpdate = prisma.user.update
let updateCount = 0
;(prisma.user.findUnique as any) = async () => ({
id: 7,
name: '管理员',
email: null,
phone: null,
role: 'admin',
status: 'active'
})
;(prisma.user.update as any) = async () => {
updateCount += 1
}
try {
const disable = createResponse(7)
await updateUserStatus({ params: { id: '7' }, body: { status: 'inactive' } } as any, disable.response as any)
assert.equal(disable.state.status, 422)
const demote = createResponse(7)
await updateUser({ params: { id: '7' }, body: { role: 'it' } } as any, demote.response as any)
assert.equal(demote.state.status, 422)
assert.equal(updateCount, 0)
} finally {
;(prisma.user.findUnique as any) = originalFindUnique
;(prisma.user.update as any) = originalUpdate
}
})
test('两个管理员并发停用时事务锁保证至少一个仍启用', async () => {
const originalFindUnique = prisma.user.findUnique
const originalTransaction = prisma.$transaction
const activeAdministrators = new Set([7, 8])
let queue = Promise.resolve()
;(prisma.user.findUnique as any) = async ({ where }: any) => ({
id: where.id,
username: `admin-${where.id}`,
name: `管理员${where.id}`,
email: null,
phone: null,
role: 'admin',
status: 'active',
person_id: null,
person: null
})
;(prisma.$transaction as any) = (callback: (tx: any) => Promise<any>) => {
const run = queue.then(() => callback({
$queryRawUnsafe: async () => [...activeAdministrators].map((id) => ({ id })),
user: {
update: async ({ where, data }: any) => {
if (data.status === 'inactive') activeAdministrators.delete(where.id)
return {
id: where.id,
username: `admin-${where.id}`,
name: `管理员${where.id}`,
email: null,
phone: null,
role: 'admin',
status: data.status,
person_id: null,
person: null
}
}
}
}))
queue = run.then(() => undefined, () => undefined)
return run
}
try {
const first = createResponse(99)
const second = createResponse(99)
await Promise.all([
updateUserStatus({ params: { id: '7' }, body: { status: 'inactive' } } as any, first.response as any),
updateUserStatus({ params: { id: '8' }, body: { status: 'inactive' } } as any, second.response as any)
])
const statuses = [first.state.status, second.state.status].sort()
assert.deepEqual(statuses, [200, 409])
assert.equal(activeAdministrators.size, 1)
} finally {
;(prisma.user.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
}
})
test('用户状态只接受 active 和 inactive', async () => {
const { response, state } = createResponse(99)
await updateUserStatus({ params: { id: '7' }, body: { status: 'disabled' } } as any, response as any)
assert.equal(state.status, 422)
assert.match(state.body.message, /active 或 inactive/)
})

View File

@ -0,0 +1,94 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { createUser, updateUser } from '../userController'
const createResponse = () => {
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
return { response, state }
}
test('管理员不能把系统用户绑定到离职或缺少钉钉userId的员工档案', async () => {
const originalUserFindUnique = prisma.user.findUnique
const originalPersonFindUnique = prisma.person.findUnique
const originalUserCreate = prisma.user.create
let createCount = 0
;(prisma.user.findUnique as any) = async () => null
;(prisma.user.create as any) = async () => {
createCount += 1
return null
}
try {
for (const person of [
{ id: 9, status: 'inactive', source_userid: 'ding-user-9' },
{ id: 9, status: 'active', source_userid: null }
]) {
;(prisma.person.findUnique as any) = async () => person
const { response, state } = createResponse()
await createUser({
body: {
username: `test-${person.status}-${person.source_userid || 'empty'}`,
password: 'test-password',
name: '测试用户',
person_id: 9
}
} as any, response as any)
assert.equal(state.status, 422)
assert.match(state.body.message, /在职且启用|缺少钉钉 userId/)
}
assert.equal(createCount, 0)
} finally {
;(prisma.user.findUnique as any) = originalUserFindUnique
;(prisma.person.findUnique as any) = originalPersonFindUnique
;(prisma.user.create as any) = originalUserCreate
}
})
test('并发绑定同一员工触发数据库唯一约束时返回409而不是500', async () => {
const originalUserFindUnique = prisma.user.findUnique
const originalPersonFindUnique = prisma.person.findUnique
const originalUserFindFirst = prisma.user.findFirst
const originalUserUpdate = prisma.user.update
;(prisma.user.findUnique as any) = async () => ({
id: 7,
name: '管理员',
email: null,
phone: null
})
;(prisma.person.findUnique as any) = async () => ({
id: 9,
status: 'active',
source_userid: 'ding-user-9'
})
;(prisma.user.findFirst as any) = async () => null
;(prisma.user.update as any) = async () => {
const error: any = new Error('Unique constraint failed')
error.code = 'P2002'
error.meta = { target: ['person_id'] }
throw error
}
try {
const { response, state } = createResponse()
await updateUser({ params: { id: '7' }, body: { person_id: 9 } } as any, response as any)
assert.equal(state.status, 409)
assert.match(state.body.message, /已绑定其他用户/)
} finally {
;(prisma.user.findUnique as any) = originalUserFindUnique
;(prisma.person.findUnique as any) = originalPersonFindUnique
;(prisma.user.findFirst as any) = originalUserFindFirst
;(prisma.user.update as any) = originalUserUpdate
}
})

View File

@ -0,0 +1,124 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { getPersonBindingOptions, getUserList } from '../userController'
const createResponse = () => {
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 1, role: 'admin' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
return { res, state }
}
test('用户列表服务端分页并支持关键词、角色和状态筛选', async () => {
const originalFindMany = prisma.user.findMany
const originalCount = prisma.user.count
let capturedArgs: any = null
;(prisma.user.findMany as any) = async (args: any) => {
capturedArgs = args
return []
}
;(prisma.user.count as any) = async () => 23
try {
const { res, state } = createResponse()
await getUserList({
query: { page: '3', pageSize: '10', keyword: '魔礼红', role: 'it', status: 'active' }
} as any, res as any)
assert.equal(state.statusCode, 200)
assert.equal(capturedArgs.skip, 20)
assert.equal(capturedArgs.take, 10)
assert.equal(capturedArgs.where.role, 'it')
assert.equal(capturedArgs.where.status, 'active')
assert.match(JSON.stringify(capturedArgs.where.OR), /real_name|nickname|job_number/)
assert.deepEqual(state.body.data, { list: [], total: 23, page: 3, pageSize: 10 })
} finally {
;(prisma.user.findMany as any) = originalFindMany
;(prisma.user.count as any) = originalCount
}
})
test('用户列表缺省参数保持旧调用可用,非法筛选返回 422', async () => {
const originalFindMany = prisma.user.findMany
const originalCount = prisma.user.count
;(prisma.user.findMany as any) = async () => []
;(prisma.user.count as any) = async () => 0
try {
const legacy = createResponse()
await getUserList({ query: {} } as any, legacy.res as any)
assert.equal(legacy.state.statusCode, 200)
assert.equal(legacy.state.body.data.page, 1)
assert.equal(legacy.state.body.data.pageSize, 10)
const invalid = createResponse()
await getUserList({ query: { role: 'super-admin' } } as any, invalid.res as any)
assert.equal(invalid.state.statusCode, 422)
} finally {
;(prisma.user.findMany as any) = originalFindMany
;(prisma.user.count as any) = originalCount
}
})
test('员工绑定选项分页返回就绪和绑定状态但不泄露钉钉 userid', async () => {
const originalFindMany = prisma.person.findMany
const originalCount = prisma.person.count
let capturedArgs: any = null
;(prisma.person.findMany as any) = async (args: any) => {
capturedArgs = args
return [{
id: 8,
real_name: '测试员工',
nickname: '魔礼红',
job_number: 'E0008',
status: 'active',
source_userid: 'secret-dingtalk-userid',
mainDepartment: { id: 3, name: '信息部' },
user: { id: 2, username: 'it-user', name: 'IT用户', role: 'it', status: 'active' }
}]
}
;(prisma.person.count as any) = async () => 1
try {
const { res, state } = createResponse()
await getPersonBindingOptions({
query: {
page: '1',
pageSize: '20',
keyword: '魔礼红',
bound: 'bound',
dingtalk_ready: 'true'
}
} as any, res as any)
assert.equal(state.statusCode, 200)
assert.match(JSON.stringify(capturedArgs.where), /source_userid/)
assert.match(JSON.stringify(capturedArgs.where), /isNot/)
assert.equal(state.body.data.list[0].dingtalk_ready, true)
assert.equal(state.body.data.list[0].is_bound, true)
assert.deepEqual(state.body.data.list[0].bound_user, {
id: 2,
username: 'it-user',
name: 'IT用户',
role: 'it',
status: 'active'
})
assert.equal(JSON.stringify(state.body).includes('secret-dingtalk-userid'), false)
assert.equal(Object.prototype.hasOwnProperty.call(state.body.data.list[0], 'source_userid'), false)
} finally {
;(prisma.person.findMany as any) = originalFindMany
;(prisma.person.count as any) = originalCount
}
})

View File

@ -0,0 +1,130 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { getDisposalList } from '../disposalController'
import { getMaintenanceList } from '../maintenanceController'
const createResponse = (role: string) => {
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 6, role } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
return { res, state }
}
test('报废 summary 按角色范围和关键词统计但不受单个状态筛选影响', async () => {
const originalFindMany = prisma.disposalRecord.findMany
const originalCount = prisma.disposalRecord.count
const originalGroupBy = prisma.disposalRecord.groupBy
let listWhere: any = null
let summaryWhere: any = null
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
listWhere = args.where
return []
}
;(prisma.disposalRecord.count as any) = async () => 2
;(prisma.disposalRecord.groupBy as any) = async (args: any) => {
summaryWhere = args.where
return [
{ status: 'pending', _count: { _all: 2 } },
{ status: 'approved', _count: { _all: 1 } },
{ status: 'completed', _count: { _all: 4 } }
]
}
try {
const { res, state } = createResponse('it')
await getDisposalList({
query: { scope: 'all', status: 'pending', keyword: '主机', page: '2', pageSize: '20' }
} as any, res as any)
assert.equal(state.statusCode, 200)
assert.match(JSON.stringify(listWhere), /pending/)
assert.doesNotMatch(JSON.stringify(summaryWhere), /"status":"pending"/)
assert.match(JSON.stringify(summaryWhere), /"applied_by":6/)
assert.match(JSON.stringify(summaryWhere), /主机/)
assert.deepEqual(state.body.data.summary, {
total: 7,
pending: 2,
approved: 1,
rejected: 0,
completed: 4
})
assert.equal(state.body.data.total, 2)
assert.equal(state.body.data.page, 2)
assert.equal(state.body.data.pageSize, 20)
} finally {
;(prisma.disposalRecord.findMany as any) = originalFindMany
;(prisma.disposalRecord.count as any) = originalCount
;(prisma.disposalRecord.groupBy as any) = originalGroupBy
}
})
test('维修 summary 按关键词和资产统计但不受单个状态筛选影响', async () => {
const originalFindMany = prisma.maintenanceRecord.findMany
const originalCount = prisma.maintenanceRecord.count
const originalGroupBy = prisma.maintenanceRecord.groupBy
let listWhere: any = null
let summaryWhere: any = null
;(prisma.maintenanceRecord.findMany as any) = async (args: any) => {
listWhere = args.where
return []
}
;(prisma.maintenanceRecord.count as any) = async () => 1
;(prisma.maintenanceRecord.groupBy as any) = async (args: any) => {
summaryWhere = args.where
return [
{ status: 'in_progress', _count: { _all: 1 } },
{ status: 'completed', _count: { _all: 3 } },
{ status: 'cancelled', _count: { _all: 2 } }
]
}
try {
const { res, state } = createResponse('it')
await getMaintenanceList({
query: { status: 'in_progress', keyword: '无法开机', equipment_id: '10' }
} as any, res as any)
assert.equal(state.statusCode, 200)
assert.match(JSON.stringify(listWhere), /in_progress/)
assert.doesNotMatch(JSON.stringify(summaryWhere), /in_progress/)
assert.match(JSON.stringify(summaryWhere), /无法开机/)
assert.equal(summaryWhere.equipment_id, 10)
assert.deepEqual(state.body.data.summary, {
total: 6,
in_progress: 1,
completed: 3,
cancelled: 2
})
assert.equal(state.body.data.total, 1)
} finally {
;(prisma.maintenanceRecord.findMany as any) = originalFindMany
;(prisma.maintenanceRecord.count as any) = originalCount
;(prisma.maintenanceRecord.groupBy as any) = originalGroupBy
}
})
test('流程列表非法筛选给出明确 422,维修越权给出 403', async () => {
const disposal = createResponse('admin')
await getDisposalList({ query: { page: '0' } } as any, disposal.res as any)
assert.equal(disposal.state.statusCode, 422)
const maintenanceStatus = createResponse('it')
await getMaintenanceList({ query: { status: 'broken' } } as any, maintenanceStatus.res as any)
assert.equal(maintenanceStatus.state.statusCode, 422)
const maintenanceRole = createResponse('administrative')
await getMaintenanceList({ query: {} } as any, maintenanceRole.res as any)
assert.equal(maintenanceRole.state.statusCode, 403)
})

View File

@ -15,7 +15,18 @@ export const getEquipmentForBarcode = async (req: Request, res: Response) => {
equipment_name: true,
equipment_model: true,
equipment_type: true,
created_at: true
created_at: true,
purchase_date: true,
department: {
select: {
name: true
}
},
storageLocation: {
select: {
name: true
}
}
}
})
@ -28,7 +39,10 @@ export const getEquipmentForBarcode = async (req: Request, res: Response) => {
equipment_name: equipment.equipment_name,
equipment_model: equipment.equipment_model,
equipment_type: equipment.equipment_type,
created_at: equipment.created_at
created_at: equipment.created_at,
purchase_date: equipment.purchase_date,
department: equipment.department,
storageLocation: equipment.storageLocation
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取资产信息失败')
@ -54,7 +68,13 @@ export const getEquipmentBatchForBarcode = async (req: Request, res: Response) =
equipment_model: true,
equipment_type: true,
created_at: true,
purchase_date: true,
storage_location_id: true,
department: {
select: {
name: true
}
},
storageLocation: {
select: {
id: true,
@ -65,17 +85,6 @@ export const getEquipmentBatchForBarcode = async (req: Request, res: Response) =
}
})
// 调试:打印原始查询结果
console.log('条码打印 - 原始查询结果:', JSON.stringify(equipmentList, null, 2))
equipmentList.forEach((eq, index) => {
console.log(`原始资产 ${index + 1} (ID: ${eq.id}):`, {
name: eq.equipment_name,
storage_location_id: eq.storage_location_id,
storageLocation: eq.storageLocation,
allKeys: Object.keys(eq)
})
})
// 直接返回查询结果,不需要再次映射
const result = equipmentList.map(eq => ({
id: eq.id,
@ -84,20 +93,12 @@ export const getEquipmentBatchForBarcode = async (req: Request, res: Response) =
equipment_model: eq.equipment_model,
equipment_type: eq.equipment_type,
created_at: eq.created_at,
purchase_date: eq.purchase_date,
department: eq.department,
storage_location_id: eq.storage_location_id,
storageLocation: eq.storageLocation
}))
// 调试:打印返回的数据
console.log('条码打印 - 最终返回结果:', JSON.stringify(result, null, 2))
result.forEach((eq, index) => {
console.log(`资产 ${index + 1} (ID: ${eq.id}):`, {
name: eq.equipment_name,
storage_location_id: eq.storage_location_id,
storageLocation: eq.storageLocation
})
})
successResponse(res, result, '获取成功')
} catch (error: any) {
console.error('批量获取资产信息失败:', error)

View File

@ -1,7 +1,20 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { parseBusinessDate } from '../utils/businessDate'
import { getReturnChildrenDiff } from '../utils/returnSnapshot'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import {
borrowAssetTree,
finalizeNormalReturnState,
loadAndLockAssetTree,
createAssetMovementRecord,
assertReturnConditionSupported,
transferAssetTree
} from '../services/assetMovementService'
const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => {
const longTerm = Boolean(isLongTerm)
@ -12,80 +25,53 @@ const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => {
: notes || undefined
}
const getChildrenSnapshot = async (equipmentId: number) => {
const children = await prisma.equipment.findMany({
where: { parent_id: equipmentId },
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true
}
})
return children.length > 0 ? JSON.stringify(children) : undefined
}
const batchBorrowTransaction = async (
equipment: any,
equipmentId: number,
person: any,
expectedReturnTime?: string,
expectedReturnTime?: Date | null,
isLongTerm?: boolean,
notes?: string
) => {
const childrenSnapshot = await getChildrenSnapshot(equipment.id)
const finalNotes = buildBorrowNotes(notes, isLongTerm)
return prisma.$transaction(async (tx) => {
await tx.borrowRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.id_number,
expected_return_time: isLongTerm ? null : (expectedReturnTime ? new Date(expectedReturnTime) : null),
is_long_term: Boolean(isLongTerm),
notes: finalNotes,
status: '已借出',
children_snapshot: childrenSnapshot
}
})
const nextStatus = equipment.remaining_quantity === 1 ? '已借出' : '可用'
const updated = await tx.equipment.updateMany({
where: {
id: equipment.id,
status: '可用',
remaining_quantity: equipment.remaining_quantity
},
data: {
remaining_quantity: { decrement: 1 },
borrowed_value: { increment: Number(equipment.unit_price) },
available_value: { decrement: Number(equipment.unit_price) },
status: nextStatus
}
})
if (updated.count !== 1) {
throw new Error('资产库存已变化,请刷新后重试')
}
})
return prisma.$transaction(
(tx) => borrowAssetTree(tx, {
rootEquipmentId: equipmentId,
person,
expectedReturnTime: isLongTerm
? null
: (expectedReturnTime || null),
isLongTerm,
notes: finalNotes
}),
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
)
}
const batchReturnTransaction = async (
equipment: any,
borrowRecord: any,
returnCondition: string,
notes?: string
notes: string | undefined,
operatedBy: number
) => {
return prisma.$transaction(async (tx) => {
const items = await loadAndLockAssetTree(tx, equipment.id)
assertReturnConditionSupported(items, returnCondition)
if (getReturnChildrenDiff(items, borrowRecord)) {
throw Object.assign(new Error('配件与借出时不一致,请使用单个归还确认'), { status: 409 })
}
await createAssetMovementRecord(tx, {
items,
actionType: returnCondition === '丢失' ? 'loss' : 'return',
toDepartmentId: null,
operatedBy,
reason: returnCondition === '丢失' ? `丢失登记(未入仓)${notes ? `;${notes}` : ''}` : notes || '批量归还入仓'
})
const currentRoot = items[0]
const updatedRecord = await tx.borrowRecord.updateMany({
where: {
id: borrowRecord.id,
equipment_id: currentRoot.id,
status: '已借出'
},
data: {
@ -100,23 +86,8 @@ const batchReturnTransaction = async (
throw new Error('该借用记录已归还,请刷新后重试')
}
const updatedEquipment = await tx.equipment.updateMany({
where: {
id: equipment.id,
remaining_quantity: { lt: equipment.total_quantity }
},
data: {
remaining_quantity: { increment: 1 },
borrowed_value: { decrement: Number(equipment.unit_price) },
available_value: { increment: Number(equipment.unit_price) },
status: '可用'
}
})
if (updatedEquipment.count !== 1) {
throw new Error('资产库存已达到上限,请检查归还记录')
}
})
await finalizeNormalReturnState(tx, currentRoot, returnCondition, items)
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
// 批量借出
@ -128,6 +99,7 @@ export const batchBorrow = async (req: Request, res: Response) => {
return errorResponse(res, '请选择要借出的资产')
}
const expectedReturn = is_long_term ? null : parseBusinessDate(expected_return_time, '预计归还时间')
const person = await prisma.person.findUnique({ where: { id: Number(person_id) } })
if (!person || person.status !== 'active') {
return errorResponse(res, '使用者不存在或已禁用')
@ -147,6 +119,17 @@ export const batchBorrow = async (req: Request, res: Response) => {
continue
}
if (equipment.parent_id) {
results.push({
equipment_id: equipmentId,
equipment_code: equipment.equipment_code,
status: 'failed',
message: '该子资产当前已挂载,请先卸载后再借用'
})
failedCount++
continue
}
if (equipment.status !== '可用' || equipment.remaining_quantity <= 0) {
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'failed', message: '资产不可借用' })
failedCount++
@ -154,9 +137,9 @@ export const batchBorrow = async (req: Request, res: Response) => {
}
await batchBorrowTransaction(
equipment,
equipment.id,
person,
expected_return_time,
expectedReturn,
is_long_term,
notes
)
@ -187,7 +170,7 @@ export const batchBorrow = async (req: Request, res: Response) => {
successResponse(res, { success_count: successCount, failed_count: failedCount, results }, '批量借出完成')
} catch (error: any) {
errorResponse(res, error.message || '批量借出失败')
errorResponse(res, error.message || '批量借出失败', error.status || 400)
}
}
@ -196,11 +179,18 @@ export const batchReturn = async (req: Request, res: Response) => {
try {
const { equipment_ids, return_condition = '正常', notes } = req.body
try {
assertReturnStorageDepartmentContract(req.body || {})
} catch (error: any) {
return errorResponse(res, error.message || '归还入仓部门契约无效', 422)
}
if (!Array.isArray(equipment_ids) || equipment_ids.length === 0) {
return errorResponse(res, '请选择要归还的资产')
}
const results: any[] = []
const operatedBy = Number(res.locals.user?.id)
let successCount = 0
let failedCount = 0
@ -233,9 +223,9 @@ export const batchReturn = async (req: Request, res: Response) => {
continue
}
await batchReturnTransaction(equipment, borrowRecord, return_condition, notes)
await batchReturnTransaction(equipment, borrowRecord, return_condition, notes, operatedBy)
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '归还成功' })
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: return_condition === '丢失' ? '丢失已登记(未入仓)' : '归还成功' })
successCount++
} catch (error: any) {
results.push({ equipment_id: equipmentId, status: 'failed', message: error.message })
@ -268,16 +258,33 @@ export const batchReturn = async (req: Request, res: Response) => {
// 批量转移部门
export const batchTransfer = async (req: Request, res: Response) => {
try {
const { equipment_ids, department_id } = req.body
const { equipment_ids, department_id, target_type = 'department', reason } = req.body
if (!Array.isArray(equipment_ids) || equipment_ids.length === 0) {
return errorResponse(res, '请选择要转移的资产')
}
const department = await prisma.department.findUnique({ where: { id: Number(department_id) } })
if (!department || department.status !== 'active') {
return errorResponse(res, '目标部门不存在或已停用')
if (!reason || typeof reason !== 'string' || !reason.trim()) {
return errorResponse(res, '调拨原因不能为空', 422)
}
if (!['department', 'idle'].includes(target_type)) {
return errorResponse(res, '调拨目标类型无效', 422)
}
const department = target_type === 'department'
? await prisma.department.findFirst({
where: {
id: Number(department_id),
status: 'active',
...buildSyncedBusinessDepartmentWhere()
}
})
: null
if (target_type === 'department' && !department) {
return errorResponse(res, '目标部门不存在或已停用', 422)
}
const operatedBy = Number(res.locals.user?.id)
const results: any[] = []
let successCount = 0
@ -285,20 +292,24 @@ export const batchTransfer = async (req: Request, res: Response) => {
for (const equipmentId of equipment_ids) {
try {
const equipment = await prisma.equipment.findUnique({ where: { id: Number(equipmentId) } })
if (!equipment) {
results.push({ equipment_id: equipmentId, status: 'failed', message: '资产不存在' })
failedCount++
continue
}
const movement = await prisma.$transaction(
(tx) => transferAssetTree(tx, {
rootEquipmentId: Number(equipmentId),
actionType: target_type === 'idle' ? 'idle' : 'transfer',
toDepartmentId: department?.id || null,
toDepartmentName: department?.name || null,
operatedBy,
reason: reason.trim()
}),
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
)
await prisma.equipment.update({
where: { id: equipment.id },
data: { department_id: department.id }
results.push({
equipment_id: equipmentId,
equipment_code: movement.items[0]?.equipment_code,
status: 'success',
message: target_type === 'idle' ? '已转为闲置' : '转移成功'
})
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '转移成功' })
successCount++
} catch (error: any) {
results.push({ equipment_id: equipmentId, status: 'failed', message: error.message })
@ -307,7 +318,7 @@ export const batchTransfer = async (req: Request, res: Response) => {
}
// 记录操作日志
const userId = res.locals.user?.id
const userId = operatedBy
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
const successCodes = results.filter(r => r.status === 'success').map(r => r.equipment_code).join(', ')
if (successCount > 0) {
@ -315,7 +326,9 @@ export const batchTransfer = async (req: Request, res: Response) => {
userId,
username,
'批量转移',
`批量转移 ${successCount} 件资产到部门 ${department.name},资产编码:${successCodes}`,
target_type === 'idle'
? `批量将 ${successCount} 件资产转为闲置,资产编码:${successCodes}`
: `批量转移 ${successCount} 件资产到部门 ${department?.name || ''},资产编码:${successCodes}`,
undefined,
undefined,
req.ip

View File

@ -2,10 +2,14 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { generateEquipmentCode } from '../utils/equipmentCode'
import { calculateEquipmentValues } from '../utils/assetValue'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
interface PersonImportData {
source_row?: number
nickname: string
real_name: string
job_number?: string
id_number?: string
department?: string
position?: string
@ -14,18 +18,28 @@ interface PersonImportData {
}
interface EquipmentImportData {
source_row?: number
equipment_name: string
equipment_type: string
equipment_model?: string
department?: string
storage_location?: string
unit_price?: number
unit_price?: number | string
total_quantity: number
supplier?: string
purchase_date?: string
description?: string
}
const normalizeOptionalText = (value: unknown) => {
if (value === undefined || value === null) return null
const text = String(value).trim()
return text || null
}
const getSourceRow = (value: unknown, index: number) =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 2 ? value : index + 2
// 批量导入使用者
export const batchImportPersons = async (req: Request, res: Response) => {
try {
@ -35,11 +49,19 @@ export const batchImportPersons = async (req: Request, res: Response) => {
return errorResponse(res, '导入数据不能为空')
}
const activeDepartments = await prisma.department.findMany({
where: {
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
},
select: { id: true, name: true }
})
const departmentMap = new Map(activeDepartments.map((department) => [department.name, department.id]))
const results = { success: 0, failed: 0, errors: [] as { row: number; message: string }[] }
for (let i = 0; i < persons.length; i++) {
const person = persons[i]
const rowNum = i + 2 // Excel行号从2开始(第1行是表头)
const rowNum = getSourceRow(person?.source_row, i)
try {
// 验证必填字段
@ -50,27 +72,56 @@ export const batchImportPersons = async (req: Request, res: Response) => {
}
// 检查工号是否重复
if (person.id_number) {
const normalizedJobNumber = normalizeOptionalText(person.job_number)
if (normalizedJobNumber) {
const existing = await prisma.person.findUnique({
where: { id_number: person.id_number }
where: { job_number: normalizedJobNumber }
})
if (existing) {
results.errors.push({ row: rowNum, message: `工号 ${person.id_number} 已存在` })
results.errors.push({ row: rowNum, message: `工号 ${normalizedJobNumber} 已存在` })
results.failed++
continue
}
}
await prisma.person.create({
data: {
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number || null,
department: person.department || null,
position: person.position || null,
phone: person.phone || null,
email: person.email || null,
status: 'active'
const normalizedDepartment = normalizeOptionalText(person.department)
const matchedDepartmentId = normalizedDepartment
? departmentMap.get(normalizedDepartment) || null
: null
await prisma.$transaction(async (tx) => {
const normalizedIdNumber = normalizeOptionalText(person.id_number)
if (normalizedIdNumber) {
const existingByIdNumber = await tx.person.findUnique({
where: { id_number: normalizedIdNumber }
})
if (existingByIdNumber) {
throw new Error(`身份证号 ${normalizedIdNumber} 已存在`)
}
}
const created = await tx.person.create({
data: {
nickname: person.nickname,
real_name: person.real_name,
job_number: normalizedJobNumber,
id_number: normalizedIdNumber,
department: normalizedDepartment,
main_department_id: matchedDepartmentId,
position: person.position || null,
phone: person.phone || null,
email: person.email || null,
status: 'active'
}
})
if (matchedDepartmentId) {
await tx.personDepartment.create({
data: {
person_id: created.id,
department_id: matchedDepartmentId
}
})
}
})
results.success++
@ -111,11 +162,15 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
// 预加载资产类型、部门、地址映射
const [assetTypes, departments, locations] = await Promise.all([
prisma.assetType.findMany({ where: { status: 'active' } }),
prisma.department.findMany({ where: { status: 'active' } }),
prisma.department.findMany({
where: {
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
}
}),
prisma.storageLocation.findMany({ where: { status: 'active' } })
])
const assetTypeMap = new Map(assetTypes.map(t => [t.name, t.id]))
const assetTypeMap = new Map(assetTypes.map((type) => [type.name, type]))
const departmentMap = new Map(departments.map(d => [d.name, d.id]))
const locationMap = new Map(locations.map(l => [l.name, l.id]))
@ -123,27 +178,52 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
for (let i = 0; i < equipments.length; i++) {
const eq = equipments[i]
const rowNum = i + 2
const rowNum = getSourceRow(eq?.source_row, i)
try {
// 验证必填字段
if (!eq.equipment_name || !eq.equipment_type || !eq.total_quantity) {
if (!eq || !normalizeOptionalText(eq.equipment_name) || !normalizeOptionalText(eq.equipment_type)) {
results.errors.push({ row: rowNum, message: '资产名称、资产类型、数量为必填项' })
results.failed++
continue
}
// 查找资产类型
const assetTypeId = assetTypeMap.get(eq.equipment_type)
if (!assetTypeId) {
const assetType = assetTypeMap.get(eq.equipment_type)
if (!assetType) {
results.errors.push({ row: rowNum, message: `资产类型 "${eq.equipment_type}" 不存在` })
results.failed++
continue
}
const quantityInput = eq.total_quantity
const totalQuantity = typeof quantityInput === 'number' || (typeof quantityInput === 'string' && String(quantityInput).trim())
? Number(quantityInput) : NaN
if (!Number.isSafeInteger(totalQuantity) || totalQuantity <= 0) {
results.errors.push({ row: rowNum, message: '数量必须是大于0的整数' })
results.failed++
continue
}
const unitPrice = eq.unit_price === undefined || eq.unit_price === null ? 0 : Number(eq.unit_price)
if (!Number.isFinite(unitPrice) || unitPrice < 0) {
results.errors.push({ row: rowNum, message: '单价必须是大于或等于0的有效数字' })
results.failed++
continue
}
if (assetType.can_be_child && totalQuantity !== 1) {
results.errors.push({ row: rowNum, message: `子资产类型 "${eq.equipment_type}" 导入时数量必须为 1` })
results.failed++
continue
}
// 查找部门(可选)
let departmentId: number | null = null
if (eq.department) {
if (res.locals.user?.role === 'administrative') {
results.errors.push({ row: rowNum, message: '行政导入资产时不能直接分配部门,请导入后由 IT 调拨' })
results.failed++
continue
}
departmentId = departmentMap.get(eq.department) || null
if (!departmentId) {
results.errors.push({ row: rowNum, message: `部门 "${eq.department}" 不存在` })
@ -167,9 +247,12 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
const equipmentCode = await generateEquipmentCode(eq.equipment_type, prisma, locationId)
// 计算价值
const unitPrice = Number(eq.unit_price) || 0
const totalQuantity = Number(eq.total_quantity) || 1
const totalValue = unitPrice * totalQuantity
const values = calculateEquipmentValues({
unitPrice,
totalQuantity,
remainingQuantity: totalQuantity,
status: '可用'
})
await prisma.equipment.create({
data: {
@ -181,16 +264,16 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
unit_price: unitPrice,
total_quantity: totalQuantity,
remaining_quantity: totalQuantity,
total_value: totalValue,
borrowed_value: 0,
available_value: totalValue,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
supplier: eq.supplier || null,
purchase_date: eq.purchase_date ? new Date(eq.purchase_date) : null,
description: eq.description || null,
status: '可用',
storage_location_id: locationId,
department_id: departmentId,
asset_type_id: assetTypeId
asset_type_id: assetType.id
}
})
results.success++

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { buildBusinessDepartmentWhere } from '../utils/organization'
const baseSelect = {
id: true,
@ -15,6 +16,51 @@ const baseSelect = {
updated_at: true
}
const departmentCollator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base'
})
const personCollator = new Intl.Collator('zh-CN', {
numeric: true,
sensitivity: 'base'
})
const resolveBusinessDepartmentIdsForPerson = (
person: {
main_department_id: number | null
personDepartments: Array<{
department: {
id: number
} | null
}>
},
departmentIdSet: Set<number>
) => {
const ids = new Set<number>()
if (person.main_department_id && departmentIdSet.has(person.main_department_id)) {
ids.add(person.main_department_id)
}
person.personDepartments.forEach((item) => {
if (item.department && departmentIdSet.has(item.department.id)) {
ids.add(item.department.id)
}
})
return [...ids]
}
const hasAnyLinkedDepartment = (person: {
main_department_id: number | null
personDepartments: Array<{
department: {
id: number
} | null
}>
}) => Boolean(person.main_department_id) || person.personDepartments.some((item) => Boolean(item.department))
const buildDepartmentLevelMap = (departments: Array<{ id: number; parent_id: number | null }>) => {
const byId = new Map(departments.map((dept) => [dept.id, dept]))
const cache = new Map<number, number>()
@ -43,11 +89,49 @@ const buildDepartmentTree = (
name: string
status: string
parent_id: number | null
source_system?: string | null
created_at: Date
updated_at: Date
_count: { equipments: number; children: number }
_count: { equipments: number; children: number; personDepartments: number }
}>
) => {
const compareDepartments = (
a: typeof departments[number],
b: typeof departments[number]
) => {
if (a.status !== b.status) {
return a.status === 'active' ? -1 : 1
}
const aHasChildren = a._count.children > 0
const bHasChildren = b._count.children > 0
if (aHasChildren !== bHasChildren) {
return aHasChildren ? -1 : 1
}
const aInUse = a._count.personDepartments > 0 || a._count.equipments > 0
const bInUse = b._count.personDepartments > 0 || b._count.equipments > 0
if (aInUse !== bInUse) {
return aInUse ? -1 : 1
}
const aSynced = Boolean(a.source_system)
const bSynced = Boolean(b.source_system)
if (aSynced !== bSynced) {
return aSynced ? -1 : 1
}
if (a._count.personDepartments !== b._count.personDepartments) {
return b._count.personDepartments - a._count.personDepartments
}
if (a._count.equipments !== b._count.equipments) {
return b._count.equipments - a._count.equipments
}
return departmentCollator.compare(a.name, b.name)
}
const childrenMap = new Map<number | null, typeof departments>()
for (const department of departments) {
const siblings = childrenMap.get(department.parent_id) || []
@ -55,21 +139,262 @@ const buildDepartmentTree = (
childrenMap.set(department.parent_id, siblings)
}
childrenMap.forEach((siblings, key) => {
childrenMap.set(key, [...siblings].sort(compareDepartments))
})
const buildNode = (department: typeof departments[number]): any => ({
...department,
equipment_count: department._count.equipments,
children_count: department._count.children,
person_count: department._count.personDepartments,
children: (childrenMap.get(department.id) || []).map(buildNode)
})
return (childrenMap.get(null) || []).map(buildNode)
}
const buildOrganizationTree = (
departments: Array<{
id: number
name: string
status: string
parent_id: number | null
source_system?: string | null
created_at: Date
updated_at: Date
last_synced_at?: Date | null
source_group_id?: number | null
source_parent_group_id?: number | null
_count: { equipments: number; children: number; personDepartments: number }
}>,
people: Array<{
id: number
nickname: string
real_name: string
id_number: string | null
department: string | null
main_department_id: number | null
position: string | null
phone: string | null
email: string | null
status: string
remarks: string | null
source_userid: string | null
source_system: string | null
source_main_department_id: number | null
source_dept_ids: string | null
source_department_name: string | null
source_main_department_name: string | null
department_sync_bucket: string | null
manager_source_userid: string | null
source_employee_status: string | null
last_synced_at: Date | null
created_at: Date
updated_at: Date
mainDepartment: {
id: number
name: string
status: string
parent_id: number | null
source_group_id: number | null
source_system: string | null
last_synced_at: Date | null
} | null
personDepartments: Array<{
department: {
id: number
name: string
status: string
parent_id: number | null
source_group_id: number | null
source_system: string | null
last_synced_at: Date | null
} | null
}>
}>
) => {
const compareDepartments = (
a: typeof departments[number],
b: typeof departments[number]
) => {
if (a.status !== b.status) {
return a.status === 'active' ? -1 : 1
}
const aHasChildren = a._count.children > 0
const bHasChildren = b._count.children > 0
if (aHasChildren !== bHasChildren) {
return aHasChildren ? -1 : 1
}
const aInUse = a._count.personDepartments > 0 || a._count.equipments > 0
const bInUse = b._count.personDepartments > 0 || b._count.equipments > 0
if (aInUse !== bInUse) {
return aInUse ? -1 : 1
}
const aSynced = Boolean(a.source_system)
const bSynced = Boolean(b.source_system)
if (aSynced !== bSynced) {
return aSynced ? -1 : 1
}
if (a._count.personDepartments !== b._count.personDepartments) {
return b._count.personDepartments - a._count.personDepartments
}
if (a._count.equipments !== b._count.equipments) {
return b._count.equipments - a._count.equipments
}
return departmentCollator.compare(a.name, b.name)
}
const comparePeople = (a: typeof people[number], b: typeof people[number]) => {
if (a.status !== b.status) {
return a.status === 'active' ? -1 : 1
}
const aSynced = Boolean(a.source_system)
const bSynced = Boolean(b.source_system)
if (aSynced !== bSynced) {
return aSynced ? -1 : 1
}
return personCollator.compare(`${a.nickname} ${a.real_name}`, `${b.nickname} ${b.real_name}`)
}
const childrenMap = new Map<number | null, typeof departments>()
for (const department of departments) {
const siblings = childrenMap.get(department.parent_id) || []
siblings.push(department)
childrenMap.set(department.parent_id, siblings)
}
childrenMap.forEach((siblings, key) => {
childrenMap.set(key, [...siblings].sort(compareDepartments))
})
const departmentIdSet = new Set(departments.map((department) => department.id))
const peopleByDepartmentId = new Map<number, typeof people>()
const unassignedPeople: typeof people = []
for (const person of people) {
const departmentIds = resolveBusinessDepartmentIdsForPerson(person, departmentIdSet)
if (departmentIds.length === 0) {
if (!hasAnyLinkedDepartment(person)) {
unassignedPeople.push(person)
}
continue
}
departmentIds.forEach((departmentId) => {
const bucket = peopleByDepartmentId.get(departmentId) || []
bucket.push(person)
peopleByDepartmentId.set(departmentId, bucket)
})
}
peopleByDepartmentId.forEach((bucket, departmentId) => {
peopleByDepartmentId.set(departmentId, [...bucket].sort(comparePeople))
})
const serializeOrganizationPerson = (person: typeof people[number]) => {
const departments = Array.from(
new Map(
person.personDepartments
.map((item) => item.department)
.filter(
(
department
): department is NonNullable<(typeof person.personDepartments)[number]['department']> => Boolean(department)
)
.map((department) => [department.id, department] as const)
).values()
)
return {
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number,
department: person.mainDepartment?.name || person.department || departments[0]?.name || null,
main_department_id: person.main_department_id,
main_department: person.mainDepartment
? {
id: person.mainDepartment.id,
name: person.mainDepartment.name,
status: person.mainDepartment.status,
parent_id: person.mainDepartment.parent_id,
source_group_id: person.mainDepartment.source_group_id,
source_system: person.mainDepartment.source_system,
last_synced_at: person.mainDepartment.last_synced_at
}
: null,
departments,
department_names: departments.map((department) => department.name),
position: person.position,
phone: person.phone,
email: person.email,
status: person.status,
remarks: person.remarks,
source_userid: person.source_userid,
source_system: person.source_system,
source_main_department_id: person.source_main_department_id,
source_dept_ids: person.source_dept_ids,
source_department_name: person.source_department_name,
source_main_department_name: person.source_main_department_name,
department_sync_bucket: person.department_sync_bucket,
manager_source_userid: person.manager_source_userid,
source_employee_status: person.source_employee_status,
last_synced_at: person.last_synced_at,
is_synced: Boolean(person.source_system),
created_at: person.created_at,
updated_at: person.updated_at
}
}
const normalizedUnassignedPeople = [...unassignedPeople].sort(comparePeople).map(serializeOrganizationPerson)
const buildNode = (department: typeof departments[number]): any => {
const normalizedPeople = (peopleByDepartmentId.get(department.id) || []).map(serializeOrganizationPerson)
const childNodes = (childrenMap.get(department.id) || []).map(buildNode)
const totalPersonCount =
normalizedPeople.length + childNodes.reduce((sum: number, child: any) => sum + child.total_person_count, 0)
return {
...department,
equipment_count: department._count.equipments,
children_count: department._count.children,
person_count: department._count.personDepartments,
direct_person_count: normalizedPeople.length,
total_person_count: totalPersonCount,
people: normalizedPeople,
children: childNodes
}
}
const tree = (childrenMap.get(null) || []).map(buildNode)
return {
departments: tree,
unassigned_people: normalizedUnassignedPeople
}
}
// 获取启用的部门列表(下拉选项,含层级信息)
export const getDepartmentOptions = async (_req: Request, res: Response) => {
try {
const departments = await prisma.department.findMany({
where: { status: 'active' },
where: {
AND: [
buildBusinessDepartmentWhere(),
{ status: 'active' }
]
},
orderBy: [{ parent_id: 'asc' }, { name: 'asc' }],
select: {
...baseSelect,
@ -96,11 +421,12 @@ export const getDepartmentOptions = async (_req: Request, res: Response) => {
export const getDepartmentTree = async (_req: Request, res: Response) => {
try {
const departments = await prisma.department.findMany({
orderBy: { name: 'asc' },
where: buildBusinessDepartmentWhere(),
orderBy: [{ parent_id: 'asc' }, { name: 'asc' }],
select: {
...baseSelect,
_count: {
select: { equipments: true, children: true }
select: { equipments: true, children: true, personDepartments: true }
}
}
})
@ -113,17 +439,101 @@ export const getDepartmentTree = async (_req: Request, res: Response) => {
}
}
export const getOrganizationTree = async (_req: Request, res: Response) => {
try {
const [departments, allPeople] = await Promise.all([
prisma.department.findMany({
where: buildBusinessDepartmentWhere(),
orderBy: [{ parent_id: 'asc' }, { name: 'asc' }],
select: {
...baseSelect,
_count: {
select: { equipments: true, children: true, personDepartments: true }
}
}
}),
prisma.person.findMany({
orderBy: [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }],
include: {
mainDepartment: {
select: {
id: true,
name: true,
status: true,
parent_id: true,
source_group_id: true,
source_system: true,
last_synced_at: true
}
},
personDepartments: {
select: {
department: {
select: {
id: true,
name: true,
status: true,
parent_id: true,
source_group_id: true,
source_system: true,
last_synced_at: true
}
}
}
}
}
})
])
const businessDepartmentIdSet = new Set(departments.map((department) => department.id))
const people = allPeople.filter((person) => {
const businessDepartmentIds = resolveBusinessDepartmentIdsForPerson(person, businessDepartmentIdSet)
if (businessDepartmentIds.length > 0) {
return true
}
return !hasAnyLinkedDepartment(person)
})
const tree = buildOrganizationTree(departments, people as any)
const activePersonTotal = people.filter((person) => person.status === 'active').length
const syncedPersonTotal = people.filter((person) => Boolean(person.source_system)).length
successResponse(
res,
{
...tree,
summary: {
department_total: departments.length,
active_department_total: departments.filter((department) => department.status === 'active').length,
person_total: people.length,
active_person_total: activePersonTotal,
inactive_person_total: people.length - activePersonTotal,
synced_person_total: syncedPersonTotal,
local_person_total: people.length - syncedPersonTotal,
unassigned_person_total: tree.unassigned_people.length
}
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取组织架构失败')
}
}
// 获取部门列表(分页)
export const getDepartmentList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, keyword, status } = req.query
const where: any = {}
const where: any = {
AND: [buildBusinessDepartmentWhere()]
}
if (status) {
where.status = status
where.AND.push({ status })
}
if (keyword) {
where.name = { contains: keyword as string }
where.AND.push({ name: { contains: keyword as string } })
}
const skip = (Number(page) - 1) * Number(pageSize)
@ -139,11 +549,11 @@ export const getDepartmentList = async (req: Request, res: Response) => {
parent: {
select: { id: true, name: true }
},
_count: {
select: { equipments: true, children: true }
}
_count: {
select: { equipments: true, children: true, personDepartments: true }
}
}),
}
}),
prisma.department.count({ where })
])
@ -153,7 +563,8 @@ export const getDepartmentList = async (req: Request, res: Response) => {
parent_name: dept.parent?.name || null,
level: levelMap.get(dept.id) || 0,
equipment_count: dept._count.equipments,
children_count: dept._count.children
children_count: dept._count.children,
person_count: dept._count.personDepartments
}))
successResponse(
@ -175,8 +586,11 @@ export const getDepartmentList = async (req: Request, res: Response) => {
const validateParentDepartment = async (parentId: number | null | undefined, currentId?: number): Promise<string | null> => {
if (!parentId) return null
const parent = await prisma.department.findUnique({
where: { id: parentId },
const parent = await prisma.department.findFirst({
where: {
id: parentId,
...buildBusinessDepartmentWhere()
},
select: { id: true, parent_id: true, status: true }
})
@ -257,8 +671,11 @@ export const updateDepartment = async (req: Request, res: Response) => {
const { id } = req.params
const { name, status, parent_id } = req.body as { name?: string; status?: string; parent_id?: number | null }
const existing = await prisma.department.findUnique({
where: { id: Number(id) },
const existing = await prisma.department.findFirst({
where: {
id: Number(id),
...buildBusinessDepartmentWhere()
},
include: {
_count: { select: { children: true } }
}
@ -313,11 +730,14 @@ export const deleteDepartment = async (req: Request, res: Response) => {
try {
const { id } = req.params
const existing = await prisma.department.findUnique({
where: { id: Number(id) },
const existing = await prisma.department.findFirst({
where: {
id: Number(id),
...buildBusinessDepartmentWhere()
},
include: {
_count: {
select: { equipments: true, children: true }
select: { equipments: true, children: true, mainPersons: true, personDepartments: true }
}
}
})
@ -335,6 +755,10 @@ export const deleteDepartment = async (req: Request, res: Response) => {
return errorResponse(res, '该部门已被资产使用,无法删除')
}
if (existing._count.mainPersons > 0 || existing._count.personDepartments > 0) {
return errorResponse(res, '该部门已被人员主数据使用,无法删除')
}
await prisma.department.delete({
where: { id: Number(id) }
})

View File

@ -0,0 +1,401 @@
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { errorResponse, successResponse } from '../utils/response'
import { resolveAuthUserById } from '../utils/authIdentity'
import { getDingTalkConfigStatus, getDingTalkPublicConfig } from '../services/dingtalkClient'
import {
NotificationCenterError,
getNotificationCenterLoginConfigStatus,
isNotificationCenterLoginAllowedForUser,
prepareNotificationCenterManualRetryPayload,
prepareNotificationCenterQueryResumePayload,
prepareNotificationCenterResumePayload,
redeemYouyidianLoginTicket
} from '../services/youyidianNotificationClient'
import { findOrProvisionEligibleDingTalkLoginUser } from '../services/dingtalkBrokerLoginService'
import {
buildDingTalkBrokerCompletionPath,
normalizeInternalRedirect
} from '../utils/dingtalkRedirect'
const establishUserSession = async (
req: Request,
userId: number,
authSource?: 'dingtalk',
dingtalkUserId?: string
) => {
await new Promise<void>((resolve, reject) => {
;((req as any).session as any).regenerate((error?: Error) => {
if (error) return reject(error)
resolve()
})
})
;((req as any).session as any).user = authSource
? { id: userId, auth_source: authSource, dingtalk_userid: dingtalkUserId }
: { id: userId }
await new Promise<void>((resolve, reject) => {
;((req as any).session as any).save((error?: Error) => {
if (error) return reject(error)
resolve()
})
})
}
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[character] || character))
const sendBrokerLoginError = (res: Response, status: number, message: string) => {
res.set({
'Cache-Control': 'no-store',
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff'
})
return res.status(status).type('html').send(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>固定资产免登失败</title>
<style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0f14;color:#f3f4f6;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;padding:20px;box-sizing:border-box}
main{width:min(420px,100%);padding:24px;border:1px solid #253041;border-radius:14px;background:#111821;box-sizing:border-box}
h1{font-size:20px;margin:0 0 12px}p{font-size:14px;line-height:1.7;color:#aab4c3;margin:0 0 20px}
a{display:block;text-align:center;padding:10px 14px;border:1px solid #334155;border-radius:9px;color:#e5e7eb;text-decoration:none}
</style>
</head>
<body><main><h1>暂时无法进入固定资产系统</h1><p>${escapeHtml(message)}</p><a href="/login">返回系统登录页</a></main></body>
</html>`)
}
export const getPublicDingTalkConfig = (req: Request, res: Response) => {
try {
const safeRedirect = normalizeInternalRedirect(
req.query.redirectPath ?? req.query.redirect ?? '/dashboard'
)
successResponse(res, getDingTalkPublicConfig(safeRedirect), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取钉钉免登配置失败', 422)
}
}
export const loginWithYouyidianTicket = async (req: Request, res: Response) => {
res.set({
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff'
})
try {
const ticket = typeof req.body?.ticket === 'string' ? req.body.ticket.trim() : ''
if (!/^[A-Za-z0-9_-]{32,256}$/.test(ticket)) {
return sendBrokerLoginError(res, 422, '登录凭证格式无效,请返回钉钉重新打开通知。')
}
const redeemed = await redeemYouyidianLoginTicket(ticket)
const safeRedirect = normalizeInternalRedirect(redeemed.redirectPath)
if (!isNotificationCenterLoginAllowedForUser(redeemed.dingtalkUserId)) {
return sendBrokerLoginError(res, 403, '当前钉钉账号不在本次免登灰度范围内,请使用原登录方式。')
}
const user = await findOrProvisionEligibleDingTalkLoginUser(redeemed.dingtalkUserId)
if (!user) {
return sendBrokerLoginError(res, 403, '当前钉钉账号未匹配到启用且在职的花名册人员,请联系管理员。')
}
await resolveAuthUserById(user.id)
await prisma.user.update({
where: { id: user.id },
data: { last_login_at: new Date() }
})
await establishUserSession(req, user.id, 'dingtalk', redeemed.dingtalkUserId)
return res.redirect(303, buildDingTalkBrokerCompletionPath(safeRedirect))
} catch (error: any) {
if (error instanceof NotificationCenterError) {
if (error.code === 'CONFIG_INCOMPLETE') {
return sendBrokerLoginError(res, 503, '统一通知登录服务尚未配置完整,请联系管理员。')
}
if (error.code === 'TIMEOUT') {
return sendBrokerLoginError(res, 504, '统一通知登录服务响应超时,请返回钉钉后重试。')
}
if (error.code === 'NETWORK') {
return sendBrokerLoginError(res, 502, '暂时无法连接统一通知登录服务,请稍后重试。')
}
if (error.code === 'TICKET_INVALID_OR_EXPIRED') {
return sendBrokerLoginError(res, 401, '登录凭证无效或已过期,请返回钉钉重新打开通知。')
}
if (error.httpStatus === 401) {
return sendBrokerLoginError(res, 503, '统一通知登录服务认证配置异常,请联系管理员。')
}
return sendBrokerLoginError(res, 502, '统一通知登录服务暂时异常,请稍后重试。')
}
const status = Number.isInteger(error?.status) ? error.status : 500
return sendBrokerLoginError(
res,
status,
status === 403 ? '当前系统账号不可用,请联系管理员。' : '建立登录会话失败,请稍后重试。'
)
}
}
export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) => {
try {
const currentProvider = { provider_type: 'notification_center' }
const [pending, failed, sent, read, unread, errorGroups] = await Promise.all([
prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: { in: ['pending', 'retrying', 'sending'] } } }),
prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: 'failed' } }),
prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: 'sent' } }),
prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: 'read' } }),
prisma.dingTalkDelivery.count({ where: { ...currentProvider, status: 'unread' } }),
prisma.dingTalkDelivery.groupBy({
by: ['last_error_code', 'last_error_message'],
where: { ...currentProvider, status: 'failed' },
_count: { _all: true },
orderBy: { _count: { id: 'desc' } },
take: 10
})
])
const config = getDingTalkConfigStatus()
const loginConfig = getNotificationCenterLoginConfigStatus()
successResponse(res, {
configured: config.configured,
channel: config.channel,
send_enabled: config.send_enabled,
enabled: config.send_enabled,
rollout_mode: config.rollout_mode,
test_user_count: config.test_user_count,
missing: config.missing,
login_configured: loginConfig.configured,
login_enabled: loginConfig.enabled,
login_rollout_mode: loginConfig.rollout_mode,
login_test_user_count: loginConfig.test_user_count,
login_missing: loginConfig.missing,
pending,
failed,
sent,
read,
unread,
error_summary: errorGroups.map((group) =>
`${group.last_error_code || 'UNKNOWN'}:${group.last_error_message || '未知错误'}(${group._count._all})`
)
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取钉钉投递状态失败')
}
}
export const getDingTalkDeliveries = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 20, status, business_id, event_type } = req.query
const where: any = {}
if (status) where.status = String(status)
if (business_id) where.business_id = Number(business_id)
if (event_type) where.event_type = String(event_type)
const skip = (Number(page) - 1) * Number(pageSize)
const [list, total] = await Promise.all([
prisma.dingTalkDelivery.findMany({
where,
skip,
take: Number(pageSize),
orderBy: { created_at: 'desc' },
include: {
recipient: {
select: { id: true, username: true, name: true, role: true, status: true }
}
}
}),
prisma.dingTalkDelivery.count({ where })
])
successResponse(res, {
list: list.map((item) => ({
id: item.id,
event_type: item.event_type,
business_type: item.business_type,
business_id: item.business_id,
recipient_user_id: item.recipient_user_id,
recipient_name: item.recipient.name || item.recipient.username,
recipient: item.recipient,
status: item.status,
attempt_count: item.attempt_count,
max_attempts: item.max_attempts,
next_attempt_at: item.next_attempt_at,
provider_type: item.provider_type,
dingtalk_task_id: item.dingtalk_task_id,
retry_of_delivery_id: item.retry_of_delivery_id,
manual_created_by: item.manual_created_by,
manual_created_at: item.manual_created_at,
last_manual_action_by: item.last_manual_action_by,
last_manual_action_type: item.last_manual_action_type,
last_manual_action_at: item.last_manual_action_at,
last_error_code: item.last_error_code,
last_error: item.last_error_message,
sent_at: item.sent_at,
read_at: item.read_at,
created_at: item.created_at,
updated_at: item.updated_at
})),
total,
page: Number(page),
pageSize: Number(pageSize)
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取钉钉投递记录失败')
}
}
export const retryDingTalkDelivery = async (req: Request, res: Response) => {
try {
if (req.body?.confirmed !== true) {
return errorResponse(res, '人工补发前必须明确确认可能产生一条新的单聊通知', 422)
}
const id = Number(req.params.id)
const delivery = await prisma.dingTalkDelivery.findUnique({
where: { id },
include: {
recipient: {
include: { person: { select: { source_userid: true, status: true } } }
}
}
})
if (!delivery) {
return errorResponse(res, '投递任务不存在', 404)
}
if (delivery.status !== 'failed') {
return errorResponse(res, '只有失败的投递任务可以手工补发', 409)
}
if (delivery.provider_type !== 'notification_center') {
return errorResponse(res, '历史工作通知仅供审计,不能通过统一通知中心补发', 409)
}
const currentDingTalkUserId = delivery.recipient.person?.source_userid?.trim() || ''
if (
delivery.recipient.status !== 'active' ||
delivery.recipient.person?.status !== 'active' ||
!currentDingTalkUserId
) {
return errorResponse(res, '收件人未绑定启用且在职的钉钉员工档案,不能补发', 422)
}
if (
['disposal_applied', 'disposal_overdue_reminder'].includes(delivery.event_type) &&
!['administrative', 'admin'].includes(delivery.recipient.role)
) {
return errorResponse(res, '收件人已不具备报废审批权限,不能继续投递该审批通知', 422)
}
const operatorUserId = Number(res.locals.user?.id)
if (!Number.isInteger(operatorUserId) || operatorUserId <= 0) {
return errorResponse(res, '当前管理员身份无效,请重新登录', 401)
}
const manualActionAt = new Date()
const currentPayload = JSON.parse(delivery.payload_json) as Record<string, unknown>
if (!delivery.dingtalk_task_id) {
const resumePayloadJson = prepareNotificationCenterResumePayload(delivery.payload_json, {
code: delivery.last_error_code,
message: delivery.last_error_message,
attemptCount: delivery.attempt_count,
operatorUserId,
resumedAt: manualActionAt
})
const updated = await prisma.dingTalkDelivery.updateMany({
where: { id, status: 'failed', dingtalk_task_id: null },
data: {
status: 'pending',
recipient_dingtalk_userid: currentDingTalkUserId,
attempt_count: 0,
next_attempt_at: new Date(),
lease_owner: null,
lease_expires_at: null,
last_error_code: null,
last_error_message: null,
failure_notified_at: null,
last_manual_action_by: operatorUserId,
last_manual_action_type: 'resume_submit',
last_manual_action_at: manualActionAt,
payload_json: resumePayloadJson
}
})
if (updated.count !== 1) return errorResponse(res, '投递任务状态已变化,请刷新后重试', 409)
return successResponse(res, { delivery_id: delivery.id }, '已按原幂等键恢复投递')
}
if (currentPayload.notification_center_status_query_error) {
const refreshableStatuses = new Set(['pending', 'sending', 'retrying', 'sent', 'unread'])
const lastKnownStatus = String(currentPayload.notification_center_last_status || '')
const restoredStatus = refreshableStatuses.has(lastKnownStatus) ? lastKnownStatus : 'pending'
const resumePayloadJson = prepareNotificationCenterQueryResumePayload(delivery.payload_json, {
code: delivery.last_error_code,
message: delivery.last_error_message,
operatorUserId,
resumedAt: manualActionAt
})
const updated = await prisma.dingTalkDelivery.updateMany({
where: {
id,
status: 'failed',
dingtalk_task_id: delivery.dingtalk_task_id
},
data: {
status: restoredStatus,
next_attempt_at: new Date(),
lease_owner: null,
lease_expires_at: null,
last_error_code: null,
last_error_message: null,
failure_notified_at: null,
last_manual_action_by: operatorUserId,
last_manual_action_type: 'resume_status_query',
last_manual_action_at: manualActionAt,
payload_json: resumePayloadJson
}
})
if (updated.count !== 1) return errorResponse(res, '投递任务状态已变化,请刷新后重试', 409)
return successResponse(res, { delivery_id: delivery.id }, '已恢复原任务状态回查,不会重新发送通知')
}
const externalFailed = currentPayload.notification_center_last_status === 'failed'
if (!externalFailed) {
return errorResponse(res, '通知中心尚未明确返回失败,不能创建新的补发消息', 409)
}
const retryPayloadJson = prepareNotificationCenterManualRetryPayload(
delivery.payload_json,
{
eventType: delivery.event_type,
businessId: delivery.business_id,
recipientUserId: currentDingTalkUserId,
sourceDeliveryId: delivery.id,
operatorUserId,
createdAt: manualActionAt
}
)
const retryPayload = JSON.parse(retryPayloadJson) as Record<string, unknown>
const retryIdempotencyKey = String(retryPayload.notification_center_idempotency_key || '').trim()
const created = await prisma.dingTalkDelivery.create({
data: {
idempotency_key: retryIdempotencyKey,
event_type: delivery.event_type,
business_type: delivery.business_type,
business_id: delivery.business_id,
recipient_user_id: delivery.recipient_user_id,
recipient_dingtalk_userid: currentDingTalkUserId,
max_attempts: delivery.max_attempts,
provider_type: 'notification_center',
retry_of_delivery_id: delivery.id,
manual_created_by: operatorUserId,
manual_created_at: manualActionAt,
last_manual_action_by: operatorUserId,
last_manual_action_type: 'create_retry',
last_manual_action_at: manualActionAt,
payload_json: retryPayloadJson
}
})
successResponse(res, { delivery_id: created.id }, '已创建一条可审计的人工补发任务')
} catch (error: any) {
const status = error?.code === 'P2002' ? 409 : 500
errorResponse(
res,
status === 409 ? '该人工补发任务已经创建,请刷新列表' : (error.message || '补发失败'),
status
)
}
}

View File

@ -1,267 +1,793 @@
import { Request, Response } from 'express'
import type { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
import { calculateEquipmentValues } from '../utils/assetValue'
import { loadAndLockAssetTree } from '../services/assetMovementService'
import {
createBusinessNotification,
escapeNotificationMarkdownInline,
findActiveDingTalkUsersByIds,
findActiveDingTalkUsersByRoles,
findActiveStationUsersByIds,
findActiveStationUsersByRoles,
formatBeijingDateTime,
getCurrentDingTalkRecipientRollout,
mergeRecipients
} from '../services/businessNotificationService'
// 创建报废申请
export const createDisposal = async (req: Request, res: Response) => {
try {
const { equipment_id, disposal_reason, disposal_method, estimated_value, notes } = req.body
const userId = res.locals.user?.id
class DisposalConflictError extends Error {
status = 409
}
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
const blockedStatuses = new Set(['已借出', '维护中', '待报废', '已报废', '已丢失'])
const equipment = await prisma.equipment.findUnique({
where: { id: Number(equipment_id) }
})
if (!equipment) {
return errorResponse(res, '资产不存在', 404)
}
if (equipment.status === '待报废' || equipment.status === '已报废') {
return errorResponse(res, '资产已在报废流程中')
}
try {
await ensureNoActiveBorrowRecords(equipment.id, '资产存在未归还记录,无法申请报废')
} catch (error: any) {
return errorResponse(res, error.message || '资产借用状态校验失败', 422)
}
const disposal = await prisma.$transaction(async (tx) => {
const created = await tx.disposalRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
disposal_reason,
disposal_method,
estimated_value: Number(estimated_value) || 0,
applied_by: userId,
notes
const disposalInclude = {
equipment: true,
applier: { select: { id: true, name: true, username: true } },
approver: { select: { id: true, name: true, username: true } },
completer: { select: { id: true, name: true, username: true } },
items: {
orderBy: { id: 'asc' as const },
include: {
equipment: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
status: true,
parent_id: true,
storage_location_id: true,
department_id: true
}
})
await tx.equipment.update({
where: { id: equipment.id },
data: { status: '待报废' }
})
return created
})
// 记录操作日志
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
await logOperation(
userId,
username,
'报废申请',
`申请报废资产 ${equipment.equipment_name}(${equipment.equipment_code}),原因:${disposal_reason},处置方式:${disposal_method}`,
equipment.id,
equipment.equipment_code,
req.ip
)
successResponse(res, disposal, '报废申请创建成功')
} catch (error: any) {
console.error('[创建报废申请失败]', error)
errorResponse(res, error.message || '创建报废申请失败')
}
}
},
actions: {
orderBy: [{ created_at: 'asc' as const }, { id: 'asc' as const }],
include: { operator: { select: { id: true, name: true, username: true } } }
}
}
// 获取报废记录列表
export const getDisposalList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, status, keyword, equipment_id } = req.query
const skip = (Number(page) - 1) * Number(pageSize)
const updateEquipmentLifecycle = async (
tx: Prisma.TransactionClient,
equipment: {
id: number
unit_price: Prisma.Decimal
total_quantity: number
remaining_quantity: number
status: string
parent_id: number | null
department_id: number | null
mount_status: string
updated_at: Date
},
targetStatus: string,
expectedStatus: string,
expectedParentId: number | null = equipment.parent_id
) => {
const remainingQuantity = targetStatus === '已借出' ? 0 : equipment.remaining_quantity
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: equipment.total_quantity,
remainingQuantity,
status: targetStatus
})
const updated = await tx.equipment.updateMany({
where: {
id: equipment.id,
status: expectedStatus,
parent_id: expectedParentId,
department_id: equipment.department_id,
mount_status: equipment.mount_status,
remaining_quantity: equipment.remaining_quantity,
updated_at: equipment.updated_at
},
data: {
status: targetStatus,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
})
if (updated.count !== 1) {
throw new DisposalConflictError('资产状态或挂载关系已变化,请刷新后重试')
}
}
const where: any = {}
if (status) where.status = status
if (equipment_id) where.equipment_id = Number(equipment_id)
if (keyword) {
where.OR = [
{ equipment_code: { contains: String(keyword) } },
{ disposal_reason: { contains: String(keyword) } }
]
const excludeApplicant = <T extends { id: number }>(recipients: T[], applicantId: number) =>
recipients.filter((recipient) => recipient.id !== applicantId)
const getSubmissionRecipients = async (tx: Prisma.TransactionClient, applicantId: number) => {
const rollout = getCurrentDingTalkRecipientRollout()
const [stationAdministrative, dingTalkAdministrative] = await Promise.all([
findActiveStationUsersByRoles(tx, ['administrative']),
findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout)
])
const eligibleStationAdministrative = excludeApplicant(stationAdministrative, applicantId)
const eligibleDingTalkAdministrative = excludeApplicant(dingTalkAdministrative, applicantId)
const stationRecipients = eligibleStationAdministrative.length > 0
? eligibleStationAdministrative
: await findActiveStationUsersByRoles(tx, ['admin'])
const dingTalkRecipients = eligibleDingTalkAdministrative.length > 0
? eligibleDingTalkAdministrative
: await findActiveDingTalkUsersByRoles(tx, ['admin'], rollout)
return { stationRecipients, dingTalkRecipients }
}
const getResultRecipients = async (tx: Prisma.TransactionClient, applierId: number) => {
const rollout = getCurrentDingTalkRecipientRollout()
const [stationApplier, stationAdministrative, dingTalkApplier, dingTalkAdministrative] = await Promise.all([
findActiveStationUsersByIds(tx, [applierId]),
findActiveStationUsersByRoles(tx, ['administrative']),
findActiveDingTalkUsersByIds(tx, [applierId], rollout),
findActiveDingTalkUsersByRoles(tx, ['administrative'], rollout)
])
let stationRecipients = mergeRecipients(stationApplier, stationAdministrative)
if (stationApplier.length === 0 || stationAdministrative.length === 0) {
stationRecipients = mergeRecipients(
stationRecipients,
await findActiveStationUsersByRoles(tx, ['admin'])
)
}
let dingTalkRecipients = mergeRecipients(dingTalkApplier, dingTalkAdministrative)
if (dingTalkApplier.length === 0 || dingTalkAdministrative.length === 0) {
dingTalkRecipients = mergeRecipients(
dingTalkRecipients,
await findActiveDingTalkUsersByRoles(tx, ['admin'], rollout)
)
}
return { stationRecipients, dingTalkRecipients }
}
const assetNotificationName = (name: unknown, code: unknown) =>
`${escapeNotificationMarkdownInline(name, '资产')}(${escapeNotificationMarkdownInline(code, '无编码')})`
export const buildDisposalAppliedDingTalkContent = (input: {
appliedAt: Date
equipmentName: unknown
equipmentCode: unknown
applicantName: unknown
reason: unknown
}) => [
`**申请时间:** ${formatBeijingDateTime(input.appliedAt)}`,
`**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`,
`**申请人:** ${escapeNotificationMarkdownInline(input.applicantName)}`,
`**报废原因:** ${escapeNotificationMarkdownInline(input.reason)}`,
'> 请核对资产及配件明细后处理。'
].join('\n\n')
export const buildDisposalApprovedDingTalkContent = (input: {
approvedAt: Date
equipmentName: unknown
equipmentCode: unknown
approverName: unknown
}) => [
`**审批时间:** ${formatBeijingDateTime(input.approvedAt)}`,
`**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`,
`**审批人:** ${escapeNotificationMarkdownInline(input.approverName)}`,
'**审批结果:** 已通过',
'> 报废单现已进入待处置阶段。'
].join('\n\n')
export const buildDisposalRejectedDingTalkContent = (input: {
rejectedAt: Date
equipmentName: unknown
equipmentCode: unknown
rejectorName: unknown
reason: unknown
}) => [
`**驳回时间:** ${formatBeijingDateTime(input.rejectedAt)}`,
`**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`,
`**驳回人:** ${escapeNotificationMarkdownInline(input.rejectorName)}`,
`**驳回原因:** ${escapeNotificationMarkdownInline(input.reason)}`,
'> 资产状态已恢复为申请前状态。'
].join('\n\n')
export const buildDisposalCompletedDingTalkContent = (input: {
completedAt: Date
equipmentName: unknown
equipmentCode: unknown
completerName: unknown
notes: unknown
}) => [
`**完成时间:** ${formatBeijingDateTime(input.completedAt)}`,
`**资产:** ${assetNotificationName(input.equipmentName, input.equipmentCode)}`,
`**完成人:** ${escapeNotificationMarkdownInline(input.completerName)}`,
'**处置结果:** 已完成报废',
`**完成说明:** ${escapeNotificationMarkdownInline(input.notes, '无')}`,
'> 相关资产现已统一标记为已报废。'
].join('\n\n')
const getCurrentUser = (res: Response) => ({
id: Number(res.locals.user?.id),
role: String(res.locals.user?.role || '')
})
const getDisposalVisibility = (res: Response): Prisma.DisposalRecordWhereInput => {
const user = getCurrentUser(res)
if (!Number.isSafeInteger(user.id) || user.id <= 0) {
throw Object.assign(new Error('用户未登录'), { status: 401 })
}
if (user.role === 'admin' || user.role === 'administrative') return {}
if (user.role === 'it') return { applied_by: user.id }
throw Object.assign(new Error('无权查看报废流程'), { status: 403 })
}
const DISPOSAL_STATUSES = ['pending', 'approved', 'rejected', 'completed'] as const
const parseListPositiveInteger = (
value: unknown,
fieldName: string,
fallback: number,
max?: number
) => {
if (value === undefined || value === null || value === '') {
if (fallback > 0) return fallback
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+$/.test(String(value))) {
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
return parsed
}
const withHistoricalDisposalNotes = <T extends {
status: string
notes?: string | null
completion_notes?: string | null
}>(record: T) => ({
...record,
historical_notes:
record.status === 'completed' && record.notes && !record.completion_notes
? record.notes
: null
})
export const createDisposal = async (req: Request, res: Response) => {
try {
const {
equipment_id,
item_ids,
disposal_reason,
disposal_method,
estimated_value,
notes
} = req.body as Record<string, any>
const userId = Number(res.locals.user?.id)
const rootId = Number(equipment_id)
if (!userId) return errorResponse(res, '用户未登录', 401)
if (!Number.isInteger(rootId) || rootId <= 0) {
return errorResponse(res, '请选择要报废的主资产', 422)
}
if (!disposal_reason?.trim() || !disposal_method?.trim()) {
return errorResponse(res, '报废原因和处置方式不能为空', 422)
}
const estimatedValue = Number(estimated_value ?? 0)
if (!Number.isFinite(estimatedValue) || estimatedValue < 0) {
return errorResponse(res, '残值估价必须是大于或等于0的有效数字', 422)
}
const [list, total] = await Promise.all([
const createdId = await prisma.$transaction(async (tx) => {
const tree = await loadAndLockAssetTree(tx, rootId)
const root = tree[0]
if (root.parent_id) {
throw new Error('挂载中的子资产不能单独申请报废,请先卸载')
}
const requestedIds = Array.isArray(item_ids)
? [...new Set(item_ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
: [rootId]
if (!requestedIds.includes(rootId)) {
throw new Error('报废明细必须包含主资产')
}
const treeIds = new Set(tree.map((item) => item.id))
const foreignId = requestedIds.find((id) => !treeIds.has(id))
if (foreignId) {
throw new Error('报废明细包含不属于当前资产树的资产')
}
const omitted = tree.filter((item) => !requestedIds.includes(item.id))
if (omitted.length > 0) {
throw new DisposalConflictError(
`配件 ${omitted.map((item) => item.equipment_code).join('、')} 仍处于挂载状态;请先卸载未勾选配件`
)
}
const blocked = tree.find((item) => blockedStatuses.has(item.status))
if (blocked) {
const message = blocked.status === '维护中'
? '资产正在维修中,请先完成或取消维修'
: `资产 ${blocked.equipment_code} 当前为${blocked.status},不能申请报废`
throw new Error(message)
}
const ids = tree.map((item) => item.id)
const [activeBorrowCount, activeMaintenanceCount, activeDisposalCount] = await Promise.all([
tx.borrowRecord.count({ where: { equipment_id: { in: ids }, status: '已借出' } }),
tx.maintenanceRecord.count({ where: { equipment_id: { in: ids }, status: 'in_progress' } }),
tx.disposalItem.count({
where: {
equipment_id: { in: ids },
disposal: { status: { in: ['pending', 'approved'] } }
}
})
])
if (activeBorrowCount > 0) throw new Error('资产树存在未归还记录,不能申请报废')
if (activeMaintenanceCount > 0) throw new Error('资产树存在进行中的维修,请先完成或取消维修')
if (activeDisposalCount > 0) throw new Error('资产树已有进行中的报废申请')
const disposal = await tx.disposalRecord.create({
data: {
equipment_id: root.id,
equipment_code: root.equipment_code,
disposal_reason: disposal_reason.trim(),
disposal_method: disposal_method.trim(),
estimated_value: estimatedValue,
applied_by: userId,
pre_status: root.status,
notes: notes?.trim() || null,
items: {
create: tree.map((item) => {
const parent = item.parent_id
? tree.find((candidate) => candidate.id === item.parent_id)
: null
return {
equipment_id: item.id,
equipment_code: item.equipment_code,
equipment_name: item.equipment_name,
parent_equipment_id: item.parent_id,
parent_equipment_code: parent?.equipment_code || null,
pre_status: item.status,
snapshot_json: JSON.stringify({
equipment_code: item.equipment_code,
equipment_name: item.equipment_name,
parent_id: item.parent_id,
department_id: item.department_id,
storage_location_id: item.storage_location_id,
status: item.status
})
}
})
},
actions: {
create: {
action_type: 'applied',
operator_id: userId,
from_status: null,
to_status: 'pending',
detail: disposal_reason.trim()
}
}
}
})
for (const item of tree) {
await updateEquipmentLifecycle(tx, item, '待报废', item.status)
}
const recipients = await getSubmissionRecipients(tx, userId)
await createBusinessNotification(tx, {
eventType: 'disposal_applied',
businessType: 'disposal',
businessId: disposal.id,
title: `待审批报废:${root.equipment_name}`,
content: `${res.locals.user?.name || res.locals.user?.username} 提交了 ${root.equipment_code} 的报废申请`,
level: 'warning',
actionUrl: `/workflows/disposals/${disposal.id}`,
stationRecipients: recipients.stationRecipients,
dingTalkRecipients: recipients.dingTalkRecipients,
metadata: { equipment_code: root.equipment_code },
dingTalk: {
title: '资产报废待审批',
content: buildDisposalAppliedDingTalkContent({
appliedAt: disposal.applied_at,
equipmentName: root.equipment_name,
equipmentCode: root.equipment_code,
applicantName: res.locals.user?.name || res.locals.user?.username,
reason: disposal_reason
}),
actionTitle: '立即审批',
messageVersion: 'v1'
}
})
return disposal.id
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
const disposal = await prisma.disposalRecord.findUnique({
where: { id: createdId },
include: disposalInclude
})
await logOperation(
userId,
res.locals.user?.username || res.locals.user?.name || 'unknown',
'报废申请',
`申请报废资产 ${disposal?.equipment_code},原因:${disposal_reason}`,
rootId,
disposal?.equipment_code,
req.ip
)
successResponse(res, disposal, '报废申请创建成功')
} catch (error: any) {
console.error('[创建报废申请失败]', error?.message || error)
errorResponse(res, error.message || '创建报废申请失败', error.status || 422)
}
}
export const getDisposalList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, status, keyword, equipment_id, scope } = req.query
const currentPage = parseListPositiveInteger(page, '页码', 1)
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100)
const currentUser = getCurrentUser(res)
if (!Number.isInteger(currentUser.id) || currentUser.id <= 0) {
return errorResponse(res, '用户未登录', 401)
}
const resolvedScope = String(scope || (currentUser.role === 'admin'
? 'all'
: currentUser.role === 'administrative'
? 'pending'
: 'mine'))
const validScopes = ['all', 'mine', 'pending', 'processed']
if (!validScopes.includes(resolvedScope)) {
return errorResponse(res, '报废列表范围无效', 422)
}
const summaryConditions: Prisma.DisposalRecordWhereInput[] = [getDisposalVisibility(res)]
if (currentUser.role === 'administrative' && resolvedScope === 'all') {
return errorResponse(res, '行政仅可查看本人发起、全部待审批和全部已处理报废流程', 403)
}
if (currentUser.role !== 'it') {
if (resolvedScope === 'mine') {
summaryConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'pending') {
summaryConditions.push({ status: 'pending' })
} else if (resolvedScope === 'processed') {
summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
}
}
const normalizedStatus = String(status || '').trim()
if (normalizedStatus && !DISPOSAL_STATUSES.includes(normalizedStatus as any)) {
return errorResponse(res, '报废状态无效', 422)
}
if (equipment_id !== undefined && equipment_id !== '') {
summaryConditions.push({
equipment_id: parseListPositiveInteger(equipment_id, '资产ID', 0)
})
}
const normalizedKeyword = String(keyword || '').trim()
if (normalizedKeyword) {
const keywordWhere = [
{ equipment_code: { contains: normalizedKeyword } },
{ disposal_reason: { contains: normalizedKeyword } },
{ items: { some: { equipment_code: { contains: normalizedKeyword } } } }
]
summaryConditions.push({ OR: keywordWhere })
}
const summaryWhere: Prisma.DisposalRecordWhereInput = summaryConditions.length > 0
? { AND: summaryConditions }
: {}
const where: Prisma.DisposalRecordWhereInput = normalizedStatus
? { AND: [summaryWhere, { status: normalizedStatus }] }
: summaryWhere
const skip = (currentPage - 1) * currentPageSize
const [list, total, statusGroups] = await Promise.all([
prisma.disposalRecord.findMany({
where,
skip,
take: Number(pageSize),
take: currentPageSize,
orderBy: { created_at: 'desc' },
include: {
equipment: { select: { equipment_name: true, equipment_type: true, unit_price: true } },
applier: { select: { id: true, name: true, username: true } },
approver: { select: { id: true, name: true, username: true } }
approver: { select: { id: true, name: true, username: true } },
completer: { select: { id: true, name: true, username: true } },
_count: { select: { items: true } }
}
}),
prisma.disposalRecord.count({ where })
prisma.disposalRecord.count({ where }),
prisma.disposalRecord.groupBy({
by: ['status'],
where: summaryWhere,
_count: { _all: true }
})
])
successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功')
const summary = {
total: 0,
pending: 0,
approved: 0,
rejected: 0,
completed: 0
}
for (const group of statusGroups) {
const count = group._count._all
summary.total += count
if (DISPOSAL_STATUSES.includes(group.status as any)) {
summary[group.status as (typeof DISPOSAL_STATUSES)[number]] = count
}
}
successResponse(
res,
{
list: list.map(withHistoricalDisposalNotes),
total,
page: currentPage,
pageSize: currentPageSize,
summary
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取报废记录列表失败')
errorResponse(res, error.message || '获取报废记录列表失败', error.status || 500)
}
}
// 获取报废记录详情
export const getDisposalDetail = async (req: Request, res: Response) => {
try {
const { id } = req.params
const visibility = getDisposalVisibility(res)
const disposalId = parseListPositiveInteger(req.params.id, '报废记录ID', 0)
const disposal = await prisma.disposalRecord.findUnique({
where: { id: Number(id) },
include: {
equipment: true,
applier: { select: { id: true, name: true, username: true } },
approver: { select: { id: true, name: true, username: true } }
}
where: { id: disposalId },
include: disposalInclude
})
if (!disposal) {
return errorResponse(res, '报废记录不存在', 404)
if (!disposal) return errorResponse(res, '报废记录不存在', 404)
if (visibility.applied_by && disposal.applied_by !== visibility.applied_by) {
return errorResponse(res, '无权查看该报废申请', 403)
}
successResponse(res, disposal, '获取成功')
successResponse(res, withHistoricalDisposalNotes(disposal), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取报废记录详情失败')
errorResponse(res, error.message || '获取报废记录详情失败', error.status || 500)
}
}
// 审批报废申请
export const approveDisposal = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { approved, reject_reason } = req.body
const userId = res.locals.user?.id
if (!userId) {
return errorResponse(res, '用户未登录', 401)
const disposalId = Number(req.params.id)
const { approved, reject_reason } = req.body as { approved?: boolean; reject_reason?: string }
const userId = Number(res.locals.user?.id)
if (typeof approved !== 'boolean') {
return errorResponse(res, '审批结果必须明确为通过或驳回', 422)
}
if (!approved && !reject_reason?.trim()) {
return errorResponse(res, '驳回原因不能为空', 422)
}
const disposal = await prisma.disposalRecord.findUnique({
where: { id: Number(id) },
include: { equipment: true }
})
if (!disposal) {
return errorResponse(res, '报废记录不存在', 404)
}
if (disposal.status !== 'pending') {
return errorResponse(res, '该申请已处理')
}
await prisma.$transaction(async (tx) => {
if (approved) {
await tx.disposalRecord.update({
where: { id: Number(id) },
data: { status: 'approved', approved_by: userId, approved_at: new Date() }
})
return
const result = await prisma.$transaction(async (tx) => {
const disposal = await tx.disposalRecord.findUnique({
where: { id: disposalId },
include: { items: { include: { equipment: true } } }
})
if (!disposal) throw Object.assign(new Error('报废记录不存在'), { status: 404 })
const changedItem = disposal.items.find((item) =>
item.equipment.status !== '待报废' ||
item.equipment.parent_id !== item.parent_equipment_id
)
if (changedItem) {
throw new DisposalConflictError(
`资产 ${changedItem.equipment_code} 的状态或挂载关系已变化,请先核查`
)
}
await tx.disposalRecord.update({
where: { id: Number(id) },
data: { status: 'rejected', approved_by: userId, approved_at: new Date(), notes: reject_reason }
})
await tx.equipment.update({
where: { id: disposal.equipment_id },
data: { status: '可用' }
})
})
// 记录操作日志
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
await logOperation(
userId,
username,
approved ? '报废审批通过' : '报废审批驳回',
`${approved ? '批准' : '驳回'}资产 ${disposal.equipment?.equipment_name || ''}(${disposal.equipment_code}) 的报废申请${!approved && reject_reason ? `,原因:${reject_reason}` : ''}`,
disposal.equipment_id,
disposal.equipment_code,
req.ip
)
successResponse(res, null, approved ? '审批通过' : '已驳回')
} catch (error: any) {
errorResponse(res, error.message || '审批失败')
}
}
// 完成报废处置
export const completeDisposal = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { actual_value, notes } = req.body
const disposal = await prisma.disposalRecord.findUnique({
where: { id: Number(id) }
})
if (!disposal) {
return errorResponse(res, '报废记录不存在', 404)
}
if (disposal.status !== 'approved') {
return errorResponse(res, '只有已审批的申请才能完成处置')
}
await prisma.$transaction(async (tx) => {
await tx.disposalRecord.update({
where: { id: Number(id) },
const nextStatus = approved ? 'approved' : 'rejected'
const approvedAt = new Date()
const updated = await tx.disposalRecord.updateMany({
where: { id: disposalId, status: 'pending' },
data: {
status: 'completed',
actual_value: actual_value !== undefined ? Number(actual_value) : disposal.estimated_value,
completed_at: new Date(),
notes: notes || disposal.notes
status: nextStatus,
approved_by: userId,
approved_at: approvedAt,
reject_reason: approved ? null : reject_reason?.trim()
}
})
if (updated.count !== 1) {
throw new DisposalConflictError('该申请已被其他人处理,请刷新后查看')
}
if (!approved) {
for (const item of disposal.items) {
await updateEquipmentLifecycle(
tx,
item.equipment,
item.pre_status,
'待报废',
item.parent_equipment_id
)
}
}
await tx.disposalAction.create({
data: {
disposal_id: disposalId,
action_type: approved ? 'approved' : 'rejected',
operator_id: userId,
from_status: 'pending',
to_status: nextStatus,
detail: approved ? null : reject_reason?.trim()
}
})
await tx.equipment.update({
where: { id: disposal.equipment_id },
data: { status: '已报废' }
const recipients = await getResultRecipients(tx, disposal.applied_by)
const rootItem = disposal.items.find((item) => item.equipment_id === disposal.equipment_id)
await createBusinessNotification(tx, {
eventType: approved ? 'disposal_approved' : 'disposal_rejected',
businessType: 'disposal',
businessId: disposal.id,
title: approved ? `报废已审批:${disposal.equipment_code}` : `报废已驳回:${disposal.equipment_code}`,
content: approved ? '报废申请已审批通过,等待完成处置' : `驳回原因:${reject_reason?.trim()}`,
level: approved ? 'success' : 'warning',
actionUrl: `/workflows/disposals/${disposal.id}`,
stationRecipients: recipients.stationRecipients,
dingTalkRecipients: recipients.dingTalkRecipients,
dingTalk: approved
? {
title: '资产报废审批已通过',
content: buildDisposalApprovedDingTalkContent({
approvedAt,
equipmentName: rootItem?.equipment.equipment_name,
equipmentCode: disposal.equipment_code,
approverName: res.locals.user?.name || res.locals.user?.username
}),
actionTitle: '查看审批结果',
messageVersion: 'v1'
}
: {
title: '资产报废申请已驳回',
content: buildDisposalRejectedDingTalkContent({
rejectedAt: approvedAt,
equipmentName: rootItem?.equipment.equipment_name,
equipmentCode: disposal.equipment_code,
rejectorName: res.locals.user?.name || res.locals.user?.username,
reason: reject_reason
}),
actionTitle: '查看申请',
messageVersion: 'v1'
}
})
return disposal
})
// 记录操作日志
const userId = res.locals.user?.id
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
await logOperation(
userId,
username,
'报废完成',
`完成资产 (${disposal.equipment_code}) 的报废处置,实际残值:¥${actual_value !== undefined ? actual_value : disposal.estimated_value}`,
disposal.equipment_id,
disposal.equipment_code,
res.locals.user?.username || res.locals.user?.name || 'unknown',
approved ? '报废审批通过' : '报废审批驳回',
`${approved ? '批准' : '驳回'}资产 ${result.equipment_code} 的报废申请`,
result.equipment_id,
result.equipment_code,
req.ip
)
successResponse(res, null, '报废处置完成')
successResponse(res, null, approved ? '审批通过' : '已驳回')
} catch (error: any) {
errorResponse(res, error.message || '完成报废处置失败')
errorResponse(res, error.message || '审批失败', error.status || 500)
}
}
export const completeDisposal = async (req: Request, res: Response) => {
try {
const disposalId = Number(req.params.id)
const { actual_value, completion_notes, notes } = req.body as Record<string, any>
const userId = Number(res.locals.user?.id)
const actualValue = actual_value === undefined ? undefined : Number(actual_value)
if (actualValue !== undefined && (!Number.isFinite(actualValue) || actualValue < 0)) {
return errorResponse(res, '实际处置价值必须是大于或等于0的有效数字', 422)
}
const result = await prisma.$transaction(async (tx) => {
const disposal = await tx.disposalRecord.findUnique({
where: { id: disposalId },
include: { items: { include: { equipment: true } } }
})
if (!disposal) throw Object.assign(new Error('报废记录不存在'), { status: 404 })
const completedAt = new Date()
const updated = await tx.disposalRecord.updateMany({
where: { id: disposalId, status: 'approved' },
data: {
status: 'completed',
actual_value: actualValue !== undefined ? actualValue : disposal.estimated_value,
completed_at: completedAt,
completed_by: userId,
completion_notes: completion_notes?.trim() || notes?.trim() || null
}
})
if (updated.count !== 1) {
throw new DisposalConflictError('该申请状态已变化,请刷新后查看')
}
for (const item of disposal.items) {
await updateEquipmentLifecycle(
tx,
item.equipment,
'已报废',
'待报废',
item.parent_equipment_id
)
}
await tx.disposalAction.create({
data: {
disposal_id: disposalId,
action_type: 'completed',
operator_id: userId,
from_status: 'approved',
to_status: 'completed',
detail: completion_notes?.trim() || notes?.trim() || null
}
})
const recipients = await getResultRecipients(tx, disposal.applied_by)
const rootItem = disposal.items.find((item) => item.equipment_id === disposal.equipment_id)
await createBusinessNotification(tx, {
eventType: 'disposal_completed',
businessType: 'disposal',
businessId: disposal.id,
title: `报废处置已完成:${disposal.equipment_code}`,
content: completion_notes?.trim() || notes?.trim() || '资产已完成报废处置',
level: 'success',
actionUrl: `/workflows/disposals/${disposal.id}`,
stationRecipients: recipients.stationRecipients,
dingTalkRecipients: recipients.dingTalkRecipients,
dingTalk: {
title: '资产报废处置已完成',
content: buildDisposalCompletedDingTalkContent({
completedAt,
equipmentName: rootItem?.equipment.equipment_name,
equipmentCode: disposal.equipment_code,
completerName: res.locals.user?.name || res.locals.user?.username,
notes: completion_notes?.trim() || notes?.trim()
}),
actionTitle: '查看结果',
messageVersion: 'v1'
}
})
return disposal
})
await logOperation(
userId,
res.locals.user?.username || res.locals.user?.name || 'unknown',
'报废完成',
`完成资产 ${result.equipment_code} 的报废处置`,
result.equipment_id,
result.equipment_code,
req.ip
)
successResponse(res, null, '报废处置完成')
} catch (error: any) {
errorResponse(res, error.message || '完成报废处置失败', error.status || 500)
}
}
// 获取资产的报废历史
export const getEquipmentDisposalHistory = async (req: Request, res: Response) => {
try {
const { equipment_id } = req.params
const visibility = getDisposalVisibility(res)
const equipmentId = parseListPositiveInteger(req.params.equipment_id, '资产ID', 0)
const history = await prisma.disposalRecord.findMany({
where: { equipment_id: Number(equipment_id) },
where: {
AND: [visibility, { OR: [
{ equipment_id: equipmentId },
{ items: { some: { equipment_id: equipmentId } } }
] }]
},
orderBy: { created_at: 'desc' },
include: {
applier: { select: { id: true, name: true } },
approver: { select: { id: true, name: true } }
approver: { select: { id: true, name: true } },
completer: { select: { id: true, name: true } },
items: true,
actions: { orderBy: { created_at: 'asc' } }
}
})
successResponse(res, history, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取报废历史失败')
errorResponse(res, error.message || '获取报废历史失败', error.status || 500)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,25 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ExcelJS = require('exceljs')
import { sumSnapshotChildrenValue } from '../utils/assetValue'
import {
flattenEquipmentExportHierarchy,
hydrateEquipmentExportHierarchy,
summarizeEquipmentExportHierarchy
} from '../utils/equipmentExportHierarchy'
import {
formatShanghaiExportFilenameTimestamp
} from '../utils/equipmentExportMetadata'
import { calculateEquipmentExportValues } from '../utils/equipmentExportValues'
import { buildEquipmentKeywordSearchConditions } from '../utils/equipmentKeywordSearch'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import { buildAdministrativeEquipmentWorkbook } from '../utils/equipmentAdministrativeExport'
const loadExcelJS = (): typeof import('exceljs') => {
// 低频导出按需加载,避免后台常驻流程在启动时就加载 Excel 组件。
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('exceljs')
}
// 生成带下拉选项的导入模板
export const generateImportTemplate = async (req: Request, res: Response) => {
@ -9,7 +27,9 @@ export const generateImportTemplate = async (req: Request, res: Response) => {
// 从数据库获取所有启用的选项数据
const [departments, assetTypes, storageLocations] = await Promise.all([
prisma.department.findMany({
where: { status: 'active' },
where: {
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
},
select: { name: true },
orderBy: { name: 'asc' }
}),
@ -29,6 +49,7 @@ export const generateImportTemplate = async (req: Request, res: Response) => {
const assetTypeNames = assetTypes.map(t => t.name)
const locationNames = storageLocations.map(l => l.name)
const ExcelJS = loadExcelJS()
const workbook = new ExcelJS.Workbook()
// 创建主数据表
@ -184,71 +205,97 @@ export const generateImportTemplate = async (req: Request, res: Response) => {
// 导出资产列表
export const exportEquipment = async (req: Request, res: Response) => {
const exportedAt = new Date()
try {
const { status, equipment_type, department_id, keyword } = req.query
const {
status,
equipment_type,
department_id,
storage_location_id,
mount_status,
keyword
} = req.query
const where: any = {}
const where: any = { parent_id: null }
if (status) where.status = status
if (equipment_type) where.equipment_type = equipment_type
if (department_id) where.department_id = Number(department_id)
const departmentId = Number(department_id)
if (department_id && !Number.isNaN(departmentId)) where.department_id = departmentId
const storageLocationId = Number(storage_location_id)
if (storage_location_id && !Number.isNaN(storageLocationId)) {
where.storage_location_id = storageLocationId
}
if (mount_status === 'mounted') {
where.children = { some: {} }
} else if (mount_status === 'unmounted') {
where.children = { none: {} }
}
if (keyword) {
where.OR = [
{ equipment_code: { contains: String(keyword) } },
{ equipment_name: { contains: String(keyword) } }
]
const normalizedKeyword = String(keyword).trim()
where.OR = buildEquipmentKeywordSearchConditions(normalizedKeyword, 2)
}
const equipments = await prisma.equipment.findMany({
where,
include: {
department: { select: { name: true } },
storageLocation: { select: { name: true } },
assetType: { select: { name: true } }
const equipments = await prisma.$transaction(
async (tx) => {
const rootEquipments = await tx.equipment.findMany({
where,
include: {
department: { select: { name: true } },
storageLocation: { select: { name: true } },
assetType: { select: { name: true } },
borrow_records: {
where: { status: '已借出' },
orderBy: [{ borrow_time: 'desc' }, { id: 'desc' }],
take: 2,
select: {
id: true,
person_nickname: true,
person_name: true,
borrow_time: true
}
}
},
orderBy: [{ created_at: 'desc' }, { id: 'desc' }]
})
return hydrateEquipmentExportHierarchy(
rootEquipments,
async (parentIds) => tx.equipment.findMany({
where: { parent_id: { in: parentIds } },
include: {
department: { select: { name: true } },
storageLocation: { select: { name: true } },
assetType: { select: { name: true } }
},
orderBy: [{ equipment_code: 'asc' }, { id: 'asc' }]
})
)
},
orderBy: { created_at: 'desc' }
{
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
maxWait: 5000,
timeout: 30000
}
)
const exportRows = flattenEquipmentExportHierarchy(equipments)
const exportValues = calculateEquipmentExportValues(exportRows)
const exportGroups = summarizeEquipmentExportHierarchy(exportRows)
const exportValueByGroupCode = new Map(
exportRows
.map((row, index) => ({ row, values: exportValues[index] }))
.filter(({ row }) => row.asset_level !== '配件')
.map(({ row, values }) => [row.relationship_group_code, values] as const)
)
const ExcelJS = loadExcelJS()
const workbook = buildAdministrativeEquipmentWorkbook(ExcelJS, {
groups: exportGroups,
valueByGroupCode: exportValueByGroupCode,
exportedAt
})
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('资产列表')
worksheet.columns = [
{ header: '资产编码', key: 'equipment_code', width: 20 },
{ header: '资产名称', key: 'equipment_name', width: 25 },
{ header: '资产类型', key: 'equipment_type', width: 15 },
{ header: '型号', key: 'equipment_model', width: 20 },
{ header: '使用部门', key: 'department', width: 15 },
{ header: '存放地址', key: 'storage_location', width: 20 },
{ header: '单价', key: 'unit_price', width: 12 },
{ header: '总数量', key: 'total_quantity', width: 10 },
{ header: '剩余数量', key: 'remaining_quantity', width: 10 },
{ header: '状态', key: 'status', width: 10 },
{ header: '采购日期', key: 'purchase_date', width: 15 },
{ header: '供应商', key: 'supplier', width: 20 }
]
// 设置表头样式
worksheet.getRow(1).font = { bold: true }
worksheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }
equipments.forEach(eq => {
worksheet.addRow({
equipment_code: eq.equipment_code,
equipment_name: eq.equipment_name,
equipment_type: eq.assetType?.name || eq.equipment_type,
equipment_model: eq.equipment_model,
department: eq.department?.name || '',
storage_location: eq.storageLocation?.name || '',
unit_price: Number(eq.unit_price),
total_quantity: eq.total_quantity,
remaining_quantity: eq.remaining_quantity,
status: eq.status,
purchase_date: eq.purchase_date ? new Date(eq.purchase_date).toLocaleDateString('zh-CN') : '',
supplier: eq.supplier || ''
})
})
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
const filename = `资产列表_${timestamp}.xlsx`
const timestamp = formatShanghaiExportFilenameTimestamp(exportedAt)
const filename = `行政资产台账_${timestamp}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)
@ -271,7 +318,8 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
if (person_name) {
where.OR = [
{ person_name: { contains: String(person_name) } },
{ person_nickname: { contains: String(person_name) } }
{ person_nickname: { contains: String(person_name) } },
{ person: { is: { job_number: { contains: String(person_name) } } } }
]
}
if (start_date || end_date) {
@ -284,11 +332,22 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
where,
include: {
equipment: { select: { equipment_name: true, unit_price: true } },
person: { select: { nickname: true, real_name: true, department: true } }
person: {
select: {
nickname: true,
real_name: true,
job_number: true,
department: true,
mainDepartment: {
select: { name: true }
}
}
}
},
orderBy: { borrow_time: 'desc' }
})
const ExcelJS = loadExcelJS()
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('借用记录')
@ -297,10 +356,12 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
{ header: '资产名称', key: 'equipment_name', width: 25 },
{ header: '使用者', key: 'person_name', width: 15 },
{ header: '使用者昵称', key: 'person_nickname', width: 15 },
{ header: '当前工号', key: 'current_job_number', width: 18 },
{ header: '部门', key: 'department', width: 15 },
{ header: '借用时间', key: 'borrow_time', width: 20 },
{ header: '预计归还', key: 'expected_return_time', width: 20 },
{ header: '实际归还', key: 'return_time', width: 20 },
{ header: '借出价值', key: 'equipment_value', width: 14 },
{ header: '状态', key: 'status', width: 10 },
{ header: '归还状况', key: 'return_condition', width: 10 },
{ header: '备注', key: 'notes', width: 30 }
@ -315,10 +376,12 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
equipment_name: record.equipment?.equipment_name || '',
person_name: record.person_name,
person_nickname: record.person_nickname,
department: record.person?.department || '',
current_job_number: record.person?.job_number || '',
department: record.person?.mainDepartment?.name || record.person?.department || '',
borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '',
expected_return_time: record.is_long_term ? '长期' : (record.expected_return_time ? new Date(record.expected_return_time).toLocaleDateString('zh-CN') : ''),
return_time: record.return_time ? new Date(record.return_time).toLocaleString('zh-CN') : '',
equipment_value: Number(record.equipment?.unit_price || 0) + sumSnapshotChildrenValue(record.children_snapshot),
status: record.status,
return_condition: record.return_condition,
notes: record.notes || ''
@ -345,13 +408,16 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
try {
// 从数据库获取所有启用的部门
const departments = await prisma.department.findMany({
where: { status: 'active' },
where: {
AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }]
},
select: { name: true },
orderBy: { name: 'asc' }
})
const departmentNames = departments.map(d => d.name)
const ExcelJS = loadExcelJS()
const workbook = new ExcelJS.Workbook()
// 创建主数据表
@ -362,6 +428,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
{ header: '昵称 *', key: 'nickname', width: 15 },
{ header: '真实姓名 *', key: 'real_name', width: 15 },
{ header: '工号', key: 'employee_id', width: 15 },
{ header: '身份证号', key: 'id_number', width: 22 },
{ header: '部门', key: 'department', width: 18 },
{ header: '职位', key: 'position', width: 15 },
{ header: '电话', key: 'phone', width: 15 },
@ -380,6 +447,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
nickname: '示例:张三',
real_name: '张三',
employee_id: 'EMP001',
id_number: '320000199001011234',
department: departmentNames[0] || '技术部',
position: '工程师',
phone: '13800138000',
@ -404,10 +472,10 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
// 预设100行的下拉选项
const maxRows = 100
// 部门下拉(D列)
// 部门下拉(E列)
if (departmentNames.length > 0) {
for (let row = 2; row <= maxRows; row++) {
mainSheet.getCell(`D${row}`).dataValidation = {
mainSheet.getCell(`E${row}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: [`数据源!$A$2:$A$${departmentNames.length + 1}`],
@ -433,6 +501,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
{ field: '昵称', required: '是', description: '使用者的昵称或常用名' },
{ field: '真实姓名', required: '是', description: '使用者的真实姓名' },
{ field: '工号', required: '否', description: '员工工号' },
{ field: '身份证号', required: '否', description: '身份证或其他证件号码' },
{ field: '部门', required: '否', description: '从下拉列表选择,当前可选:' + (departmentNames.join('、') || '暂无') },
{ field: '职位', required: '否', description: '职位名称,如"工程师"、"经理"等' },
{ field: '电话', required: '否', description: '联系电话' },

View File

@ -0,0 +1,51 @@
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
const probeDatabase = async () => {
// Referencing the new column also detects a missing maintenance migration.
await prisma.$queryRaw`
SELECT
(SELECT COUNT(id) FROM equipment WHERE 1 = 0) AS equipment_ready,
(SELECT COUNT(original_tree_snapshot) FROM maintenance_record WHERE 1 = 0) AS maintenance_ready,
(SELECT COUNT(claim_token) + COUNT(lease_expires_at) + COUNT(result_confirmed_by) + COUNT(result_confirmed_at) FROM print_job WHERE 1 = 0) AS print_job_ready,
(SELECT COUNT(protocol_version) FROM print_terminal WHERE 1 = 0) AS print_terminal_ready,
(SELECT COUNT(sid) FROM web_session WHERE 1 = 0) AS session_ready
`
}
export const createHealthController = (
probe: () => Promise<unknown> = probeDatabase,
timeoutMs = 3000
) => {
let inFlight: Promise<boolean> | null = null
const check = () => {
if (inFlight) return inFlight
let timer: NodeJS.Timeout
const pending = Promise.resolve().then(probe)
const result = Promise.race([
pending.then(() => true, () => false),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs)
})
]).finally(() => clearTimeout(timer))
inFlight = result
// A timed-out query cannot be cancelled by Prisma. Retain its shared failed
// result until it settles so repeated health calls cannot fill the DB pool.
const release = () => {
if (inFlight === result) inFlight = null
}
pending.then(release, release)
return result
}
return async (_req: Request, res: Response) => {
const healthy = await check()
res.status(healthy ? 200 : 503).json({
status: healthy ? 'ok' : 'error',
message: healthy ? 'Server is ready' : 'Service is unavailable'
})
}
}
export const getHealth = createHealthController()

View File

@ -0,0 +1,207 @@
import type { Request, Response } from 'express'
import { errorResponse, successResponse } from '../utils/response'
import {
assignUnassignedTaskItem,
closeInventoryTask,
createInventoryTask,
deleteInventoryTask,
getInventoryScopeContext,
getInventoryTaskDetail,
getTaskUnassignedItems,
listInventoryTasks,
previewInventoryTask,
submitAdminInventoryScan
} from '../services/inventoryService'
import { normalizeInventoryScopeInput } from '../utils/inventoryScope'
const toOptionalNumber = (value: unknown) => {
if (value === undefined || value === null || value === '') return null
const numericValue = Number(value)
return Number.isNaN(numericValue) ? null : numericValue
}
const getNormalizedScopeFromRequest = (req: Request) => normalizeInventoryScopeInput({
department_id: toOptionalNumber(req.body?.department_id),
department_ids: req.body?.department_ids,
asset_type_id: toOptionalNumber(req.body?.asset_type_id),
asset_type_ids: req.body?.asset_type_ids,
storage_location_id: toOptionalNumber(req.body?.storage_location_id),
storage_location_ids: req.body?.storage_location_ids,
person_id: toOptionalNumber(req.body?.person_id),
person_ids: req.body?.person_ids
})
export const getInventoryScopeContextController = async (req: Request, res: Response) => {
try {
const result = await getInventoryScopeContext(getNormalizedScopeFromRequest(req))
successResponse(res, result, '获取盘点范围成功')
} catch (error: any) {
errorResponse(res, error.message || '获取盘点范围失败', 422)
}
}
export const previewInventoryTaskController = async (req: Request, res: Response) => {
try {
const result = await previewInventoryTask(getNormalizedScopeFromRequest(req))
successResponse(res, result, '预览成功')
} catch (error: any) {
errorResponse(res, error.message || '预览盘点任务失败', 422)
}
}
export const createInventoryTaskController = async (req: Request, res: Response) => {
try {
const currentUserId = Number(res.locals.user?.id)
if (!Number.isInteger(currentUserId) || currentUserId <= 0) {
return errorResponse(res, '未获取到当前登录用户', 401)
}
const normalizedScope = getNormalizedScopeFromRequest(req)
const result = await createInventoryTask(
{
title: req.body?.title,
description: req.body?.description,
deadline_at: req.body?.deadline_at,
department_ids: normalizedScope.departmentIds,
asset_type_ids: normalizedScope.assetTypeIds,
storage_location_ids: normalizedScope.storageLocationIds,
person_ids: normalizedScope.personIds
},
currentUserId
)
successResponse(res, result, '创建成功')
} catch (error: any) {
errorResponse(res, error.message || '创建盘点任务失败', 422)
}
}
export const getInventoryTaskListController = async (req: Request, res: Response) => {
try {
const page = Math.max(1, Number(req.query.page) || 1)
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize) || 10))
const result = await listInventoryTasks(page, pageSize)
successResponse(res, result, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取盘点任务列表失败', 500)
}
}
export const getInventoryTaskDetailController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
const result = await getInventoryTaskDetail(taskId)
successResponse(res, result, '获取成功')
} catch (error: any) {
const message = error.message || '获取盘点任务详情失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
}
}
export const closeInventoryTaskController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
const currentUserId = Number(res.locals.user?.id)
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
if (!Number.isInteger(currentUserId) || currentUserId <= 0) {
return errorResponse(res, '未获取到当前登录用户', 401)
}
await closeInventoryTask(taskId, currentUserId)
successResponse(res, null, '任务已关闭')
} catch (error: any) {
const message = error.message || '关闭盘点任务失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
}
}
export const deleteInventoryTaskController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
await deleteInventoryTask(taskId)
successResponse(res, null, '任务已删除')
} catch (error: any) {
const message = error.message || '删除盘点任务失败'
if (message.includes('不存在')) {
return errorResponse(res, message, 404)
}
errorResponse(res, message, 422)
}
}
export const getInventoryTaskUnassignedController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
const list = await getTaskUnassignedItems(taskId)
successResponse(res, list, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取无人归属资产失败', 500)
}
}
export const assignInventoryTaskUnassignedController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
const itemId = Number(req.params.itemId)
const personId = Number(req.body?.person_id)
if (!Number.isInteger(taskId) || taskId <= 0 || !Number.isInteger(itemId) || itemId <= 0) {
return errorResponse(res, '待处理资产不存在', 404)
}
if (!Number.isInteger(personId) || personId <= 0) {
return errorResponse(res, '目标人员不能为空', 422)
}
await assignUnassignedTaskItem(taskId, itemId, personId)
successResponse(res, null, '分配成功')
} catch (error: any) {
const message = error.message || '分配无人归属资产失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 422)
}
}
export const submitAdminInventoryScanController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
const currentUserId = Number(res.locals.user?.id)
const equipmentCode = String(req.body?.equipment_code || '').trim()
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
if (!Number.isInteger(currentUserId) || currentUserId <= 0) {
return errorResponse(res, '未获取到当前登录用户', 401)
}
if (!equipmentCode) {
return errorResponse(res, '资产编码不能为空', 422)
}
const item = await submitAdminInventoryScan(taskId, equipmentCode, currentUserId)
successResponse(res, item, '管理人员代扫成功')
} catch (error: any) {
const message = error.message || '管理人员扫码盘点失败'
errorResponse(
res,
message,
message.includes('不存在') ? 404 : message.includes('关闭') ? 403 : 422
)
}
}

View File

@ -1,41 +1,123 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
import { calculateEquipmentValues } from '../utils/assetValue'
import { loadAndLockAssetTree } from '../services/assetMovementService'
import { buildMaintenanceSnapshot, validateMaintenanceSnapshot } from '../utils/maintenanceSnapshot'
class MaintenanceConflictError extends Error {
status = 409
}
const blockedMaintenanceStatuses = new Set(['已借出', '维护中', '待报废', '已报废'])
const MAINTENANCE_STATUSES = ['in_progress', 'completed', 'cancelled'] as const
const parseListPositiveInteger = (
value: unknown,
fieldName: string,
fallback: number,
max?: number
) => {
if (value === undefined || value === null || value === '') {
if (fallback > 0) return fallback
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+$/.test(String(value))) {
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
return parsed
}
// 创建维修记录
export const createMaintenance = async (req: Request, res: Response) => {
try {
const { equipment_id, issue_description, repair_vendor, estimated_cost, expected_end_date, notes } = req.body
const userId = res.locals.user?.id
const { equipment_id, issue_description, repair_vendor, estimated_cost, expected_end_date } = req.body
const userId = Number(res.locals.user?.id)
const equipmentId = Number(equipment_id)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
const equipment = await prisma.equipment.findUnique({
where: { id: Number(equipment_id) }
})
if (!equipment) {
return errorResponse(res, '资产不存在', 404)
if (!Number.isInteger(equipmentId) || equipmentId <= 0) {
return errorResponse(res, '资产ID无效', 422)
}
if (equipment.status === '维护中') {
return errorResponse(res, '资产已在维修中')
}
if (equipment.status === '待报废' || equipment.status === '已报废') {
return errorResponse(res, '资产已报废,无法送修')
}
const result = await prisma.$transaction(async (tx) => {
const tree = await loadAndLockAssetTree(tx, equipmentId)
const equipment = tree[0]
if (equipment.parent_id) {
throw Object.assign(new Error('该子资产当前已挂载,请先卸载后再送修'), { status: 422 })
}
try {
await ensureNoActiveBorrowRecords(equipment.id, '资产存在未归还记录,无法送修')
} catch (error: any) {
return errorResponse(res, error.message || '资产借用状态校验失败', 422)
}
const blocked = tree.find((item) => blockedMaintenanceStatuses.has(item.status))
if (blocked) {
const message = blocked.status === '维护中'
? `资产 ${blocked.equipment_code} 已在维修中`
: blocked.status === '已借出'
? `资产 ${blocked.equipment_code} 尚未归还,无法送修`
: `资产 ${blocked.equipment_code} 已在报废流程中或已报废,无法送修`
throw Object.assign(new Error(message), { status: 422 })
}
const equipmentIds = tree.map((item) => item.id)
const [activeBorrowCount, activeDisposalCount, activeMaintenanceCount] = await Promise.all([
tx.borrowRecord.count({
where: { equipment_id: { in: equipmentIds }, status: '已借出' }
}),
tx.disposalItem.count({
where: {
equipment_id: { in: equipmentIds },
disposal: { status: { in: ['pending', 'approved'] } }
}
}),
tx.maintenanceRecord.count({
where: { equipment_id: { in: equipmentIds }, status: 'in_progress' }
})
])
if (activeBorrowCount > 0) {
throw Object.assign(new Error('资产树存在未归还记录,无法送修'), { status: 422 })
}
if (activeDisposalCount > 0) {
throw Object.assign(new Error('资产树存在进行中的报废申请,无法送修'), { status: 422 })
}
if (activeMaintenanceCount > 0) {
throw new MaintenanceConflictError('资产树已有进行中的维修记录,请刷新后查看')
}
if (tree.some((item) => !['可用', '已损坏'].includes(item.status))) {
throw new MaintenanceConflictError('资产原状态无效,请人工核实后送修')
}
// All rows are locked by loadAndLockAssetTree; conditional writes still reject stale state.
for (const item of tree) {
const values = calculateEquipmentValues({
unitPrice: Number(item.unit_price),
totalQuantity: item.total_quantity,
remainingQuantity: item.remaining_quantity,
status: '维护中'
})
const acquired = await tx.equipment.updateMany({
where: {
id: item.id,
status: item.status,
parent_id: item.parent_id,
remaining_quantity: item.remaining_quantity,
department_id: item.department_id,
mount_status: item.mount_status,
updated_at: item.updated_at
},
data: { status: '维护中', ...values }
})
if (acquired.count !== 1) {
throw new MaintenanceConflictError('资产状态、库存或归属已变化,请刷新后重试')
}
}
const maintenance = await prisma.$transaction(async (tx) => {
const created = await tx.maintenanceRecord.create({
data: {
equipment_id: equipment.id,
@ -44,17 +126,13 @@ export const createMaintenance = async (req: Request, res: Response) => {
repair_vendor,
estimated_cost: Number(estimated_cost) || 0,
expected_end_date: expected_end_date ? new Date(expected_end_date) : null,
created_by: userId
created_by: userId,
original_tree_snapshot: buildMaintenanceSnapshot(tree)
}
})
await tx.equipment.update({
where: { id: equipment.id },
data: { status: '维护中' }
})
return created
})
return { maintenance: created, equipment }
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
// 记录操作日志
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
@ -62,16 +140,16 @@ export const createMaintenance = async (req: Request, res: Response) => {
userId,
username,
'送修',
`资产 ${equipment.equipment_name}(${equipment.equipment_code}) 送修,故障描述:${issue_description}${repair_vendor ? `,维修商:${repair_vendor}` : ''}`,
equipment.id,
equipment.equipment_code,
`资产 ${result.equipment.equipment_name}(${result.equipment.equipment_code}) 送修,故障描述:${issue_description}${repair_vendor ? `,维修商:${repair_vendor}` : ''}`,
result.equipment.id,
result.equipment.equipment_code,
req.ip
)
successResponse(res, maintenance, '维修记录创建成功')
successResponse(res, result.maintenance, '维修记录创建成功')
} catch (error: any) {
console.error('[创建维修记录失败]', error)
errorResponse(res, error.message || '创建维修记录失败')
errorResponse(res, error.message || '创建维修记录失败', error.status || 500)
}
}
@ -79,36 +157,74 @@ export const createMaintenance = async (req: Request, res: Response) => {
export const getMaintenanceList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, status, keyword, equipment_id } = req.query
const skip = (Number(page) - 1) * Number(pageSize)
const currentUserRole = String(res.locals.user?.role || '')
if (!['admin', 'it'].includes(currentUserRole)) {
return errorResponse(res, '无权查看维修记录', 403)
}
const currentPage = parseListPositiveInteger(page, '页码', 1)
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100)
const skip = (currentPage - 1) * currentPageSize
const where: any = {}
if (status) where.status = status
if (equipment_id) where.equipment_id = Number(equipment_id)
if (keyword) {
where.OR = [
{ equipment_code: { contains: String(keyword) } },
{ issue_description: { contains: String(keyword) } },
{ repair_vendor: { contains: String(keyword) } }
const normalizedStatus = String(status || '').trim()
if (normalizedStatus && !MAINTENANCE_STATUSES.includes(normalizedStatus as any)) {
return errorResponse(res, '维修状态无效', 422)
}
const summaryWhere: any = {}
if (equipment_id !== undefined && equipment_id !== '') {
summaryWhere.equipment_id = parseListPositiveInteger(equipment_id, '资产ID', 0)
}
const normalizedKeyword = String(keyword || '').trim()
if (normalizedKeyword) {
summaryWhere.OR = [
{ equipment_code: { contains: normalizedKeyword } },
{ issue_description: { contains: normalizedKeyword } },
{ repair_vendor: { contains: normalizedKeyword } }
]
}
const where = normalizedStatus
? { AND: [summaryWhere, { status: normalizedStatus }] }
: summaryWhere
const [list, total] = await Promise.all([
const [list, total, statusGroups] = await Promise.all([
prisma.maintenanceRecord.findMany({
where,
skip,
take: Number(pageSize),
take: currentPageSize,
orderBy: { created_at: 'desc' },
include: {
equipment: { select: { equipment_name: true, equipment_type: true } },
creator: { select: { id: true, name: true, username: true } }
}
}),
prisma.maintenanceRecord.count({ where })
prisma.maintenanceRecord.count({ where }),
prisma.maintenanceRecord.groupBy({
by: ['status'],
where: summaryWhere,
_count: { _all: true }
})
])
successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功')
const summary = {
total: 0,
in_progress: 0,
completed: 0,
cancelled: 0
}
for (const group of statusGroups) {
const count = group._count._all
summary.total += count
if (MAINTENANCE_STATUSES.includes(group.status as any)) {
summary[group.status as (typeof MAINTENANCE_STATUSES)[number]] = count
}
}
successResponse(
res,
{ list, total, page: currentPage, pageSize: currentPageSize, summary },
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取维修记录列表失败')
errorResponse(res, error.message || '获取维修记录列表失败', error.status || 500)
}
}
@ -138,32 +254,39 @@ export const getMaintenanceDetail = async (req: Request, res: Response) => {
export const updateMaintenance = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { repair_vendor, estimated_cost, expected_end_date, notes } = req.body
const { repair_vendor, estimated_cost, expected_end_date } = req.body
const maintenanceId = Number(id)
const maintenance = await prisma.maintenanceRecord.findUnique({
where: { id: Number(id) }
})
if (!maintenance) {
return errorResponse(res, '维修记录不存在', 404)
if (!Number.isInteger(maintenanceId) || maintenanceId <= 0) {
return errorResponse(res, '维修记录ID无效', 422)
}
if (maintenance.status !== 'in_progress') {
return errorResponse(res, '只能更新进行中的维修记录')
}
const updated = await prisma.maintenanceRecord.update({
where: { id: Number(id) },
const result = await prisma.maintenanceRecord.updateMany({
where: { id: maintenanceId, status: 'in_progress' },
data: {
repair_vendor,
estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : undefined,
expected_end_date: expected_end_date ? new Date(expected_end_date) : undefined
}
})
if (result.count !== 1) {
const current = await prisma.maintenanceRecord.findUnique({
where: { id: maintenanceId },
select: { id: true, status: true }
})
if (!current) {
return errorResponse(res, '维修记录不存在', 404)
}
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
const updated = await prisma.maintenanceRecord.findUnique({
where: { id: maintenanceId }
})
successResponse(res, updated, '更新成功')
} catch (error: any) {
errorResponse(res, error.message || '更新维修记录失败')
errorResponse(res, error.message || '更新维修记录失败', error.status || 500)
}
}
@ -172,35 +295,58 @@ export const completeMaintenance = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { actual_cost, repair_result } = req.body
const maintenanceId = parseListPositiveInteger(id, '维修记录ID', 0)
const maintenance = await prisma.maintenanceRecord.findUnique({
where: { id: Number(id) }
})
if (!maintenance) {
return errorResponse(res, '维修记录不存在', 404)
}
if (maintenance.status !== 'in_progress') {
return errorResponse(res, '只能完成进行中的维修记录')
}
await prisma.$transaction(async (tx) => {
await tx.maintenanceRecord.update({
where: { id: Number(id) },
const maintenance = await prisma.$transaction(async (tx) => {
await tx.$queryRaw(Prisma.sql`SELECT id FROM maintenance_record WHERE id = ${maintenanceId} FOR UPDATE`)
const currentMaintenance = await tx.maintenanceRecord.findUnique({ where: { id: maintenanceId } })
if (!currentMaintenance) {
throw Object.assign(new Error('维修记录不存在'), { status: 404 })
}
if (currentMaintenance.status !== 'in_progress') {
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
const tree = await loadAndLockAssetTree(tx, currentMaintenance.equipment_id)
if (tree[0].parent_id !== null || tree.some((item) => item.status !== '维护中')) {
throw new MaintenanceConflictError('资产树已不处于维修状态,不能覆盖当前状态')
}
// Legacy records can be completed after whole-tree checks, but cannot be
// cancelled without original statuses. New records must match their tree.
if (currentMaintenance.original_tree_snapshot) {
validateMaintenanceSnapshot(currentMaintenance.original_tree_snapshot, tree, currentMaintenance.equipment_id)
}
const updatedMaintenance = await tx.maintenanceRecord.updateMany({
where: { id: maintenanceId, status: 'in_progress', updated_at: currentMaintenance.updated_at },
data: {
status: 'completed',
actual_cost: actual_cost !== undefined ? Number(actual_cost) : maintenance.estimated_cost,
actual_cost: actual_cost !== undefined ? Number(actual_cost) : currentMaintenance.estimated_cost,
actual_end_date: new Date(),
repair_result
}
})
await tx.equipment.update({
where: { id: maintenance.equipment_id },
data: { status: '可用' }
})
})
if (updatedMaintenance.count !== 1) {
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
for (const item of tree) {
const values = calculateEquipmentValues({
unitPrice: Number(item.unit_price), totalQuantity: item.total_quantity,
remainingQuantity: item.remaining_quantity, status: '可用'
})
const updated = await tx.equipment.updateMany({
where: {
id: item.id, status: '维护中', parent_id: item.parent_id,
total_quantity: item.total_quantity, remaining_quantity: item.remaining_quantity,
department_id: item.department_id, mount_status: item.mount_status,
unit_price: item.unit_price, updated_at: item.updated_at
},
data: { status: '可用', ...values }
})
if (updated.count !== 1) {
throw new MaintenanceConflictError('资产状态、库存或挂载关系已变化,不能覆盖当前状态')
}
}
return currentMaintenance
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
// 记录操作日志
const userId = res.locals.user?.id
@ -217,7 +363,7 @@ export const completeMaintenance = async (req: Request, res: Response) => {
successResponse(res, null, '维修完成')
} catch (error: any) {
errorResponse(res, error.message || '完成维修失败')
errorResponse(res, error.message || '完成维修失败', error.status || 500)
}
}
@ -226,30 +372,63 @@ export const cancelMaintenance = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { reason } = req.body
const maintenanceId = parseListPositiveInteger(id, '维修记录ID', 0)
const maintenance = await prisma.maintenanceRecord.findUnique({
where: { id: Number(id) }
})
const maintenance = await prisma.$transaction(async (tx) => {
// Completion acquires the maintenance row before equipment writes. Use
// the same order to prevent completion/cancellation from deadlocking.
await tx.$queryRaw(Prisma.sql`SELECT id FROM maintenance_record WHERE id = ${maintenanceId} FOR UPDATE`)
const currentMaintenance = await tx.maintenanceRecord.findUnique({
where: { id: maintenanceId }
})
if (!currentMaintenance) {
throw Object.assign(new Error('维修记录不存在'), { status: 404 })
}
if (!maintenance) {
return errorResponse(res, '维修记录不存在', 404)
}
if (maintenance.status !== 'in_progress') {
return errorResponse(res, '只能取消进行中的维修记录')
}
await prisma.$transaction(async (tx) => {
await tx.maintenanceRecord.update({
where: { id: Number(id) },
if (currentMaintenance.status !== 'in_progress') {
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
const tree = await loadAndLockAssetTree(tx, currentMaintenance.equipment_id)
const snapshot = validateMaintenanceSnapshot(
currentMaintenance.original_tree_snapshot,
tree,
currentMaintenance.equipment_id
)
const updatedMaintenance = await tx.maintenanceRecord.updateMany({
where: { id: maintenanceId, status: 'in_progress', updated_at: currentMaintenance.updated_at },
data: { status: 'cancelled', repair_result: reason || '已取消' }
})
if (updatedMaintenance.count !== 1) {
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
await tx.equipment.update({
where: { id: maintenance.equipment_id },
data: { status: '可用' }
})
})
for (const item of tree) {
const previous = snapshot.find((entry) => entry.id === item.id)!
const values = calculateEquipmentValues({
unitPrice: Number(item.unit_price),
totalQuantity: item.total_quantity,
remainingQuantity: previous.remaining_quantity,
status: previous.status
})
const restored = await tx.equipment.updateMany({
where: {
id: item.id,
status: '维护中',
parent_id: previous.parent_id,
total_quantity: item.total_quantity,
remaining_quantity: item.remaining_quantity,
department_id: item.department_id,
mount_status: item.mount_status,
updated_at: item.updated_at
},
data: { status: previous.status, remaining_quantity: previous.remaining_quantity, ...values }
})
if (restored.count !== 1) {
throw new MaintenanceConflictError('资产已不处于原维修状态,不能覆盖当前状态')
}
}
return currentMaintenance
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
// 记录操作日志
const userId = res.locals.user?.id
@ -266,7 +445,7 @@ export const cancelMaintenance = async (req: Request, res: Response) => {
successResponse(res, null, '维修已取消')
} catch (error: any) {
errorResponse(res, error.message || '取消维修失败')
errorResponse(res, error.message || '取消维修失败', error.status || 500)
}
}

View File

@ -1,322 +1,402 @@
import { Request, Response } from 'express'
import { Decimal } from '@prisma/client/runtime/library'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
import {
calculateEquipmentValues,
calculateChildrenTotalValueFromRecords,
calculateCurrentTotalValueWithChildren,
refreshParentTotalValueWithChildren
} from '../utils/assetValue'
import {
loadAndLockAssetTree,
lockEquipmentRows,
type AssetTreeItem
} from '../services/assetMovementService'
async function calculateTotalValueWithChildren(parentId: number): Promise<Decimal> {
const parent = await prisma.equipment.findUnique({
where: { id: parentId },
include: {
children: {
select: { unit_price: true }
}
}
})
type TransactionClient = Prisma.TransactionClient
type MountLifecycleStatus = '可用' | '已借出' | '维护中' | '待报废' | '已报废'
if (!parent) return new Decimal(0)
const PARENT_MOUNTABLE_STATUSES = new Set<MountLifecycleStatus>(['可用', '已借出'])
const childrenValue = parent.children.reduce(
(sum, child) => sum.add(child.unit_price),
new Decimal(0)
)
class MountOperationError extends Error {
statusCode: number
return parent.unit_price.add(childrenValue)
constructor(message: string, statusCode: number = 422) {
super(message)
this.name = 'MountOperationError'
this.statusCode = statusCode
}
}
async function updateParentTotalValue(parentId: number): Promise<void> {
const totalValue = await calculateTotalValueWithChildren(parentId)
await prisma.equipment.update({
where: { id: parentId },
data: { total_value_with_children: totalValue }
})
const ensureParentMountOperable = (equipment: { status: string }, label: string) => {
if (equipment.status === '维护中') {
throw new MountOperationError(`${label}正在维修,请先完成或取消维修后再挂载或卸载配件`, 409)
}
if (!PARENT_MOUNTABLE_STATUSES.has(equipment.status as MountLifecycleStatus)) {
throw new MountOperationError(`${label}当前状态不可挂载或更换配件`)
}
}
const ensureMountableEquipment = async (
equipment: { id: number; status: string; remaining_quantity: number },
const resolveDetachedChildStatus = (status?: string): MountLifecycleStatus => {
const normalized = (status || '').trim()
if (!normalized) {
return '可用'
}
if (normalized !== '可用') {
throw new MountOperationError('拆下的配件只能恢复为可用;维修或报废请使用正式业务流程')
}
return '可用'
}
const buildEquipmentStateUpdate = (
equipment: { unit_price: unknown; total_quantity: number; status: string },
targetStatus: MountLifecycleStatus
) => {
const totalQuantity = Math.max(1, Number(equipment.total_quantity) || 1)
const remainingQuantity = targetStatus === '已借出' ? 0 : totalQuantity
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price) || 0,
totalQuantity,
remainingQuantity,
status: targetStatus
})
return {
status: targetStatus,
remaining_quantity: remainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
}
const resolveMountedChildStatus = (parent: { status: string }): MountLifecycleStatus => {
return parent.status === '已借出' || parent.status === '维护中'
? (parent.status as MountLifecycleStatus)
: '可用'
}
const ensureAssetTreeAttachable = async (
client: TransactionClient,
items: AssetTreeItem[],
label: string
) => {
if (equipment.status !== '可用' || equipment.remaining_quantity <= 0) {
throw new Error(`${label}当前状态不可挂载`)
const blocked = items.find((item) => item.status !== '可用' || item.remaining_quantity <= 0)
if (blocked) {
throw new MountOperationError(`${label}中的资产 ${blocked.equipment_code} 当前状态不可挂载`)
}
const activeBorrowCount = await client.borrowRecord.count({
where: {
equipment_id: { in: items.map((item) => item.id) },
status: '已借出'
}
})
if (activeBorrowCount > 0) {
throw new MountOperationError(`${label}存在未归还记录,当前不可挂载`)
}
await ensureNoActiveBorrowRecords(equipment.id, `${label}存在未归还记录,当前不可挂载`)
}
const ensureNoMountCycle = async (parentId: number, childId: number) => {
const updateAssetTreeState = async (
client: TransactionClient,
items: AssetTreeItem[],
input: {
rootParentId: number | null
rootMountStatus: 'mounted' | 'unmounted'
targetStatus: MountLifecycleStatus
departmentId: number | null
}
) => {
const rootId = items[0]?.id
for (const item of items) {
const isRoot = item.id === rootId
const claimed = await client.equipment.updateMany({
where: {
id: item.id,
parent_id: item.parent_id,
department_id: item.department_id,
status: item.status,
mount_status: item.mount_status,
remaining_quantity: item.remaining_quantity,
updated_at: item.updated_at
},
data: {
...(isRoot
? {
parent_id: input.rootParentId,
mount_status: input.rootMountStatus
}
: {}),
department_id: input.departmentId,
...buildEquipmentStateUpdate(item, input.targetStatus)
}
})
if (claimed.count !== 1) {
throw new MountOperationError(`资产 ${item.equipment_code} 状态或挂载关系已变化,请刷新后重试`, 409)
}
}
}
const ensureNoMountCycle = async (
client: TransactionClient,
parentId: number,
childId: number
) => {
if (parentId === childId) {
throw new Error('父资产和子资产不能是同一条记录')
throw new MountOperationError('父资产和子资产不能是同一条记录')
}
let current = await prisma.equipment.findUnique({
where: { id: parentId },
select: { parent_id: true }
})
while (current?.parent_id) {
if (current.parent_id === childId) {
throw new Error('不能把资产挂载到自己的下级节点上')
const visited = new Set<number>()
let currentId: number | null = parentId
for (let depth = 0; currentId !== null; depth += 1) {
if (depth >= 100) {
throw new MountOperationError('资产父子关系层级过深,请先检查资产关系', 409)
}
if (currentId === childId) {
throw new MountOperationError('不能把资产挂载到自己的下级节点上')
}
if (visited.has(currentId)) {
throw new MountOperationError('现有资产关系已存在循环,请先修复关系', 409)
}
visited.add(currentId)
current = await prisma.equipment.findUnique({
where: { id: current.parent_id },
await lockEquipmentRows(client, [currentId])
const current: { parent_id: number | null } | null = await client.equipment.findUnique({
where: { id: currentId },
select: { parent_id: true }
})
currentId = current?.parent_id ?? null
}
}
const getMountErrorStatus = (error: any) => {
if (error instanceof MountOperationError) return error.statusCode
if (Number.isInteger(error?.statusCode)) return error.statusCode
if (Number.isInteger(error?.status)) return error.status
if (error?.code === 'P2034' || /deadlock|lock wait timeout/i.test(String(error?.message || ''))) {
return 409
}
return 500
}
export const mountChild = async (req: Request, res: Response) => {
try {
const numericParentId = Number(req.params.parentId)
const { child_id, notes } = req.body as { child_id: number; notes?: string }
const numericChildId = Number(child_id)
const userId = Number(res.locals.user?.id)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
try {
await ensureNoMountCycle(numericParentId, child_id)
} catch (error: any) {
return errorResponse(res, error.message || '挂载关系校验失败', 422)
if (!Number.isInteger(numericParentId) || !Number.isInteger(numericChildId) || numericParentId <= 0 || numericChildId <= 0) {
return errorResponse(res, '资产参数不合法', 422)
}
const parent = await prisma.equipment.findUnique({
where: { id: numericParentId },
include: { assetType: true }
})
if (!parent) {
return errorResponse(res, '父资产不存在', 404)
}
if (!parent.assetType?.can_be_parent) {
return errorResponse(res, '该资产类型不支持挂载配件', 400)
}
try {
await ensureMountableEquipment(parent, '父资产')
} catch (error: any) {
return errorResponse(res, error.message || '父资产状态校验失败', 422)
}
const child = await prisma.equipment.findUnique({
where: { id: child_id },
include: { assetType: true }
})
if (!child) {
return errorResponse(res, '子资产不存在', 404)
}
if (!child.assetType?.can_be_child) {
return errorResponse(res, '该资产类型不能作为配件挂载', 400)
}
try {
await ensureMountableEquipment(child, '子资产')
} catch (error: any) {
return errorResponse(res, error.message || '子资产状态校验失败', 422)
}
if (child.parent_id) {
const currentParent = await prisma.equipment.findUnique({
where: { id: child.parent_id }
})
return errorResponse(res, `该配件已挂载到 ${currentParent?.equipment_name || '其他资产'}`, 400)
}
await prisma.$transaction(async (tx) => {
await tx.equipment.update({
where: { id: child_id },
data: {
parent_id: numericParentId,
mount_status: 'mounted'
}
const updatedParent = await prisma.$transaction(async (tx) => {
await lockEquipmentRows(tx, [numericParentId, numericChildId])
const [parent, child] = await Promise.all([
tx.equipment.findUnique({
where: { id: numericParentId },
include: { assetType: true }
}),
tx.equipment.findUnique({
where: { id: numericChildId },
include: { assetType: true }
})
])
if (!parent) throw new MountOperationError('父资产不存在', 404)
if (!child) throw new MountOperationError('子资产不存在', 404)
if (!parent.assetType?.can_be_parent) {
throw new MountOperationError('该资产类型不支持挂载配件', 400)
}
if (!child.assetType?.can_be_child) {
throw new MountOperationError('该资产类型不能作为配件挂载', 400)
}
ensureParentMountOperable(parent, '父资产')
if (child.parent_id !== null || !['none', 'unmounted'].includes(child.mount_status)) {
throw new MountOperationError('该配件已被挂载或关系已变化,请刷新后重试', 409)
}
await ensureNoMountCycle(tx, numericParentId, numericChildId)
const childTree = await loadAndLockAssetTree(tx, numericChildId)
await ensureAssetTreeAttachable(tx, childTree, '子资产树')
await updateAssetTreeState(tx, childTree, {
rootParentId: numericParentId,
rootMountStatus: 'mounted',
targetStatus: resolveMountedChildStatus(parent),
departmentId: parent.department_id
})
await tx.mountHistory.create({
data: {
child_id,
child_id: numericChildId,
parent_id: numericParentId,
operation_type: 'mount',
operated_by: userId,
notes
}
})
})
await refreshParentTotalValueWithChildren(tx, numericParentId)
await updateParentTotalValue(numericParentId)
const updatedParent = await prisma.equipment.findUnique({
where: { id: numericParentId },
include: {
children: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true,
status: true
return tx.equipment.findUnique({
where: { id: numericParentId },
include: {
children: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true,
status: true
}
}
}
}
})
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
successResponse(res, updatedParent, '挂载成功')
} catch (error: any) {
errorResponse(res, error.message || '挂载失败')
errorResponse(res, error.message || '挂载失败', getMountErrorStatus(error))
}
}
export const unmountChild = async (req: Request, res: Response) => {
try {
const numericParentId = Number(req.params.parentId)
const { child_id, notes } = req.body as { child_id: number; notes?: string }
const { child_id, notes, released_child_status } = req.body as {
child_id: number
notes?: string
released_child_status?: string
}
const userId = Number(res.locals.user?.id)
const detachedChildStatus = resolveDetachedChildStatus(released_child_status)
const numericChildId = Number(child_id)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
const parent = await prisma.equipment.findUnique({
where: { id: numericParentId }
})
if (!parent) {
return errorResponse(res, '父资产不存在', 404)
}
const child = await prisma.equipment.findUnique({
where: { id: child_id }
})
if (!child) {
return errorResponse(res, '子资产不存在', 404)
}
if (child.parent_id !== numericParentId) {
return errorResponse(res, '该配件未挂载到此资产', 400)
}
try {
await ensureMountableEquipment(parent, '父资产')
await ensureMountableEquipment(child, '子资产')
} catch (error: any) {
return errorResponse(res, error.message || '卸载状态校验失败', 422)
if (!Number.isInteger(numericParentId) || !Number.isInteger(numericChildId) || numericParentId <= 0 || numericChildId <= 0) {
return errorResponse(res, '资产参数不合法', 422)
}
await prisma.$transaction(async (tx) => {
await tx.equipment.update({
where: { id: child_id },
data: {
parent_id: null,
mount_status: 'unmounted'
}
await lockEquipmentRows(tx, [numericParentId, numericChildId])
const [parent, child] = await Promise.all([
tx.equipment.findUnique({ where: { id: numericParentId } }),
tx.equipment.findUnique({ where: { id: numericChildId } })
])
if (!parent) throw new MountOperationError('父资产不存在', 404)
if (!child) throw new MountOperationError('子资产不存在', 404)
if (child.parent_id !== numericParentId) {
throw new MountOperationError('该配件未挂载到此资产或关系已变化', 409)
}
ensureParentMountOperable(parent, '父资产')
const childTree = await loadAndLockAssetTree(tx, numericChildId)
await updateAssetTreeState(tx, childTree, {
rootParentId: null,
rootMountStatus: 'unmounted',
targetStatus: detachedChildStatus,
departmentId: null
})
await tx.mountHistory.create({
data: {
child_id,
child_id: numericChildId,
parent_id: numericParentId,
operation_type: 'unmount',
operated_by: userId,
notes
}
})
})
await updateParentTotalValue(numericParentId)
await refreshParentTotalValueWithChildren(tx, numericParentId)
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
successResponse(res, null, '卸载成功')
} catch (error: any) {
errorResponse(res, error.message || '卸载失败')
errorResponse(res, error.message || '卸载失败', getMountErrorStatus(error))
}
}
export const replaceChild = async (req: Request, res: Response) => {
try {
const numericParentId = Number(req.params.parentId)
const { old_child_id, new_child_id, notes } = req.body as {
const { old_child_id, new_child_id, notes, released_child_status } = req.body as {
old_child_id: number
new_child_id: number
notes?: string
released_child_status?: string
}
const userId = Number(res.locals.user?.id)
const detachedChildStatus = resolveDetachedChildStatus(released_child_status)
const numericOldChildId = Number(old_child_id)
const numericNewChildId = Number(new_child_id)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
if (old_child_id === new_child_id) {
if (
!Number.isInteger(numericParentId) || !Number.isInteger(numericOldChildId) ||
!Number.isInteger(numericNewChildId) || numericParentId <= 0 ||
numericOldChildId <= 0 || numericNewChildId <= 0
) {
return errorResponse(res, '资产参数不合法', 422)
}
if (numericOldChildId === numericNewChildId) {
return errorResponse(res, '旧配件和新配件不能相同', 422)
}
try {
await ensureNoMountCycle(numericParentId, new_child_id)
} catch (error: any) {
return errorResponse(res, error.message || '更换关系校验失败', 422)
}
const oldChild = await prisma.equipment.findUnique({
where: { id: old_child_id }
})
if (!oldChild || oldChild.parent_id !== numericParentId) {
return errorResponse(res, '旧配件未挂载到此资产', 400)
}
const parent = await prisma.equipment.findUnique({
where: { id: numericParentId },
include: { assetType: true }
})
if (!parent) {
return errorResponse(res, '父资产不存在', 404)
}
if (!parent.assetType?.can_be_parent) {
return errorResponse(res, '该资产类型不支持挂载配件', 400)
}
try {
await ensureMountableEquipment(parent, '父资产')
await ensureMountableEquipment(oldChild, '旧配件')
} catch (error: any) {
return errorResponse(res, error.message || '挂载状态校验失败', 422)
}
const newChild = await prisma.equipment.findUnique({
where: { id: new_child_id },
include: { assetType: true }
})
if (!newChild) {
return errorResponse(res, '新配件不存在', 404)
}
if (!newChild.assetType?.can_be_child) {
return errorResponse(res, '新配件类型不能作为配件挂载', 400)
}
if (newChild.parent_id) {
return errorResponse(res, '新配件已挂载到其他资产', 400)
}
try {
await ensureMountableEquipment(newChild, '新配件')
} catch (error: any) {
return errorResponse(res, error.message || '新配件状态校验失败', 422)
}
await prisma.$transaction(async (tx) => {
await tx.equipment.update({
where: { id: old_child_id },
data: {
parent_id: null,
mount_status: 'unmounted'
}
await lockEquipmentRows(tx, [numericParentId, numericOldChildId, numericNewChildId])
const [parent, oldChild, newChild] = await Promise.all([
tx.equipment.findUnique({
where: { id: numericParentId },
include: { assetType: true }
}),
tx.equipment.findUnique({ where: { id: numericOldChildId } }),
tx.equipment.findUnique({
where: { id: numericNewChildId },
include: { assetType: true }
})
])
if (!parent) throw new MountOperationError('父资产不存在', 404)
if (!oldChild || oldChild.parent_id !== numericParentId) {
throw new MountOperationError('旧配件未挂载到此资产或关系已变化', 409)
}
if (!newChild) throw new MountOperationError('新配件不存在', 404)
if (!parent.assetType?.can_be_parent) {
throw new MountOperationError('该资产类型不支持挂载配件', 400)
}
if (!newChild.assetType?.can_be_child) {
throw new MountOperationError('新配件类型不能作为配件挂载', 400)
}
if (newChild.parent_id !== null || !['none', 'unmounted'].includes(newChild.mount_status)) {
throw new MountOperationError('新配件已被挂载或关系已变化,请刷新后重试', 409)
}
ensureParentMountOperable(parent, '父资产')
await ensureNoMountCycle(tx, numericParentId, numericNewChildId)
const oldChildTree = await loadAndLockAssetTree(tx, numericOldChildId)
const newChildTree = await loadAndLockAssetTree(tx, numericNewChildId)
await ensureAssetTreeAttachable(tx, newChildTree, '新配件树')
await updateAssetTreeState(tx, oldChildTree, {
rootParentId: null,
rootMountStatus: 'unmounted',
targetStatus: detachedChildStatus,
departmentId: null
})
await tx.equipment.update({
where: { id: new_child_id },
data: {
parent_id: numericParentId,
mount_status: 'mounted'
}
await updateAssetTreeState(tx, newChildTree, {
rootParentId: numericParentId,
rootMountStatus: 'mounted',
targetStatus: resolveMountedChildStatus(parent),
departmentId: parent.department_id
})
await tx.mountHistory.create({
data: {
child_id: old_child_id,
child_id: numericOldChildId,
parent_id: numericParentId,
operation_type: 'replace',
operated_by: userId,
@ -328,7 +408,7 @@ export const replaceChild = async (req: Request, res: Response) => {
await tx.mountHistory.create({
data: {
child_id: new_child_id,
child_id: numericNewChildId,
parent_id: numericParentId,
operation_type: 'replace',
operated_by: userId,
@ -337,13 +417,12 @@ export const replaceChild = async (req: Request, res: Response) => {
new_parent_id: numericParentId
}
})
})
await updateParentTotalValue(numericParentId)
await refreshParentTotalValueWithChildren(tx, numericParentId)
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
successResponse(res, null, '更换成功')
} catch (error: any) {
errorResponse(res, error.message || '更换失败')
errorResponse(res, error.message || '更换失败', getMountErrorStatus(error))
}
}
@ -374,17 +453,18 @@ export const getChildren = async (req: Request, res: Response) => {
return errorResponse(res, '资产不存在', 404)
}
const totalChildrenValue = parent.children.reduce(
(sum, child) => sum.add(child.unit_price),
new Decimal(0)
const totalChildrenValue = calculateChildrenTotalValueFromRecords(parent.children)
const currentTotalValueWithChildren = calculateCurrentTotalValueWithChildren(
parent.unit_price,
parent.children
)
successResponse(
res,
{
children: parent.children,
total_children_value: Number(totalChildrenValue),
total_value_with_children: Number(parent.total_value_with_children)
total_children_value: totalChildrenValue,
total_value_with_children: currentTotalValueWithChildren
},
'获取成功'
)
@ -466,10 +546,12 @@ export const getAvailableChildren = async (req: Request, res: Response) => {
}
if (keyword) {
const normalizedKeyword = String(keyword).trim()
where.OR = [
{ equipment_name: { contains: keyword as string } },
{ equipment_code: { contains: keyword as string } },
{ equipment_model: { contains: keyword as string } }
{ equipment_name: { contains: normalizedKeyword } },
{ equipment_code: { contains: normalizedKeyword } },
{ equipment_model: { contains: normalizedKeyword } },
{ equipment_type: { contains: normalizedKeyword } }
]
}

View File

@ -0,0 +1,194 @@
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { errorResponse, successResponse } from '../utils/response'
const parseChildrenSnapshot = (value: string | null, rootEquipmentId: number) => {
if (!value) return { items: [] as any[], valid: true }
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return { items: [] as any[], valid: false }
const depthById = new Map<number, number>([[rootEquipmentId, 0]])
const items = parsed
.filter((item) => item && typeof item === 'object')
.map((item: Record<string, unknown>) => {
const id = Number(item.id)
const rawParentId = Number(item.parent_id ?? item.parent_equipment_id)
const parentId = Number.isInteger(rawParentId) && rawParentId > 0
? rawParentId
: rootEquipmentId
const rawDepth = Number(item.depth)
const parentDepth = depthById.get(parentId)
const depth = Number.isInteger(rawDepth) && rawDepth > 0
? rawDepth
: (parentDepth ?? 0) + 1
if (Number.isInteger(id) && id > 0) depthById.set(id, depth)
return {
...item,
id,
parent_id: parentId,
parent_equipment_id: parentId,
root_equipment_id: rootEquipmentId,
depth,
relation_source: 'borrow_snapshot' as const
}
})
.filter((item) => Number.isInteger(item.id) && item.id > 0)
return { items, valid: true }
} catch {
return { items: [] as any[], valid: false }
}
}
const currentAccessorySelect = {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
status: true,
parent_id: true,
mount_status: true,
department_id: true,
storage_location_id: true
} as const
const loadCurrentAccessories = async (rootEquipmentIds: number[]) => {
const uniqueRootIds = [...new Set(rootEquipmentIds)]
const result = new Map<number, any[]>(uniqueRootIds.map((id) => [id, []]))
const nodeMeta = new Map<number, { rootEquipmentId: number; depth: number }>(
uniqueRootIds.map((id) => [id, { rootEquipmentId: id, depth: 0 }])
)
const visited = new Set(uniqueRootIds)
let parentIds = uniqueRootIds
let depth = 0
while (parentIds.length > 0) {
if (depth >= 100) {
throw Object.assign(new Error('资产父子关系超过 100 层,请先检查资产关系'), { status: 409 })
}
const children = await prisma.equipment.findMany({
where: { parent_id: { in: parentIds } },
select: currentAccessorySelect,
orderBy: { id: 'asc' }
})
if (children.length === 0) break
for (const child of children) {
if (visited.has(child.id)) {
throw Object.assign(
new Error(`资产 ${child.equipment_code} 存在循环或重复父子关系,请先检查资产关系`),
{ status: 409 }
)
}
const parentMeta = child.parent_id ? nodeMeta.get(child.parent_id) : null
if (!parentMeta) {
throw Object.assign(
new Error(`资产 ${child.equipment_code} 的父子关系不完整,请先检查资产关系`),
{ status: 409 }
)
}
const item = {
...child,
parent_equipment_id: child.parent_id,
root_equipment_id: parentMeta.rootEquipmentId,
depth: parentMeta.depth + 1,
relation_source: 'current' as const
}
visited.add(child.id)
nodeMeta.set(child.id, {
rootEquipmentId: parentMeta.rootEquipmentId,
depth: parentMeta.depth + 1
})
result.get(parentMeta.rootEquipmentId)?.push(item)
}
parentIds = children.map((child) => child.id)
depth += 1
}
return result
}
const getRelationshipFingerprint = (items: Array<{ id: number; parent_id?: number | null }>) =>
items
.map((item) => `${item.id}:${item.parent_id ?? 'null'}`)
.sort()
.join('|')
export const getMyAssets = async (_req: Request, res: Response) => {
try {
const personId = Number(res.locals.user?.person_id)
if (!Number.isInteger(personId) || personId <= 0) {
return errorResponse(res, '当前账号尚未绑定员工档案', 403)
}
const person = await prisma.person.findUnique({
where: { id: personId },
select: {
id: true,
real_name: true,
nickname: true,
job_number: true,
status: true,
source_employee_status: true,
mainDepartment: { select: { id: true, name: true } }
}
})
if (!person || person.status !== 'active') {
return errorResponse(res, '绑定的员工档案不存在或已离职', 403)
}
const records = await prisma.borrowRecord.findMany({
where: {
person_id: personId,
status: '已借出'
},
orderBy: [{ borrow_time: 'desc' }, { id: 'desc' }],
include: {
equipment: {
include: {
storageLocation: { select: { id: true, name: true, code: true } },
department: { select: { id: true, name: true } },
children: { orderBy: { id: 'asc' }, select: currentAccessorySelect }
}
}
}
})
const currentAccessoriesByRoot = await loadCurrentAccessories(
records.map((record) => record.equipment_id)
)
successResponse(res, {
person,
assets: records.map((record) => {
const currentAccessories = currentAccessoriesByRoot.get(record.equipment_id) || []
const snapshot = parseChildrenSnapshot(record.children_snapshot, record.equipment_id)
const relationshipChanged = !snapshot.valid ||
getRelationshipFingerprint(currentAccessories) !== getRelationshipFingerprint(snapshot.items)
const currentDirectChildren = currentAccessories.filter((item) => item.depth === 1)
const legacyChildren = currentDirectChildren.length > 0
? currentDirectChildren
: snapshot.items
return {
borrow_record_id: record.id,
borrow_time: record.borrow_time,
expected_return_time: record.expected_return_time,
is_long_term: record.is_long_term,
notes: record.notes,
equipment: { ...record.equipment, children: currentDirectChildren },
children: legacyChildren,
current_accessories: currentAccessories,
borrowed_accessories_snapshot: snapshot.items,
borrowed_accessories_snapshot_valid: snapshot.valid,
accessory_relationship_changed: relationshipChanged
}
})
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取本人资产失败', error.status || 500)
}
}

View File

@ -0,0 +1,163 @@
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { errorResponse, successResponse } from '../utils/response'
const parseMetadata = (value: string | null) => {
if (!value) return null
try {
return JSON.parse(value)
} catch {
return null
}
}
const getCurrentUserId = (res: Response) => {
const userId = Number(res.locals.user?.id)
return Number.isNaN(userId) ? null : userId
}
const sanitizeNotification = (item: any) => ({
id: item.id,
is_read: item.is_read,
read_at: item.read_at,
created_at: item.created_at,
notification_id: item.notification_id,
title: item.notification.title,
content: item.notification.content,
type: item.notification.type,
level: item.notification.level,
action_url: item.notification.action_url,
related_sync_job_id: item.notification.related_sync_job_id,
metadata: parseMetadata(item.notification.metadata),
notification_created_at: item.notification.created_at
})
export const getNotificationList = async (req: Request, res: Response) => {
try {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401)
}
const { page = 1, pageSize = 10, status = 'all' } = req.query
const where: any = { user_id: userId }
if (status === 'unread') {
where.is_read = false
}
const skip = (Number(page) - 1) * Number(pageSize)
const [list, total, unreadCount] = await Promise.all([
prisma.userNotification.findMany({
where,
skip,
take: Number(pageSize),
orderBy: [{ is_read: 'asc' }, { created_at: 'desc' }],
include: {
notification: true
}
}),
prisma.userNotification.count({ where }),
prisma.userNotification.count({
where: {
user_id: userId,
is_read: false
}
})
])
successResponse(
res,
{
list: list.map(sanitizeNotification),
total,
unread_count: unreadCount,
page: Number(page),
pageSize: Number(pageSize)
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取站内消息失败')
}
}
export const getUnreadNotificationCount = async (_req: Request, res: Response) => {
try {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401)
}
const count = await prisma.userNotification.count({
where: {
user_id: userId,
is_read: false
}
})
successResponse(res, { count }, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取未读消息数量失败')
}
}
export const markNotificationAsRead = async (req: Request, res: Response) => {
try {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401)
}
const id = Number(req.params.id)
const existing = await prisma.userNotification.findFirst({
where: {
id,
user_id: userId
},
include: {
notification: true
}
})
if (!existing) {
return errorResponse(res, '消息不存在', 404)
}
const updated = await prisma.userNotification.update({
where: { id },
data: {
is_read: true,
read_at: existing.read_at || new Date()
},
include: {
notification: true
}
})
successResponse(res, sanitizeNotification(updated), '已标记为已读')
} catch (error: any) {
errorResponse(res, error.message || '标记已读失败')
}
}
export const markAllNotificationsAsRead = async (_req: Request, res: Response) => {
try {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401)
}
const result = await prisma.userNotification.updateMany({
where: {
user_id: userId,
is_read: false
},
data: {
is_read: true,
read_at: new Date()
}
})
successResponse(res, { updated: result.count }, '全部标记为已读')
} catch (error: any) {
errorResponse(res, error.message || '全部标记已读失败')
}
}

View File

@ -1,41 +1,232 @@
import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import {
isSystemReservedPerson,
PUBLIC_ASSET_PERSON_SOURCE_USERID
} from '../utils/systemReservedPerson'
const sanitizePerson = (person: any) => ({
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number,
department: person.department,
position: person.position,
phone: person.phone,
email: person.email,
status: person.status,
remarks: person.remarks,
created_at: person.created_at,
updated_at: person.updated_at
})
const departmentSelect = {
id: true,
name: true,
status: true,
parent_id: true,
source_group_id: true,
source_system: true,
last_synced_at: true
}
const personInclude = {
mainDepartment: {
select: departmentSelect
},
personDepartments: {
select: {
department: {
select: departmentSelect
}
}
}
}
const normalizeOptionalText = (value: unknown) => {
if (value === undefined) return undefined
if (value === null) return null
const text = String(value).trim()
return text || null
}
const collectDepartments = (person: any) => {
const departments = [
...(person.mainDepartment ? [person.mainDepartment] : []),
...((person.personDepartments || []).map((item: any) => item.department).filter(Boolean) as any[])
]
const uniqueDepartments = new Map<number, any>()
for (const department of departments) {
if (!uniqueDepartments.has(department.id)) {
uniqueDepartments.set(department.id, department)
}
}
return Array.from(uniqueDepartments.values())
}
const sanitizePerson = (person: any) => {
const departments = collectDepartments(person)
const mainDepartment = person.mainDepartment || departments[0] || null
return {
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
job_number: person.job_number,
id_number: person.id_number,
department: mainDepartment?.name || person.department || null,
main_department_id: person.main_department_id,
main_department: mainDepartment
? {
id: mainDepartment.id,
name: mainDepartment.name,
status: mainDepartment.status,
parent_id: mainDepartment.parent_id,
source_group_id: mainDepartment.source_group_id,
source_system: mainDepartment.source_system,
last_synced_at: mainDepartment.last_synced_at
}
: null,
departments: departments.map((department) => ({
id: department.id,
name: department.name,
status: department.status,
parent_id: department.parent_id,
source_group_id: department.source_group_id,
source_system: department.source_system,
last_synced_at: department.last_synced_at
})),
department_names: departments.map((department) => department.name),
position: person.position,
phone: person.phone,
email: person.email,
status: person.status,
remarks: person.remarks,
source_userid: person.source_userid,
source_system: person.source_system,
source_main_department_id: person.source_main_department_id,
source_dept_ids: person.source_dept_ids,
source_department_name: person.source_department_name,
source_main_department_name: person.source_main_department_name,
department_sync_bucket: person.department_sync_bucket,
manager_source_userid: person.manager_source_userid,
source_employee_status: person.source_employee_status,
dingtalk_ready: Boolean(
person.status === 'active' &&
person.source_userid?.trim()
),
last_synced_at: person.last_synced_at,
is_synced: Boolean(person.source_system),
created_at: person.created_at,
updated_at: person.updated_at
}
}
const sanitizeBusinessPerson = (person: any) => {
const departments = collectDepartments(person)
const mainDepartment = person.mainDepartment || departments[0] || null
return {
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
job_number: person.job_number,
department: mainDepartment?.name || person.department || null,
main_department: mainDepartment
? {
id: mainDepartment.id,
name: mainDepartment.name,
status: mainDepartment.status
}
: null,
departments: departments.map((department) => ({
id: department.id,
name: department.name,
status: department.status
})),
department_names: departments.map((department) => department.name),
position: person.position,
status: person.status,
dingtalk_ready: Boolean(
person.status === 'active' &&
person.source_userid?.trim()
)
}
}
const parsePositiveInteger = (value: unknown, fallback: number) => {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
const buildPersonWhere = (keyword?: unknown, status?: unknown) => {
const where: any = {}
if (status) {
where.status = status
}
const kw = typeof keyword === 'string' ? keyword.trim() : ''
if (kw) {
where.OR = [
{ nickname: { contains: kw } },
{ real_name: { contains: kw } },
{ job_number: { contains: kw } },
{ department: { contains: kw } },
{
mainDepartment: {
is: {
name: { contains: kw }
}
}
},
{
personDepartments: {
some: {
department: {
name: { contains: kw }
}
}
}
}
]
}
return where
}
const resolveDepartmentByName = async (departmentName: string | null) => {
if (!departmentName) {
return null
}
return prisma.department.findFirst({
where: {
name: departmentName,
status: 'active',
...buildSyncedBusinessDepartmentWhere()
},
select: {
id: true,
name: true
},
orderBy: { id: 'asc' }
})
}
const replacePersonDepartments = async (
tx: any,
personId: number,
departmentIds: number[]
) => {
await tx.personDepartment.deleteMany({
where: { person_id: personId }
})
if (departmentIds.length > 0) {
await tx.personDepartment.createMany({
data: departmentIds.map((departmentId) => ({
person_id: personId,
department_id: departmentId
})),
skipDuplicates: true
})
}
}
export const getPersonList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, keyword, status } = req.query
const where: any = {}
if (keyword) {
const kw = keyword as string
where.OR = [
{ nickname: { contains: kw } },
{ real_name: { contains: kw } },
{ id_number: { contains: kw } }
]
}
if (status) {
where.status = status
}
const where = buildPersonWhere(keyword, status)
const skip = (Number(page) - 1) * Number(pageSize)
const [list, total] = await Promise.all([
@ -43,7 +234,8 @@ export const getPersonList = async (req: Request, res: Response) => {
where,
skip,
take: Number(pageSize),
orderBy: { created_at: 'desc' }
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
include: personInclude
}),
prisma.person.count({ where })
])
@ -66,10 +258,15 @@ export const getPersonList = async (req: Request, res: Response) => {
export const getPersonDetail = async (req: Request, res: Response) => {
try {
const { id } = req.params
const person = await prisma.person.findUnique({ where: { id: Number(id) } })
const person = await prisma.person.findUnique({
where: { id: Number(id) },
include: personInclude
})
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
successResponse(res, sanitizePerson(person), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取使用者详情失败')
@ -81,6 +278,7 @@ export const createPerson = async (req: Request, res: Response) => {
const {
nickname,
real_name,
job_number,
id_number,
department,
position,
@ -94,25 +292,48 @@ export const createPerson = async (req: Request, res: Response) => {
return errorResponse(res, '花名与真实姓名为必填项', 422)
}
if (id_number) {
const exists = await prisma.person.findUnique({ where: { id_number } })
const normalizedJobNumber = normalizeOptionalText(job_number)
if (normalizedJobNumber) {
const exists = await prisma.person.findUnique({ where: { job_number: normalizedJobNumber } })
if (exists) {
return errorResponse(res, '证件编号已存在', 409)
return errorResponse(res, '工号已存在', 409)
}
}
const created = await prisma.person.create({
data: {
nickname,
real_name,
id_number,
department,
position,
phone,
email,
status,
remarks
const normalizedIdNumber = normalizeOptionalText(id_number)
if (normalizedIdNumber) {
const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } })
if (exists) {
return errorResponse(res, '身份证号已存在', 409)
}
}
const normalizedDepartment = normalizeOptionalText(department) ?? null
const matchedDepartment = await resolveDepartmentByName(normalizedDepartment)
const created = await prisma.$transaction(async (tx) => {
const person = await tx.person.create({
data: {
nickname: String(nickname).trim(),
real_name: String(real_name).trim(),
job_number: normalizedJobNumber,
id_number: normalizedIdNumber,
department: normalizedDepartment,
main_department_id: matchedDepartment?.id || null,
position: normalizeOptionalText(position),
phone: normalizeOptionalText(phone),
email: normalizeOptionalText(email),
status,
remarks: normalizeOptionalText(remarks)
}
})
await replacePersonDepartments(tx, person.id, matchedDepartment ? [matchedDepartment.id] : [])
return tx.person.findUnique({
where: { id: person.id },
include: personInclude
})
})
successResponse(res, sanitizePerson(created), '创建成功')
@ -124,9 +345,11 @@ export const createPerson = async (req: Request, res: Response) => {
export const updatePerson = async (req: Request, res: Response) => {
try {
const { id } = req.params
const personId = Number(id)
const {
nickname,
real_name,
job_number,
id_number,
department,
position,
@ -136,31 +359,69 @@ export const updatePerson = async (req: Request, res: Response) => {
remarks
} = req.body as Record<string, any>
const person = await prisma.person.findUnique({ where: { id: Number(id) } })
const person = await prisma.person.findUnique({
where: { id: personId },
include: personInclude
})
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
if (isSystemReservedPerson(person)) {
return errorResponse(res, '系统保留人员不允许修改', 422)
}
if (id_number && id_number !== person.id_number) {
const exists = await prisma.person.findUnique({ where: { id_number } })
const normalizedJobNumber = normalizeOptionalText(job_number)
if (normalizedJobNumber && normalizedJobNumber !== person.job_number) {
const exists = await prisma.person.findUnique({ where: { job_number: normalizedJobNumber } })
if (exists) {
return errorResponse(res, '证件编号已存在', 409)
return errorResponse(res, '工号已存在', 409)
}
}
const updated = await prisma.person.update({
where: { id: Number(id) },
data: {
nickname: nickname ?? person.nickname,
real_name: real_name ?? person.real_name,
id_number: id_number ?? person.id_number,
department,
position,
phone,
email,
status: status ?? person.status,
remarks
const normalizedIdNumber = normalizeOptionalText(id_number)
if (normalizedIdNumber && normalizedIdNumber !== person.id_number) {
const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } })
if (exists) {
return errorResponse(res, '身份证号已存在', 409)
}
}
const hasDepartmentInput = Object.prototype.hasOwnProperty.call(req.body, 'department')
const normalizedDepartment = hasDepartmentInput
? normalizeOptionalText(department) ?? null
: person.department
const matchedDepartment = hasDepartmentInput
? await resolveDepartmentByName(normalizedDepartment)
: person.mainDepartment
const updated = await prisma.$transaction(async (tx) => {
await tx.person.update({
where: { id: personId },
data: {
nickname: nickname !== undefined ? String(nickname).trim() : person.nickname,
real_name: real_name !== undefined ? String(real_name).trim() : person.real_name,
job_number: job_number !== undefined ? normalizedJobNumber : person.job_number,
id_number: id_number !== undefined ? normalizedIdNumber : person.id_number,
department: hasDepartmentInput ? normalizedDepartment : person.department,
main_department_id: hasDepartmentInput
? matchedDepartment?.id || null
: person.main_department_id,
position: position !== undefined ? normalizeOptionalText(position) : person.position,
phone: phone !== undefined ? normalizeOptionalText(phone) : person.phone,
email: email !== undefined ? normalizeOptionalText(email) : person.email,
status: status ?? person.status,
remarks: remarks !== undefined ? normalizeOptionalText(remarks) : person.remarks
}
})
if (hasDepartmentInput) {
await replacePersonDepartments(tx, personId, matchedDepartment ? [matchedDepartment.id] : [])
}
return tx.person.findUnique({
where: { id: personId },
include: personInclude
})
})
successResponse(res, sanitizePerson(updated), '更新成功')
@ -187,10 +448,14 @@ export const updatePersonStatus = async (req: Request, res: Response) => {
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
if (isSystemReservedPerson(person)) {
return errorResponse(res, '系统保留人员不允许停用', 422)
}
const updated = await prisma.person.update({
where: { id: Number(id) },
data: { status }
data: { status },
include: personInclude
})
successResponse(res, sanitizePerson(updated), '状态更新成功')
@ -199,7 +464,6 @@ export const updatePersonStatus = async (req: Request, res: Response) => {
}
}
export const deletePerson = async (req: Request, res: Response) => {
try {
const { id } = req.params
@ -209,8 +473,10 @@ export const deletePerson = async (req: Request, res: Response) => {
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
if (isSystemReservedPerson(person)) {
return errorResponse(res, '系统保留人员不允许删除', 422)
}
// 检查是否有未归还的借用记录
const activeBorrowCount = await prisma.borrowRecord.count({
where: {
person_id: personId,
@ -223,6 +489,10 @@ export const deletePerson = async (req: Request, res: Response) => {
}
await prisma.$transaction(async (tx) => {
await tx.personDepartment.deleteMany({
where: { person_id: personId }
})
await tx.borrowRecord.deleteMany({
where: { person_id: personId }
})
@ -236,20 +506,43 @@ export const deletePerson = async (req: Request, res: Response) => {
}
}
// 获取使用者选项列表(用于下拉选择)
export const getPersonOptions = async (_req: Request, res: Response) => {
export const getPersonOptions = async (req: Request, res: Response) => {
try {
const persons = await prisma.person.findMany({
where: { status: 'active' },
orderBy: { nickname: 'asc' },
select: {
id: true,
nickname: true,
real_name: true,
department: true
}
})
successResponse(res, persons, '获取成功')
const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true'
const page = parsePositiveInteger(req.query.page, 1)
const requestedPageSize = req.query.page_size ?? req.query.pageSize
const pageSize = Math.min(parsePositiveInteger(requestedPageSize, 50), 100)
const status = includeInactive ? undefined : (req.query.status || 'active')
const where = {
AND: [
buildPersonWhere(req.query.keyword, status),
{
OR: [
{ source_userid: null },
{ source_userid: { not: PUBLIC_ASSET_PERSON_SOURCE_USERID } }
]
}
]
}
const [persons, total] = await Promise.all([
prisma.person.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: includeInactive
? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }]
: [{ nickname: 'asc' }, { real_name: 'asc' }],
include: personInclude
}),
prisma.person.count({ where })
])
successResponse(res, {
list: persons.map(sanitizeBusinessPerson),
total,
page,
page_size: pageSize
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取使用者选项失败')
}

View File

@ -0,0 +1,293 @@
import type { Request, Response } from 'express'
import {
cancelQueuedPrintJob,
claimNextPrintJob,
createAssetLabelPrintJob as createAssetLabelPrintJobService,
createPrintTerminalPairCode as createPrintTerminalPairCodeService,
heartbeatPrintTerminalAgent,
getDefaultPrintTerminalReadiness,
getPrintAgentJobState,
resolveUnknownPrintJob,
isPrintServiceError,
listPrintJobs,
listPrintTerminals,
pairPrintTerminalAgent,
reportPrintAgentTestPrint,
setDefaultPrintTerminal as setDefaultPrintTerminalService,
updateClaimedPrintJobStatus,
updatePrintTerminalAdminStatus
} from '../services/printService'
import { errorResponse, successResponse } from '../utils/response'
const handlePrintServiceError = (
res: Response,
error: unknown,
fallbackMessage: string
) => {
if (isPrintServiceError(error)) {
return errorResponse(res, error.message, error.statusCode)
}
console.error('[printController]', error)
const message =
error instanceof Error && error.message ? error.message : fallbackMessage
return errorResponse(res, message)
}
const parsePositiveInt = (value: unknown) => {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
const getCurrentUserId = (res: Response) => {
return parsePositiveInt(res.locals.user?.id)
}
const getCurrentTerminalId = (res: Response) => {
return parsePositiveInt(res.locals.printTerminal?.id)
}
export const getPrintTerminals = async (req: Request, res: Response) => {
try {
const terminals = await listPrintTerminals()
successResponse(res, { list: terminals }, '获取打印终端成功')
} catch (error) {
handlePrintServiceError(res, error, '获取打印终端失败')
}
}
export const getPrintReadiness = async (_req: Request, res: Response) => {
try {
const terminal = await getDefaultPrintTerminalReadiness()
successResponse(res, { terminal }, '获取打印就绪状态成功')
} catch (error) {
handlePrintServiceError(res, error, '获取打印就绪状态失败')
}
}
export const createPrintTerminalPairCode = async (req: Request, res: Response) => {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '当前登录用户无效', 401)
}
try {
const pairCode = await createPrintTerminalPairCodeService(userId)
successResponse(res, pairCode, '已生成一次性配对码')
} catch (error) {
handlePrintServiceError(res, error, '生成配对码失败')
}
}
export const setDefaultPrintTerminal = async (req: Request, res: Response) => {
const terminalId = parsePositiveInt(req.params.id)
if (!terminalId) {
return errorResponse(res, '打印终端编号无效', 422)
}
try {
const terminal = await setDefaultPrintTerminalService(terminalId)
successResponse(res, terminal, '默认打印电脑已更新')
} catch (error) {
handlePrintServiceError(res, error, '设置默认打印电脑失败')
}
}
export const updatePrintTerminalStatus = async (req: Request, res: Response) => {
const terminalId = parsePositiveInt(req.params.id)
const nextStatus = typeof req.body?.status === 'string' ? req.body.status.trim() : ''
if (!terminalId) {
return errorResponse(res, '打印终端编号无效', 422)
}
if (nextStatus !== 'offline' && nextStatus !== 'disabled') {
return errorResponse(res, '仅支持启用为离线或停用终端', 422)
}
try {
const terminal = await updatePrintTerminalAdminStatus(terminalId, nextStatus)
successResponse(res, terminal, '终端状态已更新')
} catch (error) {
handlePrintServiceError(res, error, '更新终端状态失败')
}
}
export const createAssetLabelPrintJob = async (req: Request, res: Response) => {
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '当前登录用户无效', 401)
}
try {
const result = await createAssetLabelPrintJobService(req.body, userId)
successResponse(res, result.job, result.queue_notice)
} catch (error) {
handlePrintServiceError(res, error, '创建打印任务失败')
}
}
export const getPrintJobs = async (req: Request, res: Response) => {
try {
const page = parsePositiveInt(req.query.page) || 1
const pageSize = parsePositiveInt(req.query.pageSize) || 20
const status = typeof req.query.status === 'string' ? req.query.status : null
const result = await listPrintJobs(page, pageSize, status)
successResponse(
res,
{
list: result.list,
total: result.pagination.total,
page: result.pagination.page,
pageSize: result.pagination.pageSize
},
'获取打印任务成功'
)
} catch (error) {
handlePrintServiceError(res, error, '获取打印任务失败')
}
}
export const cancelPrintJob = async (req: Request, res: Response) => {
const jobId = parsePositiveInt(req.params.id)
if (!jobId) {
return errorResponse(res, '打印任务编号无效', 422)
}
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '当前登录用户无效', 401)
}
const canManageAll = Array.isArray(res.locals.user?.permissions) &&
res.locals.user.permissions.includes('system.settings')
try {
await cancelQueuedPrintJob(jobId, userId, canManageAll)
successResponse(res, null, '打印任务已取消')
} catch (error) {
handlePrintServiceError(res, error, '取消打印任务失败')
}
}
export const pairPrintAgent = async (req: Request, res: Response) => {
try {
const result = await pairPrintTerminalAgent({
code: req.body?.code,
name: req.body?.terminal_name || req.body?.name,
platform: req.body?.platform,
machine_name: req.body?.machine_name
})
successResponse(res, result, '打印终端配对成功')
} catch (error) {
handlePrintServiceError(res, error, '打印终端配对失败')
}
}
export const heartbeatPrintAgent = async (req: Request, res: Response) => {
const terminalId = getCurrentTerminalId(res)
if (!terminalId) {
return errorResponse(res, '打印终端未认证', 401)
}
try {
const result = await heartbeatPrintTerminalAgent(terminalId, {
name: req.body?.terminal_name || req.body?.name,
platform: req.body?.platform,
machine_name: req.body?.machine_name,
selected_printer_name: req.body?.selected_printer_name,
printer_snapshot: req.body?.printer_snapshot,
label_profile: req.body?.label_profile,
protocol_version: req.body?.protocol_version
})
successResponse(res, result, '心跳已接收')
} catch (error) {
handlePrintServiceError(res, error, '心跳上报失败')
}
}
export const claimPrintAgentJob = async (req: Request, res: Response) => {
const terminalId = getCurrentTerminalId(res)
if (!terminalId) {
return errorResponse(res, '打印终端未认证', 401)
}
try {
const result = await claimNextPrintJob(terminalId, req.body?.protocol_version)
successResponse(res, result, result.message)
} catch (error) {
handlePrintServiceError(res, error, '领取打印任务失败')
}
}
export const updatePrintAgentJobStatus = async (req: Request, res: Response) => {
const terminalId = getCurrentTerminalId(res)
const jobId = parsePositiveInt(req.params.id)
if (!terminalId) {
return errorResponse(res, '打印终端未认证', 401)
}
if (!jobId) {
return errorResponse(res, '打印任务编号无效', 422)
}
try {
const result = await updateClaimedPrintJobStatus(terminalId, jobId, {
status: req.body?.status,
claim_token: req.body?.claim_token,
error_message: req.body?.error_message,
retryable: req.body?.retryable
})
successResponse(res, result.job, result.message)
} catch (error) {
handlePrintServiceError(res, error, '更新打印任务状态失败')
}
}
export const readPrintAgentJobState = async (req: Request, res: Response) => {
const terminalId = getCurrentTerminalId(res)
const jobId = parsePositiveInt(req.params.id)
if (!terminalId) return errorResponse(res, '打印终端未认证', 401)
if (!jobId) return errorResponse(res, '打印任务编号无效', 422)
try {
successResponse(res, await getPrintAgentJobState(terminalId, jobId), '获取任务状态成功')
} catch (error) {
handlePrintServiceError(res, error, '获取任务状态失败')
}
}
export const resolvePrintJob = async (req: Request, res: Response) => {
const userId = getCurrentUserId(res)
const jobId = parsePositiveInt(req.params.id)
if (!userId) return errorResponse(res, '当前登录用户无效', 401)
if (!jobId) return errorResponse(res, '打印任务编号无效', 422)
try {
successResponse(res, await resolveUnknownPrintJob(jobId, userId, req.body || {}), '核实结果已保存')
} catch (error) {
handlePrintServiceError(res, error, '保存核实结果失败')
}
}
export const printAgentTestPrint = async (req: Request, res: Response) => {
const terminalId = getCurrentTerminalId(res)
if (!terminalId) {
return errorResponse(res, '打印终端未认证', 401)
}
try {
const result = await reportPrintAgentTestPrint(terminalId, {
selected_printer_name: req.body?.selected_printer_name,
printer_snapshot: req.body?.printer_snapshot,
label_profile: req.body?.label_profile,
success: req.body?.success,
error_message: req.body?.error_message
})
successResponse(res, result, result.message)
} catch (error) {
handlePrintServiceError(res, error, '测试打印状态上报失败')
}
}

View File

@ -0,0 +1,125 @@
import fs from 'fs'
import path from 'path'
import type { Request, Response } from 'express'
import { errorResponse, successResponse } from '../utils/response'
import {
getPrintAgentLatestManifest,
getXPrinterOfficialLatestManifest,
listPrintAgentReleaseManifests,
listXPrinterArchiveManifests,
resolvePrintAgentLatestBinary,
resolvePrintAgentReleaseBinary,
resolveXPrinterArchiveBinary
} from '../services/printDownloadService'
const getBaseUrl = (req: Request) => {
const protocol = (req.get('x-forwarded-proto') || req.protocol || 'http').split(',')[0]
const host = req.get('x-forwarded-host') || req.get('host')
return `${protocol}://${host}`
}
const sendBinaryDownload = (
res: Response,
filePath: string,
downloadName: string,
missingMessage: string
) => {
if (!fs.existsSync(filePath)) {
return errorResponse(res, missingMessage, 404)
}
return res.download(path.resolve(filePath), downloadName)
}
export const getPrintAgentLatestDownload = async (req: Request, res: Response) => {
try {
const { manifest, filePath } = resolvePrintAgentLatestBinary()
return sendBinaryDownload(res, filePath, manifest.filename, '最新打印助手安装包尚未上传')
} catch (error: any) {
return errorResponse(res, error.message || '读取打印助手安装包失败', 404)
}
}
export const getPrintAgentReleaseList = async (req: Request, res: Response) => {
try {
const baseUrl = getBaseUrl(req)
const latestManifest = getPrintAgentLatestManifest()
const releases = listPrintAgentReleaseManifests().map((manifest) => ({
...manifest,
download_url: `${baseUrl}/downloads/print-agent/releases/${manifest.version}`
}))
successResponse(
res,
{
latest_version: latestManifest.version,
latest_download_url: `${baseUrl}/downloads/print-agent/latest`,
releases
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '读取打印助手版本列表失败', 404)
}
}
export const getPrintAgentReleaseDownload = async (req: Request, res: Response) => {
try {
const version = String(req.params.version || '').trim()
if (!version) {
return errorResponse(res, '版本号不能为空', 422)
}
const { manifest, filePath } = resolvePrintAgentReleaseBinary(version)
return sendBinaryDownload(
res,
filePath,
manifest.filename,
`打印助手 ${version} 安装包尚未上传`
)
} catch (error: any) {
return errorResponse(res, error.message || '读取打印助手安装包失败', 404)
}
}
export const getXPrinterOfficialLatestController = async (req: Request, res: Response) => {
try {
const manifest = getXPrinterOfficialLatestManifest()
successResponse(res, manifest, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '读取芯烨官方驱动信息失败', 404)
}
}
export const getXPrinterArchiveListController = async (req: Request, res: Response) => {
try {
const baseUrl = getBaseUrl(req)
const archives = listXPrinterArchiveManifests().map((manifest) => ({
...manifest,
download_url: `${baseUrl}/downloads/printer-driver/xprinter/archive/${manifest.version}`
}))
successResponse(res, archives, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '读取芯烨归档驱动失败', 404)
}
}
export const getXPrinterArchiveDownloadController = async (req: Request, res: Response) => {
try {
const version = String(req.params.version || '').trim()
if (!version) {
return errorResponse(res, '版本号不能为空', 422)
}
const { manifest, filePath } = resolveXPrinterArchiveBinary(version)
return sendBinaryDownload(
res,
filePath,
manifest.filename,
`芯烨归档驱动 ${version} 尚未上传`
)
} catch (error: any) {
return errorResponse(res, error.message || '读取芯烨归档驱动失败', 404)
}
}

View File

@ -0,0 +1,249 @@
import type { Request, Response } from 'express'
import { errorResponse, successResponse } from '../utils/response'
import { resolveAuthUserById } from '../utils/authIdentity'
import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService'
import {
confirmLegacyInventoryParticipant,
confirmInventoryParticipantByPerson,
getInventoryTaskPublicInfo,
getParticipantItems,
getParticipantTaskContext,
searchTaskParticipants,
submitInventoryException,
submitInventoryScan
} from '../services/inventoryService'
const INVENTORY_SESSION_KEY = 'inventoryParticipant'
const getInventorySession = (req: Request) => (((req as any).session as any)?.[INVENTORY_SESSION_KEY] || null) as
| {
taskId: number
participantId: number
publicToken: string
}
| null
const setInventorySession = (
req: Request,
payload: {
taskId: number
participantId: number
publicToken: string
}
) => {
;((req as any).session as any)[INVENTORY_SESSION_KEY] = payload
}
const clearInventorySession = (req: Request) => {
if (((req as any).session as any)?.[INVENTORY_SESSION_KEY]) {
delete ((req as any).session as any)[INVENTORY_SESSION_KEY]
}
}
const ensureCurrentInventoryIdentity = async (req: Request, participantPersonId: number | null) => {
const sessionUser = ((req as any).session as any)?.user
const userId = Number(sessionUser?.id)
if (!Number.isInteger(userId) || userId <= 0) {
throw new Error('请重新使用钉钉登录后进入盘点任务')
}
if (sessionUser?.auth_source === 'dingtalk') {
const dingtalkUserId = String(sessionUser?.dingtalk_userid || '').trim()
const eligibleUser = await findEligibleDingTalkSessionUser(userId, dingtalkUserId)
if (!eligibleUser) {
;((req as any).session as any).user = undefined
throw new Error('钉钉身份或员工绑定已失效,请重新登录')
}
}
const currentUser = await resolveAuthUserById(userId)
if (
!participantPersonId ||
currentUser.person_id !== participantPersonId ||
currentUser.person?.status !== 'active'
) {
throw new Error('当前登录账号与盘点责任人不一致,请重新进入本人任务')
}
}
const ensureInventorySession = async (req: Request) => {
const session = getInventorySession(req)
if (!session) {
throw new Error('盘点会话不存在,请重新确认身份')
}
const taskContext = await getParticipantTaskContext(session.taskId, session.participantId)
if (taskContext.task.public_token !== session.publicToken) {
clearInventorySession(req)
throw new Error('盘点会话已失效,请重新确认身份')
}
if (taskContext.task.identity_mode === 'dingtalk') {
try {
await ensureCurrentInventoryIdentity(req, taskContext.participant.person_id)
} catch (error) {
clearInventorySession(req)
throw error
}
}
return {
session,
...taskContext
}
}
export const getPublicInventoryTaskController = async (req: Request, res: Response) => {
try {
const data = await getInventoryTaskPublicInfo(req.params.token)
successResponse(res, data, '获取成功')
} catch (error: any) {
const message = error.message || '获取盘点任务失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
}
}
export const searchPublicInventoryParticipantsController = async (req: Request, res: Response) => {
try {
const keyword = typeof req.query.keyword === 'string' ? req.query.keyword : ''
const list = await searchTaskParticipants(req.params.token, keyword)
successResponse(res, list, '获取成功')
} catch (error: any) {
const message = error.message || '搜索盘点人员失败'
errorResponse(res, message, message.includes('不存在') ? 404 : message.includes('钉钉登录') ? 403 : 500)
}
}
export const confirmPublicInventoryParticipantController = async (req: Request, res: Response) => {
try {
const participantId = Number(req.body?.participant_id)
if (!Number.isInteger(participantId) || participantId <= 0) {
return errorResponse(res, '请选择盘点人员', 422)
}
const data = await confirmLegacyInventoryParticipant(req.params.token, participantId)
setInventorySession(req, {
taskId: data.task_id,
participantId: data.participant_id,
publicToken: req.params.token
})
successResponse(res, data, '确认成功')
} catch (error: any) {
const message = error.message || '确认盘点身份失败'
errorResponse(res, message, message.includes('不存在') ? 404 : message.includes('钉钉登录') ? 403 : 422)
}
}
export const confirmCurrentInventoryParticipantController = async (req: Request, res: Response) => {
try {
const personId = Number(res.locals.user?.person_id)
const data = await confirmInventoryParticipantByPerson(req.params.token, personId)
setInventorySession(req, {
taskId: data.task_id,
participantId: data.participant_id,
publicToken: req.params.token
})
successResponse(res, data, '已按当前钉钉身份进入盘点任务')
} catch (error: any) {
const message = error.message || '确认当前盘点身份失败'
errorResponse(
res,
message,
message.includes('不存在') ? 404 : message.includes('未登录') ? 401 : 403
)
}
}
export const getPublicInventoryMeController = async (req: Request, res: Response) => {
try {
const { task, participant } = await ensureInventorySession(req)
successResponse(
res,
{
task: {
id: task.id,
title: task.title,
status: task.status,
identity_mode: task.identity_mode,
scope_department_name: task.scope_department_name,
scope_asset_type_name: task.scope_asset_type_name,
completed_count: task.completed_count,
exception_count: task.exception_count,
items_count: task.items_count,
deadline_at: task.deadline_at,
closed_at: task.closed_at
},
participant
},
'获取成功'
)
} catch (error: any) {
const message = error.message || '获取盘点会话失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 401)
}
}
export const getPublicInventoryItemsController = async (req: Request, res: Response) => {
try {
const { task, participant } = await ensureInventorySession(req)
const data = await getParticipantItems(task.id, participant.id)
successResponse(res, data, '获取成功')
} catch (error: any) {
const message = error.message || '获取盘点资产失败'
errorResponse(res, message, message.includes('不存在') ? 404 : 401)
}
}
export const submitPublicInventoryScanController = async (req: Request, res: Response) => {
try {
const { task, participant } = await ensureInventorySession(req)
if (task.status === 'closed') {
return errorResponse(res, '盘点任务已关闭', 403)
}
const equipmentCode = String(req.body?.equipment_code || '').trim()
if (!equipmentCode) {
return errorResponse(res, '资产编码不能为空', 422)
}
const data = await submitInventoryScan(task.id, participant.id, equipmentCode)
successResponse(res, data, '盘点成功')
} catch (error: any) {
const message = error.message || '提交盘点结果失败'
errorResponse(
res,
message,
message.includes('盘点范围') || message.includes('不能为空') ? 422 : message.includes('不存在') ? 404 : 401
)
}
}
export const submitPublicInventoryExceptionController = async (req: Request, res: Response) => {
try {
const { task, participant } = await ensureInventorySession(req)
if (task.status === 'closed') {
return errorResponse(res, '盘点任务已关闭', 403)
}
const itemId = Number(req.params.itemId)
if (!Number.isInteger(itemId) || itemId <= 0) {
return errorResponse(res, '盘点资产不存在', 404)
}
await submitInventoryException(
task.id,
participant.id,
itemId,
String(req.body?.exception_type || ''),
req.body?.exception_note
)
successResponse(res, null, '异常已提交')
} catch (error: any) {
const message = error.message || '提交盘点异常失败'
errorResponse(
res,
message,
message.includes('不存在') ? 404 : message.includes('无效') ? 422 : 401
)
}
}

View File

@ -1,6 +1,10 @@
import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import {
buildParentAssetFirstOrderBy,
sortParentAssetsFirst
} from '../utils/equipmentSearchPriority'
// 全局搜索
export const globalSearch = async (req: Request, res: Response) => {
@ -26,14 +30,15 @@ export const globalSearch = async (req: Request, res: Response) => {
equipment_code: true,
equipment_name: true,
equipment_type: true,
parent_id: true,
status: true,
assetType: { select: { name: true } }
},
take: 10,
orderBy: { updated_at: 'desc' }
orderBy: buildParentAssetFirstOrderBy([{ updated_at: 'desc' }, { id: 'desc' }])
})
const results = equipments.map(eq => ({
const results = sortParentAssetsFirst(equipments).map(eq => ({
id: eq.id,
code: eq.equipment_code,
name: eq.equipment_name,

View File

@ -1,17 +1,82 @@
import fs from 'fs'
import path from 'path'
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
const DEFAULT_SITE_NAME = '固定资产管理系统'
const DEFAULT_THEME_COLOR = '#409EFF'
const DEFAULT_LOGO_URL = ''
const DEFAULT_THEME_COLOR = '#10A37F'
const DEFAULT_LOGO_URL = '/branding/custom-logo.jpg'
const DEFAULT_FAVICON_URL = '/branding/favicon.svg'
// 站点 Logo 固定优先使用后台已确认的 custom-logo.jpg,避免首屏或文件探测回退到旧 SVG。
const LOGO_CANDIDATES = [
'custom-logo.jpg',
'custom-logo.jpeg',
'custom-logo.png',
'custom-logo.webp'
]
const CUSTOM_FAVICON_CANDIDATES = [
'custom-favicon.ico',
'custom-favicon.svg',
'custom-favicon.png',
'custom-favicon.webp',
'custom-favicon.jpg',
'custom-favicon.jpeg'
]
const DEFAULT_FAVICON_CANDIDATES = [
'favicon.ico',
'favicon.svg',
'favicon.png',
'favicon.webp',
'favicon.jpg',
'favicon.jpeg'
]
const resolveBrandingDir = () => {
const candidates = [
process.env.BRANDING_DIR,
path.resolve(process.cwd(), 'branding'),
path.resolve(process.cwd(), '../frontend/public/branding'),
path.resolve(__dirname, '..', '..', 'branding'),
path.resolve(__dirname, '..', '..', '..', 'frontend', 'public', 'branding')
].filter(Boolean) as string[]
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0]
}
const BRANDING_DIR = resolveBrandingDir()
const resolveBrandingAssetUrl = (candidates: string[], fallbackUrl: string) => {
for (const fileName of candidates) {
if (fs.existsSync(path.join(BRANDING_DIR, fileName))) {
return `/branding/${fileName}`
}
}
return fallbackUrl
}
const getResolvedLogoUrl = () =>
resolveBrandingAssetUrl(
LOGO_CANDIDATES,
DEFAULT_LOGO_URL
)
const getResolvedFaviconUrl = () =>
resolveBrandingAssetUrl(
[
...CUSTOM_FAVICON_CANDIDATES,
...LOGO_CANDIDATES,
...DEFAULT_FAVICON_CANDIDATES
],
getResolvedLogoUrl() || DEFAULT_FAVICON_URL
)
const mapConfigResponse = (config: any) => {
if (!config) {
return {
site_name: DEFAULT_SITE_NAME,
logo_url: DEFAULT_LOGO_URL,
favicon_url: '',
logo_url: getResolvedLogoUrl(),
favicon_url: getResolvedFaviconUrl(),
theme_color: DEFAULT_THEME_COLOR,
updated_at: null,
updated_by: null
@ -20,9 +85,9 @@ const mapConfigResponse = (config: any) => {
return {
site_name: config.site_name || DEFAULT_SITE_NAME,
logo_url: config.logo_url || DEFAULT_LOGO_URL,
favicon_url: config.favicon_url || '',
theme_color: config.theme_color || DEFAULT_THEME_COLOR,
logo_url: getResolvedLogoUrl(),
favicon_url: getResolvedFaviconUrl(),
theme_color: DEFAULT_THEME_COLOR,
updated_at: config.updated_at,
updated_by: config.updatedBy
? {
@ -57,7 +122,7 @@ export const getSiteConfig = async (req: Request, res: Response) => {
export const updateSiteConfig = async (req: Request, res: Response) => {
try {
const { site_name, theme_color } = req.body as { site_name?: string; theme_color?: string }
const { site_name } = req.body as { site_name?: string }
if (!site_name) {
return errorResponse(res, '网站名称不能为空', 422)
@ -69,11 +134,11 @@ export const updateSiteConfig = async (req: Request, res: Response) => {
const data: any = {
site_name: site_name.trim(),
theme_color: theme_color || DEFAULT_THEME_COLOR,
theme_color: DEFAULT_THEME_COLOR,
updated_at: new Date(),
updated_by: res.locals.user?.id ?? null,
logo_url: existing?.logo_url || DEFAULT_LOGO_URL,
favicon_url: existing?.favicon_url || null
favicon_url: existing?.favicon_url || DEFAULT_FAVICON_URL
}
const result = existing

View File

@ -2,6 +2,28 @@ import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { sumSnapshotChildrenValue } from '../utils/assetValue'
import {
DASHBOARD_TIME_ZONE,
getInventoryStatusBreakdown,
getShanghaiDateRange,
getShanghaiDayRange,
summarizeInventoryStatusRows
} from '../utils/dashboardStatistics'
type InventoryByTypeRow = {
equipment_type: string
total_quantity: number
available_quantity: number
borrowed_quantity: number
maintenance_quantity: number
pending_disposal_quantity: number
damaged_quantity: number
lost_quantity: number
other_quantity: number
mountable_available_quantity: number
is_child_type: boolean
}
const toNumber = (value: unknown) => {
if (value === null || value === undefined) return 0
@ -19,12 +41,17 @@ const buildEquipmentWhere = (equipmentType?: string, startDate?: string, endDate
if (startDate || endDate) {
where.created_at = {}
if (startDate) {
where.created_at.gte = new Date(startDate)
where.created_at.gte = getShanghaiDateRange(startDate).start
}
if (endDate) {
const endOfDay = new Date(endDate)
endOfDay.setHours(23, 59, 59, 999)
where.created_at.lte = endOfDay
where.created_at.lt = getShanghaiDateRange(endDate).end
}
if (
where.created_at.gte instanceof Date &&
where.created_at.lt instanceof Date &&
where.created_at.gte >= where.created_at.lt
) {
throw new Error('开始日期不能晚于结束日期')
}
}
@ -34,18 +61,30 @@ const buildEquipmentWhere = (equipmentType?: string, startDate?: string, endDate
// 获取首页统计数据
export const getDashboardStats = async (req: Request, res: Response) => {
try {
const startOfToday = new Date(new Date().setHours(0, 0, 0, 0))
const generatedAt = new Date()
const todayRange = getShanghaiDayRange(generatedAt)
const [
equipmentTotals,
maintenanceTotals,
childAssetTypes,
equipmentStatusTotals,
inventoryByType,
todayBorrowCount,
todayReturnCount,
todayBorrowValueRows,
todayReturnValueRows
mountableInventoryByType,
currentRootTotals,
borrowedWithoutActiveRecord,
todayBorrowRecords,
todayReturnRecords
] = await Promise.all([
prisma.equipment.aggregate({
prisma.assetType.findMany({
where: {
can_be_child: true,
status: 'active'
},
select: {
name: true
}
}),
prisma.equipment.groupBy({
by: ['status'],
_sum: {
total_quantity: true,
remaining_quantity: true,
@ -54,78 +93,159 @@ export const getDashboardStats = async (req: Request, res: Response) => {
available_value: true
}
}),
prisma.equipment.aggregate({
where: { status: '维护中' },
prisma.equipment.groupBy({
by: ['equipment_type', 'status'],
where: {
status: { not: '已报废' }
},
_sum: {
total_quantity: true,
available_value: true
remaining_quantity: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type'],
_sum: {
total_quantity: true,
remaining_quantity: true
where: {
parent_id: null,
mount_status: { in: ['none', 'unmounted'] },
status: '可用',
remaining_quantity: { gt: 0 },
borrow_records: {
none: {
status: '已借出'
}
}
},
orderBy: {
_sum: {
total_quantity: 'desc'
_sum: {
remaining_quantity: true
}
}),
prisma.equipment.aggregate({
where: {
parent_id: null,
status: { notIn: ['已报废', '已丢失'] }
},
_count: {
id: true
},
_sum: {
total_quantity: true
}
}),
prisma.equipment.count({
where: {
parent_id: null,
status: '已借出',
borrow_records: {
none: {
status: '已借出',
return_time: null
}
}
}
}),
prisma.borrowRecord.count({
prisma.borrowRecord.findMany({
where: {
borrow_time: {
gte: startOfToday
gte: todayRange.start,
lt: todayRange.end
}
},
select: {
children_snapshot: true,
equipment: {
select: {
unit_price: true
}
}
}
}),
prisma.borrowRecord.count({
prisma.borrowRecord.findMany({
where: {
return_time: {
gte: startOfToday
gte: todayRange.start,
lt: todayRange.end
},
return_condition: { not: '丢失' }
},
select: {
children_snapshot: true,
equipment: {
select: {
unit_price: true
}
}
}
}),
prisma.$queryRaw<Array<{ total: unknown }>>(Prisma.sql`
SELECT COALESCE(SUM(e.unit_price), 0) AS total
FROM borrow_record br
INNER JOIN equipment e ON e.id = br.equipment_id
WHERE br.borrow_time >= ${startOfToday}
`),
prisma.$queryRaw<Array<{ total: unknown }>>(Prisma.sql`
SELECT COALESCE(SUM(e.unit_price), 0) AS total
FROM borrow_record br
INNER JOIN equipment e ON e.id = br.equipment_id
WHERE br.return_time >= ${startOfToday}
`)
})
])
const totalEquipmentQuantity = toNumber(equipmentTotals._sum.total_quantity)
const availableEquipmentQuantity = toNumber(equipmentTotals._sum.remaining_quantity)
const borrowedEquipmentQuantity = Math.max(
totalEquipmentQuantity - availableEquipmentQuantity,
0
const inventorySummary = summarizeInventoryStatusRows(
equipmentStatusTotals.map((item) => ({
status: item.status,
total_quantity: item._sum.total_quantity,
remaining_quantity: item._sum.remaining_quantity
}))
)
const maintenanceEquipmentQuantity = toNumber(maintenanceTotals._sum.total_quantity)
const totalValue = toNumber(equipmentTotals._sum.total_value)
const borrowedValue = toNumber(equipmentTotals._sum.borrowed_value)
const availableValue = toNumber(equipmentTotals._sum.available_value)
const maintenanceValue = toNumber(maintenanceTotals._sum.available_value)
const todayBorrowValue = toNumber(todayBorrowValueRows[0]?.total)
const todayReturnValue = toNumber(todayReturnValueRows[0]?.total)
const usageRate =
totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0
const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0
const totalEquipmentQuantity = inventorySummary.current
const availableEquipmentQuantity = inventorySummary.available
const borrowedEquipmentQuantity = inventorySummary.borrowed
const maintenanceEquipmentQuantity = inventorySummary.maintenance
const rootEquipmentQuantity = toNumber(currentRootTotals._sum.total_quantity)
const accessoryEquipmentQuantity = Math.max(totalEquipmentQuantity - rootEquipmentQuantity, 0)
const currentValueSummary = equipmentStatusTotals.reduce((summary, item) => {
if (item.status === '已报废') return summary
const totalValue = toNumber(item._sum.total_value)
summary.total_value += totalValue
summary.borrowed_value += toNumber(item._sum.borrowed_value)
summary.available_value += toNumber(item._sum.available_value)
if (item.status === '维护中') summary.maintenance_value += totalValue
return summary
}, {
total_value: 0,
borrowed_value: 0,
available_value: 0,
maintenance_value: 0
})
const totalValue = currentValueSummary.total_value
const borrowedValue = currentValueSummary.borrowed_value
const availableValue = currentValueSummary.available_value
const maintenanceValue = currentValueSummary.maintenance_value
const todayBorrowValue = todayBorrowRecords.reduce((sum, record) => {
return sum + toNumber(record.equipment?.unit_price) + sumSnapshotChildrenValue(record.children_snapshot)
}, 0)
const todayReturnValue = todayReturnRecords.reduce((sum, record) => {
return sum + toNumber(record.equipment?.unit_price) + sumSnapshotChildrenValue(record.children_snapshot)
}, 0)
const operationalEquipmentQuantity = availableEquipmentQuantity + borrowedEquipmentQuantity
const operationalValue = availableValue + borrowedValue
const usageRate = operationalEquipmentQuantity > 0
? (borrowedEquipmentQuantity / operationalEquipmentQuantity) * 100
: 0
const valueUsageRate = operationalValue > 0 ? (borrowedValue / operationalValue) * 100 : 0
const childTypeNames = new Set(childAssetTypes.map((item) => item.name))
const mountableInventoryByTypeMap = mountableInventoryByType.reduce((map, item) => {
map.set(item.equipment_type || '其他', toNumber(item._sum.remaining_quantity))
return map
}, new Map<string, number>())
successResponse(res, {
total_equipment: totalEquipmentQuantity,
root_asset_records: toNumber(currentRootTotals._count.id),
root_equipment: rootEquipmentQuantity,
accessory_equipment: accessoryEquipmentQuantity,
available_equipment: availableEquipmentQuantity,
borrowed_equipment: borrowedEquipmentQuantity,
maintenance_equipment: maintenanceEquipmentQuantity,
pending_disposal_equipment: inventorySummary.pendingDisposal,
damaged_equipment: inventorySummary.damaged,
lost_equipment: inventorySummary.lost,
other_equipment: inventorySummary.other,
disposed_equipment: inventorySummary.disposed,
borrowed_without_active_record: borrowedWithoutActiveRecord,
usage_rate: usageRate,
today_borrow: todayBorrowCount,
today_return: todayReturnCount,
today_borrow: todayBorrowRecords.length,
today_return: todayReturnRecords.length,
total_value: totalValue,
borrowed_value: borrowedValue,
available_value: availableValue,
@ -133,18 +253,49 @@ export const getDashboardStats = async (req: Request, res: Response) => {
value_usage_rate: valueUsageRate,
today_borrow_value: todayBorrowValue,
today_return_value: todayReturnValue,
inventory_by_type: inventoryByType
.map((item) => {
const totalQuantity = toNumber(item._sum.total_quantity)
const availableQuantity = toNumber(item._sum.remaining_quantity)
return {
equipment_type: item.equipment_type || '其他',
total_quantity: totalQuantity,
available_quantity: availableQuantity,
borrowed_quantity: Math.max(totalQuantity - availableQuantity, 0)
generated_at: generatedAt.toISOString(),
business_timezone: DASHBOARD_TIME_ZONE,
inventory_scope: 'current_excluding_disposed_and_lost',
value_scope: 'book_value_excluding_disposed_including_lost',
inventory_by_type: Array.from(
inventoryByType.reduce((map, item) => {
const equipmentType = item.equipment_type || '其他'
const current: InventoryByTypeRow = map.get(equipmentType) || {
equipment_type: equipmentType,
total_quantity: 0,
available_quantity: 0,
borrowed_quantity: 0,
maintenance_quantity: 0,
pending_disposal_quantity: 0,
damaged_quantity: 0,
lost_quantity: 0,
other_quantity: 0,
mountable_available_quantity: mountableInventoryByTypeMap.get(equipmentType) || 0,
is_child_type: childTypeNames.has(equipmentType)
}
})
.filter((item) => item.total_quantity > 0)
const totalQuantity = toNumber(item._sum.total_quantity)
const remainingQuantity = toNumber(item._sum.remaining_quantity)
const breakdown = getInventoryStatusBreakdown(
item.status,
totalQuantity,
remainingQuantity
)
current.total_quantity += breakdown.current
current.available_quantity += breakdown.available
current.borrowed_quantity += breakdown.borrowed
current.maintenance_quantity += breakdown.maintenance
current.pending_disposal_quantity += breakdown.pendingDisposal
current.damaged_quantity += breakdown.damaged
current.lost_quantity += breakdown.lost
current.other_quantity += breakdown.other
map.set(equipmentType, current)
return map
}, new Map<string, InventoryByTypeRow>()).values()
)
.sort((a, b) => b.total_quantity - a.total_quantity)
.filter((item) => item.total_quantity > 0 || item.lost_quantity > 0)
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取统计数据失败')
@ -162,79 +313,88 @@ export const getValueStatistics = async (req: Request, res: Response) => {
typeof end_date === 'string' ? end_date : undefined
)
const [summary, maintenanceSummary, valueByTypeGroups, topEquipmentRows] = await Promise.all([
prisma.equipment.aggregate({
where,
_sum: {
total_value: true,
borrowed_value: true,
available_value: true
}
}),
prisma.equipment.aggregate({
where: {
...where,
status: '维护中'
},
_sum: {
available_value: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type'],
where,
_sum: {
total_value: true,
borrowed_value: true,
available_value: true
},
orderBy: {
_sum: {
total_value: 'desc'
}
}
}),
prisma.equipment.findMany({
where,
select: {
equipment_name: true,
unit_price: true,
total_value: true,
borrowed_value: true,
available_value: true
},
orderBy: {
total_value: 'desc'
},
take: 10
})
])
const currentInventoryWhere: Prisma.EquipmentWhereInput = {
...where,
status: { not: '已报废' }
}
const totalValue = toNumber(summary._sum.total_value)
const borrowedValue = toNumber(summary._sum.borrowed_value)
const availableValue = toNumber(summary._sum.available_value)
const maintenanceValue = toNumber(maintenanceSummary._sum.available_value)
const valueByType = valueByTypeGroups
.map((item) => ({
equipment_type: item.equipment_type || '未分类',
total_value: toNumber(item._sum.total_value),
borrowed_value: toNumber(item._sum.borrowed_value),
available_value: toNumber(item._sum.available_value)
}))
const equipmentRows = await prisma.equipment.findMany({
where: currentInventoryWhere,
select: {
equipment_name: true,
equipment_type: true,
unit_price: true,
total_quantity: true,
total_value: true,
borrowed_value: true,
available_value: true,
status: true
}
})
const summary = equipmentRows.reduce((current, item) => {
const totalValue = toNumber(item.total_value)
current.total_value += totalValue
current.borrowed_value += toNumber(item.borrowed_value)
current.available_value += toNumber(item.available_value)
if (item.status === '维护中') current.maintenance_value += totalValue
return current
}, {
total_value: 0,
borrowed_value: 0,
available_value: 0,
maintenance_value: 0
})
const valueByType = Array.from(
equipmentRows.reduce((map, item) => {
const equipmentType = item.equipment_type || '未分类'
const current = map.get(equipmentType) || {
equipment_type: equipmentType,
total_value: 0,
borrowed_value: 0,
available_value: 0
}
current.total_value += toNumber(item.total_value)
current.borrowed_value += toNumber(item.borrowed_value)
current.available_value += toNumber(item.available_value)
map.set(equipmentType, current)
return map
}, new Map<string, {
equipment_type: string
total_value: number
borrowed_value: number
available_value: number
}>())
.values()
)
.sort((left, right) => right.total_value - left.total_value)
.filter((item) => item.total_value > 0)
const topEquipment = topEquipmentRows.map((eq) => ({
equipment_name: eq.equipment_name,
unit_price: toNumber(eq.unit_price),
total_value: toNumber(eq.total_value),
borrowed_value: toNumber(eq.borrowed_value),
available_value: toNumber(eq.available_value)
}))
const topEquipment = equipmentRows
.map((eq) => ({
equipment_name: eq.equipment_name,
quantity: toNumber(eq.total_quantity) || 1,
unit_price: toNumber(eq.unit_price),
item_value: toNumber(eq.unit_price),
stock_total_value: toNumber(eq.total_value),
stock_borrowed_value: toNumber(eq.borrowed_value),
stock_available_value: toNumber(eq.available_value)
}))
.sort((a, b) => b.item_value - a.item_value || b.stock_total_value - a.stock_total_value)
.slice(0, 10)
const otherValue = Math.max(
summary.total_value - summary.borrowed_value - summary.available_value - summary.maintenance_value,
0
)
successResponse(res, {
total_value: totalValue,
borrowed_value: borrowedValue,
available_value: availableValue,
maintenance_value: maintenanceValue,
total_value: summary.total_value,
borrowed_value: summary.borrowed_value,
available_value: summary.available_value,
maintenance_value: summary.maintenance_value,
other_value: otherValue,
value_scope: 'book_value_excluding_disposed_including_lost',
value_by_type: valueByType,
top_equipment: topEquipment
}, '获取成功')

View File

@ -1,13 +1,101 @@
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { errorResponse, successResponse } from '../utils/response'
import { previewHrSync, runHrSync } from '../services/hrSyncService'
import { getHrSyncConfigStatus, previewHrSync, runHrSync } from '../services/hrSyncService'
const SYNC_CHANGE_PROCESSING_STATUSES = ['pending', 'processed', 'ignored'] as const
type SyncChangeProcessingStatus = (typeof SYNC_CHANGE_PROCESSING_STATUSES)[number]
const resolveSyncErrorCode = (message: string) => {
if (message.includes('SQL Server 同步配置不完整')) {
return 400
}
return 500
}
const parseJsonText = (value: string | null) => {
if (!value) return null
try {
return JSON.parse(value)
} catch {
return null
}
}
const toRecord = (value: unknown) =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, number>) : {}
const normalizeProcessingStatus = (value: unknown): SyncChangeProcessingStatus | null => {
if (typeof value !== 'string') return null
const normalized = value.trim().toLowerCase()
return SYNC_CHANGE_PROCESSING_STATUSES.includes(normalized as SyncChangeProcessingStatus)
? (normalized as SyncChangeProcessingStatus)
: null
}
const normalizeProcessingNotes = (value: unknown) => {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized ? normalized : null
}
const serializeSyncJob = (job: any) => {
const payload = parseJsonText(job.result_payload)
return {
id: job.id,
sync_type: job.sync_type,
trigger_mode: job.trigger_mode,
status: job.status,
operator: job.operator,
summary: job.summary,
created_at: job.created_at,
changes_count:
typeof payload?.changes_count === 'number' ? payload.changes_count : job._count?.changes || 0,
counts: toRecord(payload?.counts),
error_message: typeof payload?.error_message === 'string' ? payload.error_message : null
}
}
const resolveProcessedByName = (change: any) => {
if (!change.processedBy) return null
return change.processedBy.name || change.processedBy.username || null
}
const serializeSyncChange = (change: any) => ({
id: change.id,
entity_type: change.entity_type,
entity_key: change.entity_key,
entity_name: change.entity_name,
change_type: change.change_type,
title: change.title,
detail: change.detail,
before_snapshot: parseJsonText(change.before_snapshot),
after_snapshot: parseJsonText(change.after_snapshot),
processing_status: change.processing_status,
processing_notes: change.processing_notes,
processed_by: change.processed_by,
processed_by_name: resolveProcessedByName(change),
processed_at: change.processed_at,
created_at: change.created_at
})
export const getHrSyncStatusController = async (_req: Request, res: Response) => {
try {
const result = getHrSyncConfigStatus()
successResponse(res, result, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取同步状态失败', 500)
}
}
export const previewHrSyncController = async (_req: Request, res: Response) => {
try {
const result = await previewHrSync()
successResponse(res, result, '预览成功')
} catch (error: any) {
errorResponse(res, error.message || '预览同步数据失败', 500)
const message = error.message || '预览同步数据失败'
errorResponse(res, message, resolveSyncErrorCode(message))
}
}
@ -17,6 +105,170 @@ export const runHrSyncController = async (_req: Request, res: Response) => {
const result = await runHrSync(operator)
successResponse(res, result, '同步成功')
} catch (error: any) {
errorResponse(res, error.message || '执行同步失败', 500)
const message = error.message || '执行同步失败'
errorResponse(res, message, resolveSyncErrorCode(message))
}
}
export const getHrSyncJobListController = async (req: Request, res: Response) => {
try {
const page = Math.max(1, Number(req.query.page) || 1)
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize) || 10))
const skip = (page - 1) * pageSize
const where = {
sync_type: 'hr'
}
const [list, total] = await Promise.all([
prisma.syncJob.findMany({
where,
skip,
take: pageSize,
orderBy: { created_at: 'desc' },
include: {
_count: {
select: {
changes: true
}
}
}
}),
prisma.syncJob.count({ where })
])
successResponse(
res,
{
list: list.map(serializeSyncJob),
total,
page,
pageSize
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取同步历史失败', 500)
}
}
export const getHrSyncJobDetailController = async (req: Request, res: Response) => {
try {
const id = Number(req.params.id)
if (!Number.isInteger(id) || id <= 0) {
return errorResponse(res, '同步任务不存在', 404)
}
const job = await prisma.syncJob.findFirst({
where: {
id,
sync_type: 'hr'
},
include: {
_count: {
select: {
changes: true
}
},
changes: {
include: {
processedBy: {
select: {
id: true,
username: true,
name: true
}
}
},
orderBy: { created_at: 'desc' }
}
}
})
if (!job) {
return errorResponse(res, '同步任务不存在', 404)
}
successResponse(
res,
{
...serializeSyncJob(job),
changes: job.changes.map(serializeSyncChange)
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取同步任务详情失败', 500)
}
}
export const updateHrSyncChangeProcessingController = async (req: Request, res: Response) => {
try {
const id = Number(req.params.id)
if (!Number.isInteger(id) || id <= 0) {
return errorResponse(res, '同步变更不存在', 404)
}
const processingStatus = normalizeProcessingStatus(req.body?.processing_status)
if (!processingStatus) {
return errorResponse(res, '处理状态无效', 400)
}
const currentUserId = Number(res.locals.user?.id)
if (processingStatus !== 'pending' && (!Number.isInteger(currentUserId) || currentUserId <= 0)) {
return errorResponse(res, '未获取到当前处理人信息', 401)
}
const existingChange = await prisma.syncChange.findFirst({
where: {
id,
syncJob: {
sync_type: 'hr'
}
},
select: {
id: true
}
})
if (!existingChange) {
return errorResponse(res, '同步变更不存在', 404)
}
const now = new Date()
const updateData =
processingStatus === 'pending'
? {
processing_status: processingStatus,
processing_notes: normalizeProcessingNotes(req.body?.processing_notes),
processed_by: null,
processed_at: null
}
: {
processing_status: processingStatus,
processing_notes: normalizeProcessingNotes(req.body?.processing_notes),
processed_by: currentUserId,
processed_at: now
}
const updatedChange = await prisma.syncChange.update({
where: {
id
},
data: updateData,
include: {
processedBy: {
select: {
id: true,
username: true,
name: true
}
}
}
})
successResponse(res, serializeSyncChange(updatedChange), '更新成功')
} catch (error: any) {
errorResponse(res, error.message || '更新同步变更处理状态失败', 500)
}
}

View File

@ -1,54 +1,212 @@
import { Request, Response } from 'express'
import bcrypt from 'bcryptjs'
import type { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { getPermissionsForRole, isUserRole } from '../utils/permissions'
const SALT_ROUNDS = 10
const USER_STATUSES = ['active', 'inactive'] as const
const PERSON_BOUND_FILTERS = ['all', 'bound', 'unbound'] as const
const sanitizeUser = (user: any) => ({
id: user.id,
username: user.username,
name: user.name,
email: user.email,
phone: user.phone,
role: user.role,
roles: [user.role],
status: user.status,
last_login_at: user.last_login_at,
created_at: user.created_at,
updated_at: user.updated_at
})
class UserValidationError extends Error {
constructor(message: string, public readonly status = 422) {
super(message)
this.name = 'UserValidationError'
}
}
const sanitizeUser = (user: any) => {
const person = user.person
? {
id: user.person.id,
real_name: user.person.real_name,
nickname: user.person.nickname,
status: user.person.status,
source_employee_status: user.person.source_employee_status
}
: null
return {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
phone: user.phone,
role: user.role,
roles: [user.role],
permissions: getPermissionsForRole(user.role),
status: user.status,
person_id: user.person_id,
person_bound: Boolean(user.person_id),
dingtalk_ready: Boolean(
user.status === 'active' &&
user.person?.status === 'active' &&
user.person?.source_userid?.trim()
),
person,
last_login_at: user.last_login_at,
created_at: user.created_at,
updated_at: user.updated_at
}
}
const personSelect = {
id: true,
real_name: true,
nickname: true,
status: true,
source_employee_status: true,
source_userid: true
} as const
const normalizePersonId = (value: unknown) => {
if (value === undefined) return undefined
if (value === null || value === '') return null
const personId = Number(value)
if (!Number.isInteger(personId) || personId <= 0) {
throw new UserValidationError('员工绑定无效')
}
return personId
}
const normalizeUserStatus = (value: unknown, fallback?: string) => {
const status = value === undefined || value === null || value === ''
? fallback
: String(value).trim()
if (!status || !USER_STATUSES.includes(status as (typeof USER_STATUSES)[number])) {
throw new UserValidationError('状态必须是 active 或 inactive')
}
return status
}
const getCurrentUserId = (res: Response) => {
const userId = Number((res as any).locals?.user?.id)
return Number.isInteger(userId) && userId > 0 ? userId : null
}
const parseListPositiveInteger = (
value: unknown,
fieldName: string,
fallback: number,
max?: number
) => {
if (value === undefined || value === null || value === '') return fallback
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
throw new UserValidationError(`${fieldName}无效`)
}
return parsed
}
const parseOptionalBoolean = (value: unknown, fieldName: string) => {
if (value === undefined || value === null || value === '') return undefined
const normalized = String(value).trim().toLowerCase()
if (normalized === 'true') return true
if (normalized === 'false') return false
throw new UserValidationError(`${fieldName}必须是 true 或 false`)
}
const lockActiveAdministrators = async (tx: Prisma.TransactionClient) => {
return tx.$queryRawUnsafe<Array<{ id: number }>>(
"SELECT id FROM `user` WHERE role = 'admin' AND status = 'active' ORDER BY id FOR UPDATE"
)
}
const assertActiveAdministratorRemains = async (
tx: Prisma.TransactionClient,
targetUserId: number
) => {
const activeAdministrators = await lockActiveAdministrators(tx)
const targetIsActiveAdministrator = activeAdministrators.some((item) => Number(item.id) === targetUserId)
if (targetIsActiveAdministrator && activeAdministrators.length <= 1) {
throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409)
}
}
const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => {
const person = await prisma.person.findUnique({ where: { id: personId } })
if (!person) {
throw new UserValidationError('绑定的员工档案不存在')
}
if (person.status !== 'active') {
throw new UserValidationError('只能绑定在职且启用的员工档案')
}
if (!person.source_userid?.trim()) {
throw new UserValidationError('该员工档案缺少钉钉 userId,暂时不能绑定')
}
const occupied = await prisma.user.findFirst({
where: {
person_id: personId,
...(excludeUserId ? { id: { not: excludeUserId } } : {})
},
select: { id: true }
})
if (occupied) {
throw new UserValidationError('该员工档案已绑定其他用户', 409)
}
}
const sendUserWriteError = (res: Response, error: any, fallbackMessage: string) => {
if (error?.code === 'P2002') {
const targets = Array.isArray(error?.meta?.target)
? error.meta.target.map(String)
: [String(error?.meta?.target || '')]
const message = targets.some((target: string) => target.includes('person_id'))
? '该员工档案已绑定其他用户'
: targets.some((target: string) => target.includes('username'))
? '用户名已存在'
: '用户资料与现有记录冲突'
return errorResponse(res, message, 409)
}
return errorResponse(res, error.message || fallbackMessage, error.status || 500)
}
export const getUserList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, keyword, role, status } = req.query
const { page = 1, pageSize = 10, keyword, role, roles, status } = req.query
const currentPage = parseListPositiveInteger(page, '页码', 1)
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 10, 100)
const where: any = {}
const where: Prisma.UserWhereInput = {}
if (keyword) {
const normalizedKeyword = String(keyword || '').trim()
if (normalizedKeyword) {
where.OR = [
{ username: { contains: keyword as string } },
{ name: { contains: keyword as string } },
{ email: { contains: keyword as string } }
{ username: { contains: normalizedKeyword } },
{ name: { contains: normalizedKeyword } },
{ email: { contains: normalizedKeyword } },
{ person: { is: { real_name: { contains: normalizedKeyword } } } },
{ person: { is: { nickname: { contains: normalizedKeyword } } } },
{ person: { is: { job_number: { contains: normalizedKeyword } } } }
]
}
if (role) {
where.role = role
const normalizedRole = String(role || (Array.isArray(roles) ? roles[0] : roles) || '').trim()
if (normalizedRole) {
if (!isUserRole(normalizedRole)) {
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
}
where.role = normalizedRole
}
if (status) {
where.status = status
const normalizedStatus = String(status || '').trim()
if (normalizedStatus) {
if (!USER_STATUSES.includes(normalizedStatus as (typeof USER_STATUSES)[number])) {
return errorResponse(res, '状态必须是 active 或 inactive', 422)
}
where.status = normalizedStatus
}
const skip = (Number(page) - 1) * Number(pageSize)
const skip = (currentPage - 1) * currentPageSize
const [list, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: Number(pageSize),
orderBy: { created_at: 'desc' }
take: currentPageSize,
orderBy: { created_at: 'desc' },
include: { person: { select: personSelect } }
}),
prisma.user.count({ where })
])
@ -58,19 +216,115 @@ export const getUserList = async (req: Request, res: Response) => {
{
list: list.map(sanitizeUser),
total,
page: Number(page),
pageSize: Number(pageSize)
page: currentPage,
pageSize: currentPageSize
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取用户列表失败')
errorResponse(res, error.message || '获取用户列表失败', error.status || 500)
}
}
export const getPersonBindingOptions = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 20, keyword, status, bound = 'all', dingtalk_ready } = req.query
const currentPage = parseListPositiveInteger(page, '页码', 1)
const currentPageSize = parseListPositiveInteger(pageSize, '每页数量', 20, 100)
const normalizedBound = String(bound || 'all').trim().toLowerCase()
if (!PERSON_BOUND_FILTERS.includes(normalizedBound as (typeof PERSON_BOUND_FILTERS)[number])) {
return errorResponse(res, '绑定状态必须是 all、bound 或 unbound', 422)
}
const normalizedStatus = String(status || '').trim()
if (normalizedStatus && !USER_STATUSES.includes(normalizedStatus as (typeof USER_STATUSES)[number])) {
return errorResponse(res, '员工状态必须是 active 或 inactive', 422)
}
const dingtalkReady = parseOptionalBoolean(dingtalk_ready, 'dingtalk_ready')
const conditions: Prisma.PersonWhereInput[] = []
const normalizedKeyword = String(keyword || '').trim()
if (normalizedKeyword) {
conditions.push({
OR: [
{ real_name: { contains: normalizedKeyword } },
{ nickname: { contains: normalizedKeyword } },
{ job_number: { contains: normalizedKeyword } },
{ mainDepartment: { is: { name: { contains: normalizedKeyword } } } }
]
})
}
if (normalizedStatus) conditions.push({ status: normalizedStatus })
if (normalizedBound === 'bound') conditions.push({ user: { isNot: null } })
if (normalizedBound === 'unbound') conditions.push({ user: { is: null } })
if (dingtalkReady === true) {
conditions.push({
status: 'active',
AND: [{ source_userid: { not: null } }, { source_userid: { not: '' } }]
})
} else if (dingtalkReady === false) {
conditions.push({
OR: [
{ status: { not: 'active' } },
{ source_userid: null },
{ source_userid: '' }
]
})
}
const where: Prisma.PersonWhereInput = conditions.length > 0 ? { AND: conditions } : {}
const skip = (currentPage - 1) * currentPageSize
const [list, total] = await Promise.all([
prisma.person.findMany({
where,
skip,
take: currentPageSize,
orderBy: [{ status: 'asc' }, { nickname: 'asc' }, { id: 'asc' }],
select: {
id: true,
real_name: true,
nickname: true,
job_number: true,
status: true,
source_userid: true,
mainDepartment: { select: { id: true, name: true } },
user: {
select: {
id: true,
username: true,
name: true,
role: true,
status: true
}
}
}
}),
prisma.person.count({ where })
])
successResponse(res, {
list: list.map((person) => ({
id: person.id,
real_name: person.real_name,
nickname: person.nickname,
job_number: person.job_number,
status: person.status,
main_department: person.mainDepartment,
dingtalk_ready: person.status === 'active' && Boolean(person.source_userid?.trim()),
is_bound: Boolean(person.user),
bound_user: person.user
})),
total,
page: currentPage,
pageSize: currentPageSize
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取员工绑定选项失败', error.status || 500)
}
}
export const createUser = async (req: Request, res: Response) => {
try {
const { username, password, name, email, phone, roles, role, status } = req.body as Record<
const { username, password, name, email, phone, roles, role, status, person_id } = req.body as Record<
string,
any
>
@ -86,6 +340,15 @@ export const createUser = async (req: Request, res: Response) => {
const primaryRole =
(Array.isArray(roles) ? roles[0] : roles) || role || (username === 'admin' ? 'admin' : 'user')
if (!isUserRole(primaryRole)) {
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
}
const normalizedStatus = normalizeUserStatus(status, 'active')
const personId = normalizePersonId(person_id)
if (typeof personId === 'number') {
await ensurePersonCanBind(personId)
}
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS)
@ -97,20 +360,22 @@ export const createUser = async (req: Request, res: Response) => {
email,
phone,
role: primaryRole,
status: status || 'active'
}
status: normalizedStatus,
person_id: personId ?? null
},
include: { person: { select: personSelect } }
})
successResponse(res, sanitizeUser(created), '创建成功')
} catch (error: any) {
errorResponse(res, error.message || '创建用户失败')
sendUserWriteError(res, error, '创建用户失败')
}
}
export const updateUser = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { name, email, phone, roles, role, status } = req.body as Record<string, any>
const { name, email, phone, roles, role, status, person_id } = req.body as Record<string, any>
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
if (!user) {
@ -125,21 +390,66 @@ export const updateUser = async (req: Request, res: Response) => {
const newRole = (Array.isArray(roles) ? roles[0] : roles) || role
if (newRole) {
if (!isUserRole(newRole)) {
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
}
updateData.role = newRole
}
if (status) {
updateData.status = status
if (status !== undefined) {
updateData.status = normalizeUserStatus(status)
}
const updated = await prisma.user.update({
where: { id: Number(id) },
data: updateData
})
const currentUserId = getCurrentUserId(res)
if (currentUserId === user.id) {
if (newRole && newRole !== 'admin') {
return errorResponse(res, '不能降低当前登录管理员自己的角色', 422)
}
if (updateData.status && updateData.status !== 'active') {
return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
}
}
const personId = normalizePersonId(person_id)
if (typeof personId === 'number') {
await ensurePersonCanBind(personId, user.id)
updateData.person_id = personId
} else if (personId === null) {
updateData.person_id = null
}
const changesRoleOrStatus = Boolean(updateData.role || updateData.status)
const updated = changesRoleOrStatus
? await prisma.$transaction(async (tx) => {
const activeAdministrators = await lockActiveAdministrators(tx)
const current = await tx.user.findUnique({ where: { id: user.id } })
if (!current) {
throw new UserValidationError('用户不存在', 404)
}
const finalRole = updateData.role || current.role
const finalStatus = updateData.status || current.status
const removesActiveAdministrator = activeAdministrators.some(
(item) => Number(item.id) === current.id
) && (finalRole !== 'admin' || finalStatus !== 'active')
if (removesActiveAdministrator && activeAdministrators.length <= 1) {
throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409)
}
return tx.user.update({
where: { id: current.id },
data: updateData,
include: { person: { select: personSelect } }
})
})
: await prisma.user.update({
where: { id: user.id },
data: updateData,
include: { person: { select: personSelect } }
})
successResponse(res, sanitizeUser(updated), '更新成功')
} catch (error: any) {
errorResponse(res, error.message || '更新用户失败')
sendUserWriteError(res, error, '更新用户失败')
}
}
@ -175,23 +485,33 @@ export const updateUserStatus = async (req: Request, res: Response) => {
const { id } = req.params
const { status } = req.body as { status?: string }
if (!status) {
return errorResponse(res, '状态不能为空', 422)
}
const normalizedStatus = normalizeUserStatus(status)
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
if (!user) {
return errorResponse(res, '用户不存在', 404)
}
const updated = await prisma.user.update({
where: { id: Number(id) },
data: { status }
if (getCurrentUserId(res) === user.id && normalizedStatus !== 'active') {
return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
}
const updated = await prisma.$transaction(async (tx) => {
if (normalizedStatus !== 'active') {
await assertActiveAdministratorRemains(tx, user.id)
} else {
await lockActiveAdministrators(tx)
}
return tx.user.update({
where: { id: user.id },
data: { status: normalizedStatus },
include: { person: { select: personSelect } }
})
})
successResponse(res, sanitizeUser(updated), '状态更新成功')
} catch (error: any) {
errorResponse(res, error.message || '更新用户状态失败')
errorResponse(res, error.message || '更新用户状态失败', error.status || 500)
}
}
@ -201,6 +521,10 @@ export const deleteUser = async (req: Request, res: Response) => {
const { id } = req.params
const userId = Number(id)
if (getCurrentUserId(res) === userId) {
return errorResponse(res, '不能删除当前登录账户', 422)
}
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
@ -210,7 +534,11 @@ export const deleteUser = async (req: Request, res: Response) => {
mount_histories: true,
disposal_applied: true,
disposal_approved: true,
maintenance_records: true
disposal_completed: true,
disposal_actions: true,
maintenance_records: true,
asset_transfers_operated: true,
dingtalk_deliveries: true
}
}
}
@ -219,22 +547,22 @@ export const deleteUser = async (req: Request, res: Response) => {
return errorResponse(res, '用户不存在', 404)
}
// 不允许删除 admin 用户
if (user.username === 'admin') {
return errorResponse(res, '不能删除管理员账户')
}
if (
user._count.site_configs > 0 ||
user._count.mount_histories > 0 ||
user._count.disposal_applied > 0 ||
user._count.disposal_approved > 0 ||
user._count.disposal_completed > 0 ||
user._count.disposal_actions > 0 ||
user._count.maintenance_records > 0
|| user._count.asset_transfers_operated > 0
|| user._count.dingtalk_deliveries > 0
) {
return errorResponse(res, '该用户已存在业务历史记录,无法删除')
}
await prisma.$transaction(async (tx) => {
await assertActiveAdministratorRemains(tx, userId)
await tx.operationLog.deleteMany({
where: { user_id: userId }
})
@ -244,6 +572,6 @@ export const deleteUser = async (req: Request, res: Response) => {
successResponse(res, null, '删除成功')
} catch (error: any) {
errorResponse(res, error.message || '删除用户失败')
errorResponse(res, error.message || '删除用户失败', error.status || 500)
}
}

View File

@ -1,10 +1,10 @@
import 'dotenv/config'
import path from 'path'
import express from 'express'
import { getHealth } from './controllers/healthController'
import cors from 'cors'
import dotenv from 'dotenv'
import session from 'express-session'
import equipmentRoutes from './routes/equipment'
import { scanEquipment } from './controllers/equipmentController'
import borrowRoutes, { returnRouter } from './routes/borrow'
import statisticsRoutes from './routes/statistics'
import authRoutes from './routes/auth'
@ -24,66 +24,97 @@ import batchRoutes from './routes/batch'
import exportRoutes from './routes/export'
import searchRoutes from './routes/search'
import syncRoutes from './routes/sync'
import { keycloak, memoryStore } from './utils/keycloak'
import { protect, requireAdmin } from './middleware/auth'
import notificationRoutes from './routes/notifications'
import inventoryRoutes from './routes/inventory'
import publicInventoryRoutes from './routes/publicInventory'
import printTerminalRoutes from './routes/printTerminals'
import printJobRoutes from './routes/printJobs'
import printAgentRoutes from './routes/printAgent'
import printDownloadRoutes from './routes/printDownloads'
import myAssetRoutes from './routes/myAssets'
import dingtalkRoutes from './routes/dingtalk'
import { keycloak } from './utils/keycloak'
import { protect } from './middleware/auth'
import { startHrSyncScheduler } from './services/hrSyncService'
dotenv.config()
import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService'
import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService'
import { isCorsOriginAllowed } from './utils/corsPolicy'
import { unsafeOriginGuard } from './middleware/originGuard'
import { assertSupportedAuthMode, resolveSessionSecret } from './utils/authMode'
import { persistentSessionStore, startSessionCleanupWorker } from './utils/sessionStore'
import { parseStandardJsonBody, parseStandardFormBody } from './utils/requestBody'
const app = express()
const PORT = process.env.PORT || 3000
const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secret'
const AUTH_MODE = assertSupportedAuthMode()
const SESSION_SECRET = resolveSessionSecret()
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === undefined
? process.env.NODE_ENV === 'production'
: process.env.SESSION_COOKIE_SECURE === 'true'
// Middleware
app.use(cors({ origin: true, credentials: true }))
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.set('trust proxy', 1)
app.use(cors({
origin: (origin, callback) => callback(null, isCorsOriginAllowed(origin) ? (origin || true) : false),
credentials: true
}))
app.use(unsafeOriginGuard)
app.use(parseStandardJsonBody)
app.use(parseStandardFormBody)
app.use(
session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: memoryStore,
store: persistentSessionStore,
cookie: {
secure: false,
secure: SESSION_COOKIE_SECURE,
httpOnly: true,
sameSite: 'lax',
maxAge: 1000 * 60 * 60 // 1 hour
}
})
)
if (process.env.AUTH_MODE !== 'local') {
if (AUTH_MODE === 'keycloak') {
app.use(keycloak.middleware({ logout: '/api/auth/logout', admin: '/keycloak' }))
}
app.use('/uploads', express.static(path.resolve(__dirname, '..', 'uploads')))
// Routes
app.get('/api/equipment/scan/:equipment_code', protect(), scanEquipment)
app.use('/api/equipment', protect(), requireAdmin(), equipmentRoutes)
app.use('/api/equipment', protect(), equipmentRoutes)
app.use('/api/borrow', protect(), borrowRoutes)
app.use('/api/return', protect(), returnRouter)
app.use('/api/statistics', protect(), statisticsRoutes)
app.use('/api/barcode', protect(), barcodeRoutes)
app.use('/api/auth', authRoutes)
app.use('/api/settings', settingsRoutes)
app.use('/api/users', protect(), requireAdmin(), usersRoutes)
app.use('/api/users', protect(), usersRoutes)
app.use('/api/persons', personRoutes)
app.use('/api/storage-locations', storageLocationRoutes)
app.use('/api/asset-types', assetTypeRoutes)
app.use('/api/departments', departmentRoutes)
app.use('/api/operation-logs', operationLogRoutes)
app.use('/api/batch-import', batchImportRoutes)
app.use('/api/mount', protect(), mountRoutes)
app.use('/api/disposal', protect(), disposalRoutes)
app.use('/api/maintenance', protect(), maintenanceRoutes)
app.use('/api/batch', protect(), requireAdmin(), batchRoutes)
app.use('/api/mount', mountRoutes)
app.use('/api/disposal', disposalRoutes)
app.use('/api/maintenance', maintenanceRoutes)
app.use('/api/batch', protect(), batchRoutes)
app.use('/api/export', protect(), exportRoutes)
app.use('/api/search', protect(), searchRoutes)
app.use('/api/sync', protect(), requireAdmin(), syncRoutes)
app.use('/api/sync', syncRoutes)
app.use('/api/notifications', notificationRoutes)
app.use('/api/inventory', inventoryRoutes)
app.use('/api/public/inventory', publicInventoryRoutes)
app.use('/api/print-terminals', protect(), printTerminalRoutes)
app.use('/api/print-jobs', protect(), printJobRoutes)
app.use('/api/print-agent', printAgentRoutes)
app.use('/api/my-assets', myAssetRoutes)
app.use('/api/dingtalk', dingtalkRoutes)
app.use('/downloads', printDownloadRoutes)
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'Server is running' })
})
app.get('/api/health', getHealth)
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
@ -99,4 +130,11 @@ app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`)
})
startSessionCleanupWorker()
ensurePublicAssetPersonExists().catch((error) => {
console.error('初始化系统保留人员失败:', error)
})
startHrSyncScheduler()
startDingTalkDeliveryWorker()

View File

@ -0,0 +1,195 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { keycloak } from '../../utils/keycloak'
import { protect } from '../auth'
const makeUser = (id: number, username: string) => ({
id,
username,
password: 'hidden',
name: username,
email: null,
phone: null,
role: 'admin',
status: 'active',
person_id: null,
person: null
})
const createResponse = () => {
const state = { statusCode: 200, body: null as any, nextCount: 0 }
const res = {
locals: {} as any,
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
const next = () => {
state.nextCount += 1
}
return { res, next, state }
}
test('Keycloak 模式下真实 Bearer 令牌优先于旧的钉钉 H5 session', async () => {
const originalMode = process.env.AUTH_MODE
const originalProtect = keycloak.protect
const originalFindUnique = prisma.user.findUnique
const lookups: any[] = []
process.env.AUTH_MODE = 'keycloak'
;(keycloak.protect as any) = () => async (req: any, _res: any, next: any) => {
req.kauth = {
grant: {
access_token: {
content: {
preferred_username: 'keycloak-user',
name: 'Keycloak User',
realm_access: { roles: ['ignored-external-role'] }
}
}
}
}
return next()
}
;(prisma.user.findUnique as any) = async (args: any) => {
lookups.push(args.where)
if (args.where.username === 'keycloak-user') return makeUser(22, 'keycloak-user')
if (args.where.id === 11) return makeUser(11, 'old-dingtalk-session')
return null
}
try {
const middleware = protect()
const req = {
headers: { authorization: 'Bearer real-keycloak-token' },
session: { user: { id: 11 } }
}
const { res, next, state } = createResponse()
await middleware(req as any, res as any, next as any)
assert.equal(state.nextCount, 1)
assert.equal(res.locals.user.id, 22)
assert.deepEqual(lookups, [{ username: 'keycloak-user' }])
} finally {
if (originalMode === undefined) delete process.env.AUTH_MODE
else process.env.AUTH_MODE = originalMode
;(keycloak.protect as any) = originalProtect
;(prisma.user.findUnique as any) = originalFindUnique
}
})
test('Keycloak 模式下 Bearer session 占位值仍使用 H5 session', async () => {
const originalMode = process.env.AUTH_MODE
const originalProtect = keycloak.protect
const originalFindUnique = prisma.user.findUnique
let keycloakCalled = 0
process.env.AUTH_MODE = 'keycloak'
;(keycloak.protect as any) = () => async (_req: any, _res: any, next: any) => {
keycloakCalled += 1
return next()
}
;(prisma.user.findUnique as any) = async (args: any) => {
if (args.where.id === 11) return makeUser(11, 'dingtalk-session')
return null
}
try {
const middleware = protect()
const req = {
headers: { authorization: 'Bearer session' },
session: { user: { id: 11 } }
}
const { res, next, state } = createResponse()
await middleware(req as any, res as any, next as any)
assert.equal(state.nextCount, 1)
assert.equal(keycloakCalled, 0)
assert.equal(res.locals.user.id, 11)
} finally {
if (originalMode === undefined) delete process.env.AUTH_MODE
else process.env.AUTH_MODE = originalMode
;(keycloak.protect as any) = originalProtect
;(prisma.user.findUnique as any) = originalFindUnique
}
})
test('local 模式即使带真实 Bearer 也只使用本地 session', async () => {
const originalMode = process.env.AUTH_MODE
const originalFindUnique = prisma.user.findUnique
process.env.AUTH_MODE = 'local'
;(prisma.user.findUnique as any) = async (args: any) => {
if (args.where.id === 31) return makeUser(31, 'local-session')
return null
}
try {
const middleware = protect()
const req = {
headers: { authorization: 'Bearer should-not-win' },
session: { user: { id: 31 } }
}
const { res, next, state } = createResponse()
await middleware(req as any, res as any, next as any)
assert.equal(state.nextCount, 1)
assert.equal(res.locals.user.id, 31)
} finally {
if (originalMode === undefined) delete process.env.AUTH_MODE
else process.env.AUTH_MODE = originalMode
;(prisma.user.findUnique as any) = originalFindUnique
}
})
test('钉钉会话在员工改绑后立即失效且不继续读取系统用户', async () => {
const originalMode = process.env.AUTH_MODE
const originalFindFirst = prisma.user.findFirst
const originalFindUnique = prisma.user.findUnique
let findUniqueCalls = 0
process.env.AUTH_MODE = 'local'
;(prisma.user.findFirst as any) = async (args: any) => {
assert.equal(args.where.id, 41)
assert.equal(args.where.person.is.source_userid, 'ding-user-a')
return null
}
;(prisma.user.findUnique as any) = async () => {
findUniqueCalls += 1
return makeUser(41, 'rebound-user')
}
try {
const middleware = protect()
const req = {
headers: {},
session: {
user: {
id: 41,
auth_source: 'dingtalk',
dingtalk_userid: 'ding-user-a'
}
}
}
const { res, next, state } = createResponse()
await middleware(req as any, res as any, next as any)
assert.equal(state.nextCount, 0)
assert.equal(state.statusCode, 401)
assert.equal(state.body.message, '钉钉绑定已失效,请重新登录或联系管理员')
assert.equal(req.session.user, undefined)
assert.equal(findUniqueCalls, 0)
} finally {
if (originalMode === undefined) delete process.env.AUTH_MODE
else process.env.AUTH_MODE = originalMode
;(prisma.user.findFirst as any) = originalFindFirst
;(prisma.user.findUnique as any) = originalFindUnique
}
})

View File

@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { unsafeOriginGuard } from '../originGuard'
const originalEnv = {
nodeEnv: process.env.NODE_ENV,
publicBaseUrl: process.env.APP_PUBLIC_BASE_URL,
centerUrl: process.env.YYD_NOTIFICATION_CENTER_URL,
corsOrigins: process.env.CORS_ALLOWED_ORIGINS
}
test.before(() => {
process.env.NODE_ENV = 'production'
process.env.APP_PUBLIC_BASE_URL = 'https://asset.example.com'
process.env.YYD_NOTIFICATION_CENTER_URL = 'https://sc.example.com/notification-center'
process.env.CORS_ALLOWED_ORIGINS = ''
})
test.after(() => {
for (const [key, value] of Object.entries({
NODE_ENV: originalEnv.nodeEnv,
APP_PUBLIC_BASE_URL: originalEnv.publicBaseUrl,
YYD_NOTIFICATION_CENTER_URL: originalEnv.centerUrl,
CORS_ALLOWED_ORIGINS: originalEnv.corsOrigins
})) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
})
const runGuard = (input: { method: string; origin?: string; path: string }) => {
const state = { status: 200, body: null as any, nextCalls: 0 }
const req = {
method: input.method,
protocol: 'https',
path: input.path,
get(name: string) {
if (name === 'host') return 'asset.example.com'
if (name === 'origin') return input.origin
return undefined
}
}
const res = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
unsafeOriginGuard(req as any, res as any, () => {
state.nextCalls += 1
})
return state
}
test('恶意同站来源的资产写操作在进入业务路由前返回403', () => {
const result = runGuard({
method: 'POST',
origin: 'https://sc.example.com',
path: '/api/equipment'
})
assert.equal(result.status, 403)
assert.equal(result.nextCalls, 0)
assert.deepEqual(result.body, { code: 403, message: '请求来源不允许', data: null })
})
test('通知中心来源只可进入一次性票据回调', () => {
assert.equal(runGuard({
method: 'POST',
origin: 'https://sc.example.com',
path: '/api/dingtalk/broker-login'
}).nextCalls, 1)
assert.equal(runGuard({
method: 'DELETE',
origin: 'https://sc.example.com',
path: '/api/equipment/1'
}).status, 403)
})
test('钉钉 WebView 的 null Origin 只可进入一次性票据回调', () => {
assert.equal(runGuard({
method: 'POST',
origin: 'null',
path: '/api/dingtalk/broker-login'
}).nextCalls, 1)
assert.equal(runGuard({
method: 'POST',
origin: 'null',
path: '/api/equipment'
}).status, 403)
})

View File

@ -1,26 +1,68 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express'
import { keycloak } from '../utils/keycloak'
import { resolveOrProvisionAuthUser } from '../utils/authIdentity'
import {
AuthIdentityError,
resolveAuthUserById,
resolveOrProvisionAuthUser
} from '../utils/authIdentity'
import { PERMISSIONS, type Permission } from '../utils/permissions'
import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService'
import { resolveAuthMode } from '../utils/authMode'
const isLocalAuth = () => process.env.AUTH_MODE === 'local'
const isLocalAuth = () => resolveAuthMode() === 'local'
const hasRealBearerToken = (req: Request) => {
const authorization = req.headers.authorization
const match = authorization?.match(/^Bearer\s+(.+)$/i)
const token = match?.[1]?.trim()
return Boolean(token && token.toLowerCase() !== 'session')
}
// 选择保护策略:local 使用会话校验;默认使用 Keycloak 保护
export function protect(): RequestHandler {
if (isLocalAuth()) {
return function localSessionProtect(req: Request, res: Response, next: NextFunction) {
const keycloakMiddleware = isLocalAuth() ? null : keycloak.protect()
return async function authWrapper(req: Request, res: Response, next: NextFunction) {
const authenticateSession = async () => {
const sessionUser = (req as any).session?.user
if (sessionUser) {
res.locals.user = sessionUser
return next()
const sessionUserId = Number(sessionUser?.id)
const authSource = sessionUser?.auth_source === 'dingtalk' ? 'dingtalk' : undefined
const sessionDingTalkUserId = authSource === 'dingtalk'
? String(sessionUser?.dingtalk_userid || '').trim()
: ''
if (!Number.isInteger(sessionUserId) || sessionUserId <= 0) {
return false
}
try {
if (authSource === 'dingtalk') {
const eligibleUser = await findEligibleDingTalkSessionUser(sessionUserId, sessionDingTalkUserId)
if (!eligibleUser) {
throw new AuthIdentityError('钉钉绑定已失效,请重新登录或联系管理员', 401)
}
}
const currentUser = await resolveAuthUserById(sessionUserId)
;(req as any).session.user = authSource
? {
id: currentUser.id,
auth_source: authSource,
dingtalk_userid: sessionDingTalkUserId
}
: { id: currentUser.id }
res.locals.user = currentUser
next()
return true
} catch (error: any) {
if ((req as any).session) {
;(req as any).session.user = undefined
}
const status = error instanceof AuthIdentityError ? error.status : 401
res.status(status).json({ code: status, message: error.message || '认证失败', data: null })
return true
}
return res.status(401).json({ code: 401, message: '未登录', data: null })
}
}
const keycloakMiddleware = keycloak.protect()
return function keycloakWrapper(req: Request, res: Response, next: NextFunction) {
keycloakMiddleware(req, res, async (err?: any) => {
const authenticateKeycloak = () => keycloakMiddleware?.(req, res, async (err?: any) => {
if (err) {
return next(err)
}
@ -39,25 +81,44 @@ export function protect(): RequestHandler {
return next(resolveError)
}
}
next()
return next()
})
// 本地模式只认服务端会话,不接受 Bearer 令牌改变当前身份。
if (!keycloakMiddleware) {
if (await authenticateSession()) return
return res.status(401).json({ code: 401, message: '未登录', data: null })
}
// Keycloak 模式下,真实 Bearer 令牌优先于浏览器里可能残留的钉钉 H5 会话。
// 前端本地登录使用的 `Bearer session` 只是占位值,仍按会话处理。
if (hasRealBearerToken(req)) {
return authenticateKeycloak()
}
if (await authenticateSession()) return
return authenticateKeycloak()
}
}
export function requireAdmin(): RequestHandler {
return function adminGuard(req: Request, res: Response, next: NextFunction) {
if (isLocalAuth()) {
const sessionUser = (req as any).session?.user
if (sessionUser && (sessionUser.role === 'admin' || sessionUser.roles?.includes('admin'))) {
res.locals.user = sessionUser
return next()
}
return res.status(403).json({ code: 403, message: '权限不足', data: null })
}
return requirePermission(PERMISSIONS.SYSTEM_SETTINGS)
}
const roles: string[] =
((req as any).kauth?.grant?.access_token?.content?.realm_access?.roles as string[]) || []
if (roles.includes('admin')) {
export function requirePermission(permission: Permission): RequestHandler {
return function permissionGuard(_req: Request, res: Response, next: NextFunction) {
const permissions = res.locals.user?.permissions
if (Array.isArray(permissions) && permissions.includes(permission)) {
return next()
}
return res.status(403).json({ code: 403, message: '权限不足', data: null })
}
}
export function requireAnyPermission(...required: Permission[]): RequestHandler {
return function permissionGuard(_req: Request, res: Response, next: NextFunction) {
const permissions = res.locals.user?.permissions
if (Array.isArray(permissions) && required.some((permission) => permissions.includes(permission))) {
return next()
}
return res.status(403).json({ code: 403, message: '权限不足', data: null })

Some files were not shown because too many files have changed in this diff Show More