Compare commits
48 Commits
codex/back
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6751bb8cb | ||
|
|
8cfeb1c6b1 | ||
|
|
843babd381 | ||
|
|
02b637064c | ||
|
|
3e7486e760 | ||
|
|
f31924cc1e | ||
|
|
f69406e888 | ||
|
|
963dcc06af | ||
|
|
e1eb946506 | ||
|
|
3e05835408 | ||
|
|
6e1441f7a4 | ||
|
|
68ba8d74e4 | ||
|
|
c29751059e | ||
|
|
2e316a7104 | ||
|
|
f670ccb5d5 | ||
|
|
a65de1d52f | ||
|
|
149d4510df | ||
|
|
920fa1289d | ||
|
|
6a38680aa7 | ||
|
|
d25d600c7a | ||
|
|
0b83ef7f88 | ||
|
|
f4812dc6a2 | ||
|
|
0a0145210d | ||
|
|
a1ac722317 | ||
|
|
346e830328 | ||
|
|
8e295e48cd | ||
|
|
a6463fc30a | ||
|
|
373825dbfe | ||
|
|
a754472cd5 | ||
|
|
81d1419ef5 | ||
|
|
d1f96859f3 | ||
|
|
3f2151a72f | ||
|
|
ef4c95f28d | ||
|
|
5ce86b0f5c | ||
|
|
879d74f668 | ||
|
|
94a5e8a639 | ||
|
|
a484eb9e0d | ||
|
|
f280380720 | ||
|
|
d2a780a20e | ||
|
|
7cdbe3a507 | ||
|
|
bc7d3cca17 | ||
|
|
aa0f27dce0 | ||
|
|
0dc3c09315 | ||
|
|
c7a9324635 | ||
|
|
9519c10b78 | ||
|
|
247c3107b5 | ||
|
|
ceacc2f98d | ||
|
|
c3f8eb6f6f |
19
.env.example
19
.env.example
@ -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
32
.gitignore
vendored
@ -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
|
||||
|
||||
90
AGENTS.md
90
AGENTS.md
@ -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`
|
||||
- 不提交真实密码、数据库连接串或生产地址;相关配置优先走环境变量。
|
||||
|
||||
@ -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`
|
||||
|
||||
三种方式都无法取得密码时,备份会明确失败,不会继续生成不完整备份,也不会把密码打印到日志。
|
||||
|
||||
## 备份内容
|
||||
|
||||
每次执行会生成一个时间戳目录,包含:
|
||||
|
||||
20
README.md
20
README.md
@ -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
8
backend/.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
uploads
|
||||
coverage
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
@ -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
4171
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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`);
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE `dingtalk_delivery`
|
||||
ADD COLUMN `provider_type` VARCHAR(20) NOT NULL DEFAULT 'work_notice' AFTER `lease_expires_at`;
|
||||
@ -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`
|
||||
);
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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`);
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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`);
|
||||
@ -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
|
||||
|
||||
97
backend/scripts/__tests__/deployPrecheckSafety.test.js
Normal file
97
backend/scripts/__tests__/deployPrecheckSafety.test.js
Normal 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'"),
|
||||
'未完成记录必须优先阻断,不能被旧的已完成记录掩盖'
|
||||
)
|
||||
})
|
||||
73
backend/scripts/__tests__/legacyRemoteScriptSafety.test.js
Normal file
73
backend/scripts/__tests__/legacyRemoteScriptSafety.test.js
Normal 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']), /不支持的参数/)
|
||||
}
|
||||
})
|
||||
@ -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: '已借出' } }
|
||||
})
|
||||
})
|
||||
113
backend/scripts/__tests__/sheet2PublicSupplementUtils.test.js
Normal file
113
backend/scripts/__tests__/sheet2PublicSupplementUtils.test.js
Normal 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')
|
||||
})
|
||||
80
backend/scripts/__tests__/sheet2RemoteScriptSafety.test.js
Normal file
80
backend/scripts/__tests__/sheet2RemoteScriptSafety.test.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
34
backend/scripts/backfillBorrowRecordJobNumber.sql
Normal file
34
backend/scripts/backfillBorrowRecordJobNumber.sql
Normal 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;
|
||||
397
backend/scripts/backfillImportedEquipmentLocations.js
Normal file
397
backend/scripts/backfillImportedEquipmentLocations.js
Normal 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
|
||||
}
|
||||
389
backend/scripts/buildMigrationManualOverridesFromWorkbook.js
Normal file
389
backend/scripts/buildMigrationManualOverridesFromWorkbook.js
Normal 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)
|
||||
}
|
||||
514
backend/scripts/exportAdminManualReviewWorkbook.js
Normal file
514
backend/scripts/exportAdminManualReviewWorkbook.js
Normal 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
|
||||
}
|
||||
231
backend/scripts/exportManualReviewCandidates.js
Normal file
231
backend/scripts/exportManualReviewCandidates.js
Normal 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)
|
||||
})
|
||||
534
backend/scripts/hardwareQuantitySplit.js
Normal file
534
backend/scripts/hardwareQuantitySplit.js
Normal 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
|
||||
}
|
||||
858
backend/scripts/importConfirmedEquipmentToLive.js
Normal file
858
backend/scripts/importConfirmedEquipmentToLive.js
Normal 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
|
||||
}
|
||||
854
backend/scripts/importParentChildMigrationToLive.js
Normal file
854
backend/scripts/importParentChildMigrationToLive.js
Normal 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)
|
||||
})
|
||||
613
backend/scripts/importSheet2PublicAssetsToLive.js
Normal file
613
backend/scripts/importSheet2PublicAssetsToLive.js
Normal 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
|
||||
}
|
||||
133
backend/scripts/locationNormalization.js
Normal file
133
backend/scripts/locationNormalization.js
Normal 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
|
||||
}
|
||||
1278
backend/scripts/prepareEquipmentMigrationDryRun.js
Normal file
1278
backend/scripts/prepareEquipmentMigrationDryRun.js
Normal file
File diff suppressed because it is too large
Load Diff
733
backend/scripts/prepareParentChildMigrationDryRun.js
Normal file
733
backend/scripts/prepareParentChildMigrationDryRun.js
Normal 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)
|
||||
})
|
||||
611
backend/scripts/prepareSheet2SupplementDryRun.js
Normal file
611
backend/scripts/prepareSheet2SupplementDryRun.js
Normal 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
|
||||
}
|
||||
893
backend/scripts/repairMultiUnitMountedChildren.js
Normal file
893
backend/scripts/repairMultiUnitMountedChildren.js
Normal 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)
|
||||
})
|
||||
51
backend/scripts/sheet2PublicAssetImportUtils.js
Normal file
51
backend/scripts/sheet2PublicAssetImportUtils.js
Normal 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
|
||||
}
|
||||
231
backend/scripts/sheet2PublicSupplementUtils.js
Normal file
231
backend/scripts/sheet2PublicSupplementUtils.js
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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] })
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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, '已借出')
|
||||
}
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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'),
|
||||
/允许范围/
|
||||
)
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
64
backend/src/controllers/__tests__/healthController.test.ts
Normal file
64
backend/src/controllers/__tests__/healthController.test.ts
Normal 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)
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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/)
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
124
backend/src/controllers/__tests__/userController.list.test.ts
Normal file
124
backend/src/controllers/__tests__/userController.list.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
130
backend/src/controllers/__tests__/workflowList.summary.test.ts
Normal file
130
backend/src/controllers/__tests__/workflowList.summary.test.ts
Normal 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)
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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) }
|
||||
})
|
||||
|
||||
401
backend/src/controllers/dingtalkController.ts
Normal file
401
backend/src/controllers/dingtalkController.ts
Normal 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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
@ -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: '联系电话' },
|
||||
|
||||
51
backend/src/controllers/healthController.ts
Normal file
51
backend/src/controllers/healthController.ts
Normal 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()
|
||||
207
backend/src/controllers/inventoryController.ts
Normal file
207
backend/src/controllers/inventoryController.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 } }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
194
backend/src/controllers/myAssetController.ts
Normal file
194
backend/src/controllers/myAssetController.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
163
backend/src/controllers/notificationController.ts
Normal file
163
backend/src/controllers/notificationController.ts
Normal 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 || '全部标记已读失败')
|
||||
}
|
||||
}
|
||||
@ -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 || '获取使用者选项失败')
|
||||
}
|
||||
|
||||
293
backend/src/controllers/printController.ts
Normal file
293
backend/src/controllers/printController.ts
Normal 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, '测试打印状态上报失败')
|
||||
}
|
||||
}
|
||||
125
backend/src/controllers/printDownloadController.ts
Normal file
125
backend/src/controllers/printDownloadController.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
249
backend/src/controllers/publicInventoryController.ts
Normal file
249
backend/src/controllers/publicInventoryController.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}, '获取成功')
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
195
backend/src/middleware/__tests__/auth.priority.test.ts
Normal file
195
backend/src/middleware/__tests__/auth.priority.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
94
backend/src/middleware/__tests__/originGuard.test.ts
Normal file
94
backend/src/middleware/__tests__/originGuard.test.ts
Normal 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)
|
||||
})
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user