diff --git a/.env.example b/.env.example index 0831a6d..93c4541 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ 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=供应商,快递公司,外部交流 diff --git a/.gitignore b/.gitignore index 4b8827c..c13428f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ frontend/dist/ frontend/tsconfig.tsbuildinfo frontend/vite.config.js frontend/vite.config.d.ts +frontend/public/branding/custom-logo.* +frontend/public/branding/custom-favicon.* diff --git a/AGENTS.md b/AGENTS.md index 8618c7a..3e48a7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,45 +1,48 @@ # 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`:负责理解需求、拆任务、判断是否启用子代理、整合结果、统一汇报。 +- 本项目默认团队分工: + - `frontend-worker`:使用官方 `worker`,只负责前端文件。 + - `backend-worker`:使用官方 `worker`,只负责后端 / 数据库相关文件。 + - `review-explorer`:使用官方 `explorer`,只负责代码审查、找问题、提风险,不直接改代码。 +- 子代理按职责复用,不必每次换一套;但仍遵守官方能力边界,不自定义内建类型。 +- 不魔改为“常驻团队系统”;子代理只用官方支持的能力边界。 +- 本项目内所有子代理默认统一使用 `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` +- 不提交真实密码、数据库连接串或生产地址;相关配置优先走环境变量。 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..644847e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +uploads +coverage +*.log +.env +.env.* +.git diff --git a/backend/prisma/migrations/20260317_person_department_sync_relations/migration.sql b/backend/prisma/migrations/20260317_person_department_sync_relations/migration.sql new file mode 100644 index 0000000..ed2d5c4 --- /dev/null +++ b/backend/prisma/migrations/20260317_person_department_sync_relations/migration.sql @@ -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`); diff --git a/backend/prisma/migrations/20260317_sync_notifications/migration.sql b/backend/prisma/migrations/20260317_sync_notifications/migration.sql new file mode 100644 index 0000000..a202482 --- /dev/null +++ b/backend/prisma/migrations/20260317_sync_notifications/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260318_sync_change_processing/migration.sql b/backend/prisma/migrations/20260318_sync_change_processing/migration.sql new file mode 100644 index 0000000..4e43098 --- /dev/null +++ b/backend/prisma/migrations/20260318_sync_change_processing/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bd0a7cb..a239e8a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -44,6 +44,8 @@ model Department { children Department[] @relation("DepartmentHierarchy") equipments Equipment[] + mainPersons Person[] @relation("PersonMainDepartment") + personDepartments PersonDepartment[] @@index([status]) @@index([parent_id]) @@ -156,6 +158,8 @@ model User { disposal_approved DisposalRecord[] @relation("DisposalApprover") // 维修记录关联 maintenance_records MaintenanceRecord[] + notifications UserNotification[] + processed_sync_changes SyncChange[] @relation("SyncChangeProcessedBy") @@index([role]) @@index([status]) @@ -183,6 +187,7 @@ model Person { real_name String @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) @@ -198,16 +203,33 @@ model Person { 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[] @@index([nickname]) @@index([real_name]) @@index([status]) + @@index([main_department_id]) @@index([source_system]) @@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 +264,86 @@ 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 MountHistory { id Int @id @default(autoincrement()) diff --git a/backend/src/controllers/barcodeController.ts b/backend/src/controllers/barcodeController.ts index b4e6049..b912a72 100644 --- a/backend/src/controllers/barcodeController.ts +++ b/backend/src/controllers/barcodeController.ts @@ -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) diff --git a/backend/src/controllers/batchController.ts b/backend/src/controllers/batchController.ts index c10ade0..8ba5c3d 100644 --- a/backend/src/controllers/batchController.ts +++ b/backend/src/controllers/batchController.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' +import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => { const longTerm = Boolean(isLongTerm) @@ -55,7 +56,14 @@ const batchBorrowTransaction = async ( } }) - const nextStatus = equipment.remaining_quantity === 1 ? '已借出' : '可用' + const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0) + const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出' + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: nextRemainingQuantity, + status: nextStatus + }) const updated = await tx.equipment.updateMany({ where: { id: equipment.id, @@ -63,9 +71,10 @@ const batchBorrowTransaction = async ( remaining_quantity: equipment.remaining_quantity }, data: { - remaining_quantity: { decrement: 1 }, - borrowed_value: { increment: Number(equipment.unit_price) }, - available_value: { decrement: Number(equipment.unit_price) }, + remaining_quantity: nextRemainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value, status: nextStatus } }) @@ -73,6 +82,8 @@ const batchBorrowTransaction = async ( if (updated.count !== 1) { throw new Error('资产库存已变化,请刷新后重试') } + + await syncMountedChildrenState(tx, equipment.id, '已借出') }) } @@ -100,15 +111,26 @@ const batchReturnTransaction = async ( throw new Error('该借用记录已归还,请刷新后重试') } + const nextRemainingQuantity = Math.min( + Number(equipment.total_quantity), + Number(equipment.remaining_quantity) + 1 + ) + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: nextRemainingQuantity, + status: '可用' + }) 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) }, + remaining_quantity: nextRemainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value, status: '可用' } }) @@ -116,6 +138,8 @@ const batchReturnTransaction = async ( if (updatedEquipment.count !== 1) { throw new Error('资产库存已达到上限,请检查归还记录') } + + await syncMountedChildrenState(tx, equipment.id, '可用') }) } @@ -147,6 +171,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++ diff --git a/backend/src/controllers/batchImportController.ts b/backend/src/controllers/batchImportController.ts index 6effd13..60caf5e 100644 --- a/backend/src/controllers/batchImportController.ts +++ b/backend/src/controllers/batchImportController.ts @@ -2,6 +2,7 @@ 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' interface PersonImportData { nickname: string @@ -26,6 +27,12 @@ interface EquipmentImportData { description?: string } +const normalizeOptionalText = (value: unknown) => { + if (value === undefined || value === null) return null + const text = String(value).trim() + return text || null +} + // 批量导入使用者 export const batchImportPersons = async (req: Request, res: Response) => { try { @@ -35,6 +42,12 @@ export const batchImportPersons = async (req: Request, res: Response) => { return errorResponse(res, '导入数据不能为空') } + const activeDepartments = await prisma.department.findMany({ + where: { 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++) { @@ -61,16 +74,33 @@ export const batchImportPersons = async (req: Request, res: Response) => { } } - 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 created = await tx.person.create({ + data: { + nickname: person.nickname, + real_name: person.real_name, + id_number: person.id_number || null, + 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++ @@ -115,7 +145,7 @@ export const batchImportEquipments = async (req: Request, res: Response) => { 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])) @@ -134,13 +164,19 @@ export const batchImportEquipments = async (req: Request, res: Response) => { } // 查找资产类型 - 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 } + if (assetType.can_be_child && Number(eq.total_quantity) !== 1) { + results.errors.push({ row: rowNum, message: `子资产类型 "${eq.equipment_type}" 导入时数量必须为 1` }) + results.failed++ + continue + } + // 查找部门(可选) let departmentId: number | null = null if (eq.department) { @@ -169,7 +205,12 @@ export const batchImportEquipments = async (req: Request, res: Response) => { // 计算价值 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 +222,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++ diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index 55c5781..f5fc45f 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -1,6 +1,11 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' +import { + calculateEquipmentValues, + getDisplayEquipmentValue, + syncMountedChildrenState +} from '../utils/assetValue' const parseNumber = (value: any): number | undefined => { const num = Number(value) @@ -24,6 +29,15 @@ const equipmentInclude = { } } +const personInclude = { + mainDepartment: { + select: { + id: true, + name: true + } + } +} + const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: string) => { if (equipmentId) { return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude }) @@ -46,6 +60,9 @@ const ensurePersonAvailable = async (personId?: number) => { } const ensureEquipmentBorrowable = (equipment: any) => { + if (equipment.parent_id) { + throw new Error('该子资产当前已挂载,请先卸载后再借用') + } // 检查报废状态 if (equipment.status === '待报废' || equipment.status === '已报废') { throw new Error('资产已报废或待报废,不可借用') @@ -115,7 +132,14 @@ const borrowEquipmentInTransaction = async ( } }) - const nextStatus = equipment.remaining_quantity === 1 ? '已借出' : '可用' + const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0) + const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出' + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: nextRemainingQuantity, + status: nextStatus + }) const updateResult = await tx.equipment.updateMany({ where: { id: equipment.id, @@ -123,9 +147,10 @@ const borrowEquipmentInTransaction = async ( remaining_quantity: equipment.remaining_quantity }, data: { - remaining_quantity: { decrement: 1 }, - borrowed_value: { increment: Number(equipment.unit_price) }, - available_value: { decrement: Number(equipment.unit_price) }, + remaining_quantity: nextRemainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value, status: nextStatus } }) @@ -134,6 +159,8 @@ const borrowEquipmentInTransaction = async ( throw new Error('资产库存已变化,请刷新后重试') } + await syncMountedChildrenState(tx, equipment.id, '已借出') + const updatedEquipment = await tx.equipment.findUnique({ where: { id: equipment.id }, include: equipmentInclude @@ -240,15 +267,26 @@ const returnEquipmentInTransaction = async ( throw new Error('该借用记录已归还,请刷新后重试') } + const nextRemainingQuantity = Math.min( + Number(equipment.total_quantity), + Number(equipment.remaining_quantity) + 1 + ) + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: nextRemainingQuantity, + status: '可用' + }) const equipmentUpdate = 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) }, + remaining_quantity: nextRemainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value, status: '可用' } }) @@ -257,6 +295,8 @@ const returnEquipmentInTransaction = async ( throw new Error('资产库存已达到上限,请检查归还记录') } + await syncMountedChildrenState(tx, equipment.id, '可用') + return { id: borrowRecord.id, person_id: borrowRecord.person_id, @@ -278,7 +318,8 @@ const mapBorrowRecord = (record: any) => ({ id: record.person.id, nickname: record.person.nickname, real_name: record.person.real_name, - department: record.person.department, + department: record.person.mainDepartment?.name || record.person.department, + main_department: record.person.mainDepartment || null, phone: record.person.phone } : { @@ -772,7 +813,9 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => { }, orderBy: { borrow_time: 'desc' }, include: { - person: true, + person: { + include: personInclude + }, equipment: { select: { equipment_name: true, @@ -800,7 +843,7 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => { equipment_code: equipment.equipment_code, equipment_name: equipment.equipment_name, unit_price: Number(equipment.unit_price), - equipment_value: Number(equipment.unit_price), + equipment_value: getDisplayEquipmentValue(equipment), storage_location_id: equipment.storage_location_id, storageLocation: equipment.storageLocation, borrow_records: borrowRecords.map(mapBorrowRecord), @@ -817,7 +860,7 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => { // 获取借用记录列表 export const getBorrowRecordList = async (req: Request, res: Response) => { try { - const { page = 1, pageSize = 10, status, person_name, person_id } = req.query + const { page = 1, pageSize = 10, status, person_name, person_id, keyword } = req.query const filters: any[] = [] @@ -840,6 +883,20 @@ export const getBorrowRecordList = async (req: Request, res: Response) => { ] }) } + + if (keyword) { + const kw = keyword as string + filters.push({ + OR: [ + { equipment_code: { contains: kw } }, + { + equipment: { + equipment_name: { contains: kw } + } + } + ] + }) + } const where = filters.length ? { AND: filters } : undefined const skip = (Number(page) - 1) * Number(pageSize) @@ -849,9 +906,11 @@ export const getBorrowRecordList = async (req: Request, res: Response) => { where, skip, take: Number(pageSize), - orderBy: { borrow_time: 'desc' }, - include: { - person: true, + orderBy: { borrow_time: 'desc' }, + include: { + person: { + include: personInclude + }, equipment: { select: { equipment_name: true, diff --git a/backend/src/controllers/departmentController.ts b/backend/src/controllers/departmentController.ts index efe7a9c..88a3e3e 100644 --- a/backend/src/controllers/departmentController.ts +++ b/backend/src/controllers/departmentController.ts @@ -15,6 +15,11 @@ const baseSelect = { updated_at: true } +const departmentCollator = new Intl.Collator('zh-CN', { + numeric: true, + sensitivity: 'base' +}) + const buildDepartmentLevelMap = (departments: Array<{ id: number; parent_id: number | null }>) => { const byId = new Map(departments.map((dept) => [dept.id, dept])) const cache = new Map() @@ -43,11 +48,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() for (const department of departments) { const siblings = childrenMap.get(department.parent_id) || [] @@ -55,10 +98,15 @@ 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) }) @@ -96,11 +144,11 @@ 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' }, + orderBy: [{ parent_id: 'asc' }, { name: 'asc' }], select: { ...baseSelect, _count: { - select: { equipments: true, children: true } + select: { equipments: true, children: true, personDepartments: true } } } }) @@ -139,11 +187,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 +201,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( @@ -317,7 +366,7 @@ export const deleteDepartment = async (req: Request, res: Response) => { where: { id: Number(id) }, include: { _count: { - select: { equipments: true, children: true } + select: { equipments: true, children: true, mainPersons: true, personDepartments: true } } } }) @@ -335,6 +384,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) } }) diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index 5e8d831..893089f 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -3,6 +3,7 @@ import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' +import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' // 创建报废申请 export const createDisposal = async (req: Request, res: Response) => { @@ -22,6 +23,10 @@ export const createDisposal = async (req: Request, res: Response) => { return errorResponse(res, '资产不存在', 404) } + if (equipment.parent_id) { + return errorResponse(res, '该子资产当前已挂载,请先卸载后再申请报废', 422) + } + if (equipment.status === '待报废' || equipment.status === '已报废') { return errorResponse(res, '资产已在报废流程中') } @@ -33,6 +38,12 @@ export const createDisposal = async (req: Request, res: Response) => { } const disposal = await prisma.$transaction(async (tx) => { + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: Number(equipment.remaining_quantity), + status: '待报废' + }) const created = await tx.disposalRecord.create({ data: { equipment_id: equipment.id, @@ -47,9 +58,16 @@ export const createDisposal = async (req: Request, res: Response) => { await tx.equipment.update({ where: { id: equipment.id }, - data: { status: '待报废' } + data: { + status: '待报废', + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } }) + await syncMountedChildrenState(tx, equipment.id, '待报废') + return created }) @@ -165,14 +183,28 @@ export const approveDisposal = async (req: Request, res: Response) => { return } + const values = calculateEquipmentValues({ + unitPrice: Number(disposal.equipment?.unit_price || 0), + totalQuantity: Number(disposal.equipment?.total_quantity || 0), + remainingQuantity: Number(disposal.equipment?.remaining_quantity || 0), + status: '可用' + }) + 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: '可用' } + data: { + status: '可用', + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } }) + + await syncMountedChildrenState(tx, disposal.equipment_id, '可用') }) // 记录操作日志 @@ -212,6 +244,17 @@ export const completeDisposal = async (req: Request, res: Response) => { } await prisma.$transaction(async (tx) => { + const equipment = await tx.equipment.findUnique({ + where: { id: disposal.equipment_id } + }) + + const values = calculateEquipmentValues({ + unitPrice: Number(equipment?.unit_price || 0), + totalQuantity: Number(equipment?.total_quantity || 0), + remainingQuantity: Number(equipment?.remaining_quantity || 0), + status: '已报废' + }) + await tx.disposalRecord.update({ where: { id: Number(id) }, data: { @@ -224,8 +267,15 @@ export const completeDisposal = async (req: Request, res: Response) => { await tx.equipment.update({ where: { id: disposal.equipment_id }, - data: { status: '已报废' } + data: { + status: '已报废', + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } }) + + await syncMountedChildrenState(tx, disposal.equipment_id, '已报废') }) // 记录操作日志 diff --git a/backend/src/controllers/equipmentController.ts b/backend/src/controllers/equipmentController.ts index afa4a56..5587594 100644 --- a/backend/src/controllers/equipmentController.ts +++ b/backend/src/controllers/equipmentController.ts @@ -2,19 +2,37 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { generateEquipmentCode, validateEquipmentCode } from '../utils/equipmentCode' +import { + calculateEquipmentValues, + deriveParentBaseUnitPrice, + refreshParentTotalValueWithChildren, + sumBundleChildrenValue, + syncMountedChildrenState +} from '../utils/assetValue' -// 计算资产价值 -const calculateEquipmentValues = (unitPrice: number, totalQuantity: number, remainingQuantity: number) => { - const totalValue = Number(unitPrice) * totalQuantity - const borrowedQuantity = totalQuantity - remainingQuantity - const borrowedValue = Number(unitPrice) * borrowedQuantity - const availableValue = Number(unitPrice) * remainingQuantity - - return { - total_value: totalValue, - borrowed_value: borrowedValue, - available_value: availableValue - } +type EquipmentEntryMode = 'single' | 'parent' | 'child' + +interface EquipmentCreateInput { + equipment_code?: string + equipment_name?: string + equipment_model?: string + equipment_type?: string + code_generation_mode?: 'auto' | 'manual' + unit_price?: number | string + total_quantity?: number | string + remaining_quantity?: number | string + purchase_date?: string | null + supplier?: string | null + description?: string | null + storage_location_id?: number | string | null + department_id?: number | string | null + asset_type_id?: number | string | null + status?: string +} + +interface EquipmentBundleChildInput extends EquipmentCreateInput { + quantity?: number | string | null + notes?: string | null } const includeStorageLocation = { @@ -43,12 +61,48 @@ const includeStorageLocation = { } } -const ensureLocationExists = async (locationId?: number | null) => { +const includeEquipmentRelations = { + ...includeStorageLocation, + parent: { + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + unit_price: true + } + }, + children: { + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + unit_price: true, + status: true, + mount_status: true + } + } +} + +const toOptionalNumber = (value: unknown) => { + if (value === undefined || value === null || value === '') return null + const numericValue = Number(value) + return Number.isNaN(numericValue) ? null : numericValue +} + +const normalizeEntryMode = (value: unknown): EquipmentEntryMode => { + if (value === 'parent' || value === 'child') return value + return 'single' +} + +const ensureLocationExists = async (client: any, locationId?: number | null) => { if (!locationId) { throw new Error('地址信息不能为空') } - const location = await prisma.storageLocation.findUnique({ + const location = await client.storageLocation.findUnique({ where: { id: locationId } }) @@ -59,12 +113,150 @@ const ensureLocationExists = async (locationId?: number | null) => { return location } +const ensureAssetTypeExists = async (client: any, equipmentType?: string, assetTypeId?: number | null) => { + const normalizedEquipmentType = equipmentType?.trim() + + const assetType = assetTypeId + ? await client.assetType.findUnique({ + where: { id: assetTypeId } + }) + : normalizedEquipmentType + ? await client.assetType.findFirst({ + where: { name: normalizedEquipmentType } + }) + : null + + if (!assetType || assetType.status !== 'active') { + throw new Error(`资产类型 "${normalizedEquipmentType || assetTypeId || ''}" 不存在或已停用`) + } + + if (normalizedEquipmentType && assetType.name !== normalizedEquipmentType) { + throw new Error('资产类型信息不匹配,请重新选择后再保存') + } + + return assetType +} + +const resolveEquipmentCode = async ( + client: any, + codeGenerationMode: 'auto' | 'manual', + equipmentCode: string | undefined, + equipmentType: string, + locationId?: number | null +) => { + if (codeGenerationMode === 'auto') { + let finalCode = await generateEquipmentCode(equipmentType, client, locationId) + + const existingCheck = await client.equipment.findUnique({ + where: { equipment_code: finalCode } + }) + + if (existingCheck) { + finalCode = await generateEquipmentCode(equipmentType, client, locationId) + } + + return finalCode + } + + if (!equipmentCode?.trim()) { + throw new Error('自定义录入模式下资产编码不能为空') + } + + const normalizedCode = equipmentCode.trim().toUpperCase() + if (!validateEquipmentCode(normalizedCode)) { + throw new Error('资产编码格式不正确') + } + + const existing = await client.equipment.findUnique({ + where: { equipment_code: normalizedCode } + }) + + if (existing) { + throw new Error('资产编码已存在') + } + + return normalizedCode +} + +const createEquipmentRecord = async ( + client: any, + input: EquipmentCreateInput, + options?: { + forceSingleQuantity?: boolean + parentId?: number | null + mountStatus?: 'none' | 'mounted' | 'unmounted' + } +) => { + const equipmentName = input.equipment_name?.trim() + if (!equipmentName) { + throw new Error('资产名称不能为空') + } + + const locationId = toOptionalNumber(input.storage_location_id) + const departmentId = toOptionalNumber(input.department_id) + const assetTypeId = toOptionalNumber(input.asset_type_id) + + await ensureLocationExists(client, locationId) + const assetType = await ensureAssetTypeExists(client, input.equipment_type, assetTypeId) + + const codeGenerationMode = input.code_generation_mode === 'manual' ? 'manual' : 'auto' + const finalCode = await resolveEquipmentCode( + client, + codeGenerationMode, + input.equipment_code, + assetType.name, + locationId + ) + + const finalTotalQuantity = options?.forceSingleQuantity + ? 1 + : Math.max(1, Number(input.total_quantity) || 1) + const requestedRemainingQuantity = options?.forceSingleQuantity + ? 1 + : Number(input.remaining_quantity ?? finalTotalQuantity) || finalTotalQuantity + const remainingQuantity = Math.min(finalTotalQuantity, Math.max(0, requestedRemainingQuantity)) + const unitPrice = Number(input.unit_price) || 0 + const finalStatus = input.status || '可用' + const values = calculateEquipmentValues({ + unitPrice, + totalQuantity: finalTotalQuantity, + remainingQuantity, + status: finalStatus + }) + + return client.equipment.create({ + data: { + equipment_code: finalCode, + equipment_name: equipmentName, + equipment_model: input.equipment_model?.trim() || null, + equipment_type: assetType.name, + code_generation_mode: codeGenerationMode, + unit_price: unitPrice, + total_quantity: finalTotalQuantity, + remaining_quantity: remainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value, + purchase_date: input.purchase_date ? new Date(input.purchase_date) : null, + supplier: input.supplier?.trim() || null, + description: input.description?.trim() || null, + status: finalStatus, + storage_location_id: locationId || undefined, + department_id: departmentId || undefined, + asset_type_id: assetType.id, + parent_id: options?.parentId || null, + mount_status: options?.mountStatus || 'none' + } + }) +} + // 获取设备列表 export const getEquipmentList = async (req: Request, res: Response) => { try { const { page = 1, pageSize = 10, equipmentType, status, keyword, storage_location_id, department_id, asset_type_id, mount_status, parent_id } = req.query const where: any = {} + const hasParentFilter = !Number.isNaN(Number(parent_id)) && Boolean(parent_id) if (equipmentType) { where.equipment_type = equipmentType @@ -75,11 +267,24 @@ export const getEquipmentList = async (req: Request, res: Response) => { } if (keyword) { - where.OR = [ + const ownSearchConditions = [ { equipment_name: { contains: keyword as string } }, { equipment_code: { contains: keyword as string } }, { equipment_model: { contains: keyword as string } } ] + + where.OR = hasParentFilter + ? ownSearchConditions + : [ + ...ownSearchConditions, + { + children: { + some: { + OR: ownSearchConditions + } + } + } + ] } const locationId = Number(storage_location_id) @@ -99,18 +304,25 @@ export const getEquipmentList = async (req: Request, res: Response) => { // 挂载状态筛选 if (mount_status && mount_status !== 'all') { - if (mount_status === 'mounted') { - where.mount_status = 'mounted' + if (hasParentFilter) { + if (mount_status === 'mounted') { + where.mount_status = 'mounted' + } else if (mount_status === 'unmounted') { + where.mount_status = { in: ['none', 'unmounted'] } + } + } else if (mount_status === 'mounted') { + where.children = { some: {} } } else if (mount_status === 'unmounted') { - where.mount_status = { in: ['none', 'unmounted'] } - where.parent_id = null + where.children = { none: {} } } } // 父资产筛选(显示某主机的所有配件) const parentIdNum = Number(parent_id) - if (!Number.isNaN(parentIdNum) && parent_id) { + if (hasParentFilter) { where.parent_id = parentIdNum + } else { + where.parent_id = null } const skip = (Number(page) - 1) * Number(pageSize) @@ -136,7 +348,10 @@ export const getEquipmentList = async (req: Request, res: Response) => { equipment_code: true, equipment_name: true, equipment_model: true, - unit_price: true + equipment_type: true, + unit_price: true, + status: true, + mount_status: true } } } @@ -166,30 +381,7 @@ export const getEquipmentDetail = async (req: Request, res: Response) => { const equipment = await prisma.equipment.findUnique({ where: { id: Number(id) }, - include: { - ...includeStorageLocation, - parent: { - select: { - id: true, - equipment_code: true, - equipment_name: true, - equipment_model: true, - unit_price: true - } - }, - children: { - select: { - id: true, - equipment_code: true, - equipment_name: true, - equipment_model: true, - equipment_type: true, - unit_price: true, - status: true, - mount_status: true - } - } - } + include: includeEquipmentRelations }) if (!equipment) { @@ -205,109 +397,163 @@ export const getEquipmentDetail = async (req: Request, res: Response) => { // 新增资产 export const createEquipment = async (req: Request, res: Response) => { try { - const { - equipment_code, - equipment_name, - equipment_model, - equipment_type, - code_generation_mode = 'auto', - unit_price, - total_quantity, - purchase_date, - supplier, - description, - storage_location_id, - department_id, - asset_type_id - } = req.body - - // 生成或验证设备编码 - let finalCode = equipment_code - - const locationId = storage_location_id ? Number(storage_location_id) : null - const deptId = department_id ? Number(department_id) : null - const typeId = asset_type_id ? Number(asset_type_id) : null - - if (code_generation_mode === 'auto') { - // 自动生成编码,确保唯一性(generateEquipmentCode内部已处理唯一性检查) - finalCode = await generateEquipmentCode(equipment_type, prisma, locationId) - - // 再次检查编码唯一性(双重保险) - const existingCheck = await prisma.equipment.findUnique({ - where: { equipment_code: finalCode } - }) - - if (existingCheck) { - // 如果编码已存在,重新生成(这种情况理论上不应该发生,但作为安全措施) - finalCode = await generateEquipmentCode(equipment_type, prisma, locationId) - } - } else { - if (!equipment_code) { - return errorResponse(res, '自定义录入模式下资产编码不能为空') - } - - if (!validateEquipmentCode(equipment_code)) { - return errorResponse(res, '资产编码格式不正确') - } - - // 检查编码是否已存在 - const existing = await prisma.equipment.findUnique({ - where: { equipment_code } - }) - - if (existing) { - return errorResponse(res, '资产编码已存在') - } - } - - try { - await ensureLocationExists(locationId) - } catch (error: any) { - return errorResponse(res, error.message || '地址无效', 422) - } + const entryMode = normalizeEntryMode(req.body.entry_mode) + const userId = Number(res.locals.user?.id) + const locationId = toOptionalNumber(req.body.storage_location_id) + const departmentId = toOptionalNumber(req.body.department_id) + const assetTypeId = toOptionalNumber(req.body.asset_type_id) + const bundleChildren = Array.isArray(req.body.children) ? (req.body.children as EquipmentBundleChildInput[]) : [] - // 计算价值 - const remaining_quantity = total_quantity || 1 - const values = calculateEquipmentValues( - Number(unit_price) || 0, - Number(total_quantity) || 1, - remaining_quantity - ) - - const created = await prisma.equipment.create({ - data: { - equipment_code: finalCode, - equipment_name, - equipment_model, - equipment_type, - code_generation_mode, - unit_price: Number(unit_price) || 0, - total_quantity: Number(total_quantity) || 1, - remaining_quantity, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value, - purchase_date: purchase_date ? new Date(purchase_date) : null, - supplier, - description, - status: '可用', - storage_location_id: locationId || undefined, - department_id: deptId || undefined, - asset_type_id: typeId || undefined + const createdEquipment = await prisma.$transaction(async (tx) => { + if (entryMode === 'parent') { + if (!Number.isFinite(userId) || userId <= 0) { + throw new Error('用户未登录,无法创建父子资产') + } + + const parentAssetType = await ensureAssetTypeExists(tx, req.body.equipment_type, assetTypeId) + if (!parentAssetType.can_be_parent) { + throw new Error('当前资产类型不能作为父资产') + } + + const bundleChildrenTotalValue = sumBundleChildrenValue(bundleChildren) + const parentBaseUnitPrice = deriveParentBaseUnitPrice(req.body.unit_price, bundleChildrenTotalValue) + + const parent = await createEquipmentRecord( + tx, + { + ...req.body, + unit_price: parentBaseUnitPrice, + storage_location_id: locationId, + department_id: departmentId, + asset_type_id: parentAssetType.id + }, + { + forceSingleQuantity: true, + mountStatus: 'none' + } + ) + + for (let index = 0; index < bundleChildren.length; index += 1) { + const childInput = bundleChildren[index] + const childName = childInput.equipment_name?.trim() + const childType = childInput.equipment_type?.trim() + const childQuantity = Math.max(1, Math.floor(Number(childInput.quantity) || 1)) + const hasAnyValue = Boolean( + childName || + childType || + childInput.equipment_model?.trim() || + childInput.description?.trim() || + childInput.notes?.trim() || + childQuantity > 1 || + Number(childInput.unit_price || 0) + ) + + if (!hasAnyValue) { + continue + } + + if (!childName || !childType) { + throw new Error(`第 ${index + 1} 个子资产还没填写完整`) + } + + const childAssetType = await ensureAssetTypeExists( + tx, + childType, + toOptionalNumber(childInput.asset_type_id) + ) + if (!childAssetType.can_be_child) { + throw new Error(`子资产类型 "${childAssetType.name}" 不能作为配件挂载`) + } + + for (let quantityIndex = 0; quantityIndex < childQuantity; quantityIndex += 1) { + const child = await createEquipmentRecord( + tx, + { + ...childInput, + purchase_date: childInput.purchase_date ?? req.body.purchase_date, + supplier: childInput.supplier ?? req.body.supplier, + storage_location_id: locationId, + department_id: departmentId, + asset_type_id: childAssetType.id + }, + { + forceSingleQuantity: true, + parentId: parent.id, + mountStatus: 'mounted' + } + ) + + await tx.mountHistory.create({ + data: { + child_id: child.id, + parent_id: parent.id, + operation_type: 'mount', + operated_by: userId, + notes: childInput.notes?.trim() || '创建父资产时自动挂载' + } + }) + } + } + + await refreshParentTotalValueWithChildren(tx, parent.id) + return parent } + + if (entryMode === 'child') { + const childAssetType = await ensureAssetTypeExists(tx, req.body.equipment_type, assetTypeId) + if (!childAssetType.can_be_child) { + throw new Error('当前资产类型不能作为子资产') + } + + return createEquipmentRecord( + tx, + { + ...req.body, + storage_location_id: locationId, + department_id: departmentId, + asset_type_id: childAssetType.id + }, + { + forceSingleQuantity: true, + mountStatus: 'none' + } + ) + } + + return createEquipmentRecord( + tx, + { + ...req.body, + storage_location_id: locationId, + department_id: departmentId, + asset_type_id: assetTypeId + }, + { + forceSingleQuantity: false, + mountStatus: 'none' + } + ) }) const equipment = await prisma.equipment.findUnique({ - where: { id: created.id }, - include: includeStorageLocation + where: { id: createdEquipment.id }, + include: includeEquipmentRelations }) successResponse(res, equipment, '新增成功') } catch (error: any) { - if (error.code === 'P2002') { + if (error.code === 'P2002' || error.message?.includes('资产编码已存在')) { return errorResponse(res, '资产编码已存在') } - errorResponse(res, error.message || '新增资产失败') + const statusCode = + error.message?.includes('不存在') || + error.message?.includes('不能为空') || + error.message?.includes('格式不正确') || + error.message?.includes('不能作为') || + error.message?.includes('填写完整') + ? 422 + : 500 + errorResponse(res, error.message || '新增资产失败', statusCode) } } @@ -343,7 +589,7 @@ export const updateEquipment = async (req: Request, res: Response) => { if (storage_location_id !== undefined) { const normalizedLocationId = Number(storage_location_id) try { - await ensureLocationExists(normalizedLocationId) + await ensureLocationExists(prisma, normalizedLocationId) } catch (error: any) { return errorResponse(res, error.message || '地址无效', 422) } @@ -372,7 +618,18 @@ export const updateEquipment = async (req: Request, res: Response) => { total_quantity !== undefined ? Math.max(finalTotalQuantity - borrowedQuantity, 0) : currentEquipment.remaining_quantity - const values = calculateEquipmentValues(finalUnitPrice, finalTotalQuantity, newRemainingQuantity) + const resolvedStatus = status + ?? ( + ['维护中', '待报废', '已报废', '已损坏'].includes(currentEquipment.status) + ? currentEquipment.status + : (newRemainingQuantity > 0 ? '可用' : '已借出') + ) + const values = calculateEquipmentValues({ + unitPrice: finalUnitPrice, + totalQuantity: finalTotalQuantity, + remainingQuantity: newRemainingQuantity, + status: resolvedStatus + }) // 调试日志:观察请求与计算过程 console.log('[更新资产] 输入参数:', { @@ -419,7 +676,7 @@ export const updateEquipment = async (req: Request, res: Response) => { borrowed_value: values.borrowed_value, available_value: values.available_value, // 若未显式传入状态,则根据剩余库存自动设定 - status: status ?? (newRemainingQuantity > 0 ? '可用' : '已借出'), + status: resolvedStatus, purchase_date: purchase_date ? new Date(purchase_date) : currentEquipment.purchase_date, supplier, description, @@ -430,6 +687,29 @@ export const updateEquipment = async (req: Request, res: Response) => { } }) + const childCount = await prisma.equipment.count({ + where: { parent_id: Number(id) } + }) + + if ( + childCount > 0 && + ['可用', '已借出', '维护中', '待报废', '已报废'].includes(resolvedStatus) + ) { + await syncMountedChildrenState( + prisma, + Number(id), + resolvedStatus as '可用' | '已借出' | '维护中' | '待报废' | '已报废' + ) + } + + if (childCount > 0) { + await refreshParentTotalValueWithChildren(prisma, Number(id)) + } + + if (currentEquipment.parent_id) { + await refreshParentTotalValueWithChildren(prisma, currentEquipment.parent_id) + } + console.log('[更新资产] 已写入:', { id, total_quantity: finalTotalQuantity, @@ -528,7 +808,7 @@ export const generateCode = async (req: Request, res: Response) => { const code = await generateEquipmentCode(equipment_type, prisma, locationId) successResponse(res, { equipment_code: code, - code_format: '{类型简称}-{地址ID(4位)}-{序号(5位)}' + code_format: 'JS-{类型简称}-{序号(4位)}' }, '生成成功') } else { errorResponse(res, '自定义录入模式不需要生成编码') diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index c938c8b..a2af3b7 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -1,5 +1,12 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' +import { + getEquipmentDisplayAvailableValue, + getEquipmentDisplayBorrowedValue, + getEquipmentDisplayTotalValue, + getEquipmentMountedChildrenValue, + sumSnapshotChildrenValue +} from '../utils/assetValue' // eslint-disable-next-line @typescript-eslint/no-var-requires const ExcelJS = require('exceljs') @@ -218,7 +225,11 @@ export const exportEquipment = async (req: Request, res: Response) => { { 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: 'unit_price', width: 12 }, + { header: '当前价值', key: 'display_total_value', width: 14 }, + { header: '挂载配件价值', key: 'mounted_children_value', width: 16 }, + { header: '借出价值', key: 'borrowed_value', width: 14 }, + { header: '可用价值', key: 'available_value', width: 14 }, { header: '总数量', key: 'total_quantity', width: 10 }, { header: '剩余数量', key: 'remaining_quantity', width: 10 }, { header: '状态', key: 'status', width: 10 }, @@ -239,6 +250,10 @@ export const exportEquipment = async (req: Request, res: Response) => { department: eq.department?.name || '', storage_location: eq.storageLocation?.name || '', unit_price: Number(eq.unit_price), + display_total_value: getEquipmentDisplayTotalValue(eq), + mounted_children_value: getEquipmentMountedChildrenValue(eq), + borrowed_value: getEquipmentDisplayBorrowedValue(eq), + available_value: getEquipmentDisplayAvailableValue(eq), total_quantity: eq.total_quantity, remaining_quantity: eq.remaining_quantity, status: eq.status, @@ -284,7 +299,16 @@ 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, + department: true, + mainDepartment: { + select: { name: true } + } + } + } }, orderBy: { borrow_time: 'desc' } }) @@ -301,6 +325,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { { 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 +340,11 @@ 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 || '', + 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 || '' diff --git a/backend/src/controllers/maintenanceController.ts b/backend/src/controllers/maintenanceController.ts index 9da5c39..0f36f18 100644 --- a/backend/src/controllers/maintenanceController.ts +++ b/backend/src/controllers/maintenanceController.ts @@ -3,6 +3,7 @@ import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' +import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' // 创建维修记录 export const createMaintenance = async (req: Request, res: Response) => { @@ -22,6 +23,10 @@ export const createMaintenance = async (req: Request, res: Response) => { return errorResponse(res, '资产不存在', 404) } + if (equipment.parent_id) { + return errorResponse(res, '该子资产当前已挂载,请先卸载后再送修', 422) + } + if (equipment.status === '维护中') { return errorResponse(res, '资产已在维修中') } @@ -36,6 +41,12 @@ export const createMaintenance = async (req: Request, res: Response) => { } const maintenance = await prisma.$transaction(async (tx) => { + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: Number(equipment.remaining_quantity), + status: '维护中' + }) const created = await tx.maintenanceRecord.create({ data: { equipment_id: equipment.id, @@ -50,9 +61,16 @@ export const createMaintenance = async (req: Request, res: Response) => { await tx.equipment.update({ where: { id: equipment.id }, - data: { status: '维护中' } + data: { + status: '维护中', + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } }) + await syncMountedChildrenState(tx, equipment.id, '维护中') + return created }) @@ -185,7 +203,21 @@ export const completeMaintenance = async (req: Request, res: Response) => { return errorResponse(res, '只能完成进行中的维修记录') } + const equipment = await prisma.equipment.findUnique({ + where: { id: maintenance.equipment_id } + }) + + if (!equipment) { + return errorResponse(res, '资产不存在', 404) + } + await prisma.$transaction(async (tx) => { + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: Number(equipment.remaining_quantity), + status: '可用' + }) await tx.maintenanceRecord.update({ where: { id: Number(id) }, data: { @@ -198,8 +230,15 @@ export const completeMaintenance = async (req: Request, res: Response) => { await tx.equipment.update({ where: { id: maintenance.equipment_id }, - data: { status: '可用' } + data: { + status: '可用', + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } }) + + await syncMountedChildrenState(tx, equipment.id, '可用') }) // 记录操作日志 @@ -239,7 +278,21 @@ export const cancelMaintenance = async (req: Request, res: Response) => { return errorResponse(res, '只能取消进行中的维修记录') } + const equipment = await prisma.equipment.findUnique({ + where: { id: maintenance.equipment_id } + }) + + if (!equipment) { + return errorResponse(res, '资产不存在', 404) + } + await prisma.$transaction(async (tx) => { + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: Number(equipment.remaining_quantity), + status: '可用' + }) await tx.maintenanceRecord.update({ where: { id: Number(id) }, data: { status: 'cancelled', repair_result: reason || '已取消' } @@ -247,8 +300,15 @@ export const cancelMaintenance = async (req: Request, res: Response) => { await tx.equipment.update({ where: { id: maintenance.equipment_id }, - data: { status: '可用' } + data: { + status: '可用', + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } }) + + await syncMountedChildrenState(tx, equipment.id, '可用') }) // 记录操作日志 diff --git a/backend/src/controllers/mountController.ts b/backend/src/controllers/mountController.ts index d817856..1f69275 100644 --- a/backend/src/controllers/mountController.ts +++ b/backend/src/controllers/mountController.ts @@ -1,36 +1,11 @@ import { Request, Response } from 'express' -import { Decimal } from '@prisma/client/runtime/library' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' - -async function calculateTotalValueWithChildren(parentId: number): Promise { - const parent = await prisma.equipment.findUnique({ - where: { id: parentId }, - include: { - children: { - select: { unit_price: true } - } - } - }) - - if (!parent) return new Decimal(0) - - const childrenValue = parent.children.reduce( - (sum, child) => sum.add(child.unit_price), - new Decimal(0) - ) - - return parent.unit_price.add(childrenValue) -} - -async function updateParentTotalValue(parentId: number): Promise { - const totalValue = await calculateTotalValueWithChildren(parentId) - await prisma.equipment.update({ - where: { id: parentId }, - data: { total_value_with_children: totalValue } - }) -} +import { + calculateChildrenTotalValueFromRecords, + refreshParentTotalValueWithChildren +} from '../utils/assetValue' const ensureMountableEquipment = async ( equipment: { id: number; status: string; remaining_quantity: number }, @@ -142,7 +117,7 @@ export const mountChild = async (req: Request, res: Response) => { }) }) - await updateParentTotalValue(numericParentId) + await refreshParentTotalValueWithChildren(prisma, numericParentId) const updatedParent = await prisma.equipment.findUnique({ where: { id: numericParentId }, @@ -221,7 +196,7 @@ export const unmountChild = async (req: Request, res: Response) => { }) }) - await updateParentTotalValue(numericParentId) + await refreshParentTotalValueWithChildren(prisma, numericParentId) successResponse(res, null, '卸载成功') } catch (error: any) { @@ -339,7 +314,7 @@ export const replaceChild = async (req: Request, res: Response) => { }) }) - await updateParentTotalValue(numericParentId) + await refreshParentTotalValueWithChildren(prisma, numericParentId) successResponse(res, null, '更换成功') } catch (error: any) { @@ -374,16 +349,13 @@ 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) successResponse( res, { children: parent.children, - total_children_value: Number(totalChildrenValue), + total_children_value: totalChildrenValue, total_value_with_children: Number(parent.total_value_with_children) }, '获取成功' diff --git a/backend/src/controllers/notificationController.ts b/backend/src/controllers/notificationController.ts new file mode 100644 index 0000000..e74d4f1 --- /dev/null +++ b/backend/src/controllers/notificationController.ts @@ -0,0 +1,167 @@ +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 || '全部标记已读失败') + } +} diff --git a/backend/src/controllers/personController.ts b/backend/src/controllers/personController.ts index 975e05f..e04aa9d 100644 --- a/backend/src/controllers/personController.ts +++ b/backend/src/controllers/personController.ts @@ -2,40 +2,180 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' -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() + 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, + 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, + 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 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 } }, + { id_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' + }, + 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 +183,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 +207,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 || '获取使用者详情失败') @@ -94,25 +240,39 @@ 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 normalizedIdNumber = normalizeOptionalText(id_number) + if (normalizedIdNumber) { + const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } }) if (exists) { return errorResponse(res, '证件编号已存在', 409) } } - const created = await prisma.person.create({ - data: { - nickname, - real_name, - id_number, - department, - position, - phone, - email, - status, - remarks - } + 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(), + 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,6 +284,7 @@ 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, @@ -136,31 +297,57 @@ export const updatePerson = async (req: Request, res: Response) => { remarks } = req.body as Record - 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 (id_number && id_number !== person.id_number) { - const exists = await prisma.person.findUnique({ where: { id_number } }) + 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 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 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, + 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), '更新成功') @@ -190,7 +377,8 @@ export const updatePersonStatus = async (req: Request, res: Response) => { const updated = await prisma.person.update({ where: { id: Number(id) }, - data: { status } + data: { status }, + include: personInclude }) successResponse(res, sanitizePerson(updated), '状态更新成功') @@ -199,7 +387,6 @@ export const updatePersonStatus = async (req: Request, res: Response) => { } } - export const deletePerson = async (req: Request, res: Response) => { try { const { id } = req.params @@ -210,7 +397,6 @@ export const deletePerson = async (req: Request, res: Response) => { return errorResponse(res, '使用者不存在', 404) } - // 检查是否有未归还的借用记录 const activeBorrowCount = await prisma.borrowRecord.count({ where: { person_id: personId, @@ -223,6 +409,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 +426,15 @@ export const deletePerson = 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 - } + orderBy: [{ nickname: 'asc' }, { real_name: 'asc' }], + include: personInclude }) - successResponse(res, persons, '获取成功') + + successResponse(res, persons.map(sanitizePerson), '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取使用者选项失败') } diff --git a/backend/src/controllers/settingsController.ts b/backend/src/controllers/settingsController.ts index 5f4bb69..f98a22a 100644 --- a/backend/src/controllers/settingsController.ts +++ b/backend/src/controllers/settingsController.ts @@ -1,17 +1,84 @@ +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/logo.svg' +const DEFAULT_FAVICON_URL = '/branding/favicon.svg' +const CUSTOM_LOGO_CANDIDATES = [ + 'custom-logo.svg', + 'custom-logo.png', + 'custom-logo.webp', + 'custom-logo.jpg', + 'custom-logo.jpeg' +] +const DEFAULT_LOGO_CANDIDATES = ['logo.svg', 'logo.png', 'logo.webp', 'logo.jpg', 'logo.jpeg'] +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( + [...CUSTOM_LOGO_CANDIDATES, ...DEFAULT_LOGO_CANDIDATES], + DEFAULT_LOGO_URL + ) + +const getResolvedFaviconUrl = () => + resolveBrandingAssetUrl( + [ + ...CUSTOM_FAVICON_CANDIDATES, + ...CUSTOM_LOGO_CANDIDATES, + ...DEFAULT_FAVICON_CANDIDATES, + ...DEFAULT_LOGO_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 +87,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 +124,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 +136,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 diff --git a/backend/src/controllers/statisticsController.ts b/backend/src/controllers/statisticsController.ts index dfd91af..77f9e53 100644 --- a/backend/src/controllers/statisticsController.ts +++ b/backend/src/controllers/statisticsController.ts @@ -2,6 +2,29 @@ import { Request, Response } from 'express' import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' +import { + getEquipmentDisplayAvailableValue, + getEquipmentDisplayBorrowedValue, + getEquipmentDisplayItemValue, + getEquipmentDisplayTotalValue, + sumSnapshotChildrenValue +} from '../utils/assetValue' + +type DisplayValueRow = { + equipment_type?: string | null + status?: string | null + total_value?: unknown + borrowed_value?: unknown + available_value?: unknown + total_value_with_children?: unknown +} + +type DisplayValueSummary = { + total_value: number + borrowed_value: number + available_value: number + maintenance_value: number +} const toNumber = (value: unknown) => { if (value === null || value === undefined) return 0 @@ -31,6 +54,28 @@ const buildEquipmentWhere = (equipmentType?: string, startDate?: string, endDate return where } +const summarizeDisplayValues = (rows: DisplayValueRow[]): DisplayValueSummary => + rows.reduce( + (summary, row) => { + const totalValue = getEquipmentDisplayTotalValue(row) + summary.total_value += totalValue + summary.borrowed_value += getEquipmentDisplayBorrowedValue(row) + summary.available_value += getEquipmentDisplayAvailableValue(row) + + if (row.status === '维护中') { + summary.maintenance_value += totalValue + } + + return summary + }, + { + total_value: 0, + borrowed_value: 0, + available_value: 0, + maintenance_value: 0 + } + ) + // 获取首页统计数据 export const getDashboardStats = async (req: Request, res: Response) => { try { @@ -38,39 +83,67 @@ export const getDashboardStats = async (req: Request, res: Response) => { const [ equipmentTotals, + availableInventoryTotals, + borrowableInventoryTotals, maintenanceTotals, inventoryByType, + displayValueRows, todayBorrowCount, todayReturnCount, - todayBorrowValueRows, - todayReturnValueRows + todayBorrowRecords, + todayReturnRecords ] = await Promise.all([ prisma.equipment.aggregate({ _sum: { total_quantity: true, - remaining_quantity: true, total_value: true, borrowed_value: true, available_value: true } }), prisma.equipment.aggregate({ - where: { status: '维护中' }, + where: { + status: '可用' + }, _sum: { - total_quantity: true, - available_value: true + remaining_quantity: true } }), - prisma.equipment.groupBy({ - by: ['equipment_type'], + prisma.equipment.aggregate({ + where: { + status: { + in: ['可用', '已借出'] + } + }, _sum: { total_quantity: true, remaining_quantity: true + } + }), + prisma.equipment.aggregate({ + where: { status: '维护中' }, + _sum: { + total_quantity: true, + total_value: true + } + }), + prisma.equipment.groupBy({ + by: ['equipment_type', 'status'], + _sum: { + total_quantity: true, + remaining_quantity: true + } + }), + prisma.equipment.findMany({ + where: { + parent_id: null }, - orderBy: { - _sum: { - total_quantity: 'desc' - } + select: { + status: true, + total_value: true, + borrowed_value: true, + available_value: true, + total_value_with_children: true } }), prisma.borrowRecord.count({ @@ -87,33 +160,56 @@ export const getDashboardStats = async (req: Request, res: Response) => { } } }), - prisma.$queryRaw>(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>(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} - `) + prisma.borrowRecord.findMany({ + where: { + borrow_time: { + gte: startOfToday + } + }, + select: { + children_snapshot: true, + equipment: { + select: { + unit_price: true + } + } + } + }), + prisma.borrowRecord.findMany({ + where: { + return_time: { + gte: startOfToday + } + }, + select: { + children_snapshot: true, + equipment: { + select: { + unit_price: true + } + } + } + }) ]) const totalEquipmentQuantity = toNumber(equipmentTotals._sum.total_quantity) - const availableEquipmentQuantity = toNumber(equipmentTotals._sum.remaining_quantity) + const availableEquipmentQuantity = toNumber(availableInventoryTotals._sum.remaining_quantity) const borrowedEquipmentQuantity = Math.max( - totalEquipmentQuantity - availableEquipmentQuantity, + toNumber(borrowableInventoryTotals._sum.total_quantity) - toNumber(borrowableInventoryTotals._sum.remaining_quantity), 0 ) 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 displayValueSummary = summarizeDisplayValues(displayValueRows) + const totalValue = displayValueSummary.total_value + const borrowedValue = displayValueSummary.borrowed_value + const availableValue = displayValueSummary.available_value + const maintenanceValue = displayValueSummary.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 usageRate = totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0 const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0 @@ -133,17 +229,37 @@ 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) + inventory_by_type: Array.from( + inventoryByType.reduce((map, item) => { + const equipmentType = item.equipment_type || '其他' + const current = map.get(equipmentType) || { + equipment_type: equipmentType, + total_quantity: 0, + available_quantity: 0, + borrowed_quantity: 0 } - }) + const totalQuantity = toNumber(item._sum.total_quantity) + const remainingQuantity = toNumber(item._sum.remaining_quantity) + + current.total_quantity += totalQuantity + + if (item.status === '可用') { + current.available_quantity += remainingQuantity + current.borrowed_quantity += Math.max(totalQuantity - remainingQuantity, 0) + } else if (item.status === '已借出') { + current.borrowed_quantity += Math.max(totalQuantity - remainingQuantity, 0) + } + + map.set(equipmentType, current) + return map + }, new Map()).values() + ) + .sort((a, b) => b.total_quantity - a.total_quantity) .filter((item) => item.total_quantity > 0) }, '获取成功') } catch (error: any) { @@ -162,36 +278,21 @@ 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: { + const displayValueWhere: Prisma.EquipmentWhereInput = { + ...where, + parent_id: null + } + + const [displayValueRows, topEquipmentRows] = await Promise.all([ + prisma.equipment.findMany({ + where: displayValueWhere, + select: { + equipment_type: true, + status: true, 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' - } + available_value: true, + total_value_with_children: true } }), prisma.equipment.findMany({ @@ -199,42 +300,61 @@ export const getValueStatistics = async (req: Request, res: Response) => { select: { equipment_name: true, unit_price: true, + total_quantity: true, total_value: true, + total_value_with_children: true, borrowed_value: true, - available_value: true + available_value: true, + status: true }, - orderBy: { - total_value: 'desc' - }, - take: 10 }) ]) - 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 summary = summarizeDisplayValues(displayValueRows) + const valueByType = Array.from( + displayValueRows.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 += getEquipmentDisplayTotalValue(item) + current.borrowed_value += getEquipmentDisplayBorrowedValue(item) + current.available_value += getEquipmentDisplayAvailableValue(item) + + map.set(equipmentType, current) + return map + }, new Map()) + .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 = topEquipmentRows + .map((eq) => ({ + equipment_name: eq.equipment_name, + quantity: toNumber(eq.total_quantity) || 1, + unit_price: toNumber(eq.unit_price), + item_value: getEquipmentDisplayItemValue(eq), + stock_total_value: getEquipmentDisplayTotalValue(eq), + stock_borrowed_value: getEquipmentDisplayBorrowedValue(eq), + stock_available_value: getEquipmentDisplayAvailableValue(eq) + })) + .sort((a, b) => b.item_value - a.item_value || b.stock_total_value - a.stock_total_value) + .slice(0, 10) 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, value_by_type: valueByType, top_equipment: topEquipment }, '获取成功') diff --git a/backend/src/controllers/syncController.ts b/backend/src/controllers/syncController.ts index 69853d8..3c68a00 100644 --- a/backend/src/controllers/syncController.ts +++ b/backend/src/controllers/syncController.ts @@ -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) : {} + +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) } } diff --git a/backend/src/index.ts b/backend/src/index.ts index 9be79c1..caa21fa 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,6 +24,7 @@ import batchRoutes from './routes/batch' import exportRoutes from './routes/export' import searchRoutes from './routes/search' import syncRoutes from './routes/sync' +import notificationRoutes from './routes/notifications' import { keycloak, memoryStore } from './utils/keycloak' import { protect, requireAdmin } from './middleware/auth' import { startHrSyncScheduler } from './services/hrSyncService' @@ -79,6 +80,7 @@ app.use('/api/batch', protect(), requireAdmin(), batchRoutes) app.use('/api/export', protect(), exportRoutes) app.use('/api/search', protect(), searchRoutes) app.use('/api/sync', protect(), requireAdmin(), syncRoutes) +app.use('/api/notifications', notificationRoutes) // Health check app.get('/api/health', (req, res) => { diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 0000000..5e923db --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,19 @@ +import { Router } from 'express' +import { + getNotificationList, + getUnreadNotificationCount, + markAllNotificationsAsRead, + markNotificationAsRead +} from '../controllers/notificationController' +import { protect } from '../middleware/auth' + +const router = Router() + +router.use(protect()) + +router.get('/', getNotificationList) +router.get('/unread-count', getUnreadNotificationCount) +router.patch('/read-all', markAllNotificationsAsRead) +router.patch('/:id/read', markNotificationAsRead) + +export default router diff --git a/backend/src/routes/sync.ts b/backend/src/routes/sync.ts index 3c0c906..8433a66 100644 --- a/backend/src/routes/sync.ts +++ b/backend/src/routes/sync.ts @@ -1,8 +1,22 @@ import { Router } from 'express' -import { previewHrSyncController, runHrSyncController } from '../controllers/syncController' +import { + getHrSyncJobDetailController, + getHrSyncJobListController, + getHrSyncStatusController, + previewHrSyncController, + runHrSyncController, + updateHrSyncChangeProcessingController +} from '../controllers/syncController' +import { protect, requireAdmin } from '../middleware/auth' const router = Router() +router.use(protect(), requireAdmin()) + +router.get('/hr/jobs', getHrSyncJobListController) +router.get('/hr/jobs/:id', getHrSyncJobDetailController) +router.patch('/hr/changes/:id/processing', updateHrSyncChangeProcessingController) +router.get('/hr/status', getHrSyncStatusController) router.get('/hr/preview', previewHrSyncController) router.post('/hr/run', runHrSyncController) diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index 12391d6..5b1a6e0 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -36,12 +36,82 @@ type HrSyncResult = { updated: number deactivated: number } + sync_job_id?: number + changes_count?: number +} + +type SyncTriggerMode = 'manual' | 'scheduler' + +type DepartmentSnapshot = { + id: number + sourceGroupId: number + name: string + status: string + sourceParentGroupId: number | null + parentId: number | null + parentName: string | null +} + +type PersonSnapshot = { + id: number + sourceUserId: string + nickname: string + realName: string + status: string + position: string | null + department: string | null + mainDepartmentId: number | null + mainDepartmentName: string | null + departmentIds: number[] + departmentNames: string[] +} + +type HrSyncChangeDraft = { + entityType: 'person' | 'department' + entityKey: string + entityName: string + changeType: string + title: string + detail: string + beforeSnapshot?: Record | null + afterSnapshot?: Record | null +} + +type HrSyncSummary = { + title: string + content: string + counts: Record + previewChanges: Array<{ + title: string + detail: string + change_type: string + }> } const SOURCE_SYSTEM = 'ding_hr' +const REQUIRED_SQLSERVER_FIELDS = [ + 'SQLSERVER_HOST', + 'SQLSERVER_DATABASE', + 'SQLSERVER_USER', + 'SQLSERVER_PASSWORD' +] as const let schedulerStarted = false let schedulerRunning = false -let lastSchedulerRunKey = '' +const lastSchedulerRunKeys = new Set() + +const CHANGE_TYPE_LABELS: Record = { + person_created: '新人入职', + person_deactivated: '人员离职/停用', + person_reactivated: '人员重新启用', + person_main_department_changed: '主部门变动', + person_departments_changed: '关联部门变动', + person_position_changed: '岗位变动', + department_created: '新增部门', + department_renamed: '部门名称变动', + department_hierarchy_changed: '部门层级变动', + department_deactivated: '部门停用', + department_reactivated: '部门重新启用' +} const getConfiguredSet = (value: string, defaultValue: string) => new Set( @@ -51,6 +121,20 @@ const getConfiguredSet = (value: string, defaultValue: string) => .filter(Boolean) ) +export const getHrSyncConfigStatus = () => { + const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => { + const value = process.env[field] + return !value || !String(value).trim() + }) + + return { + enabled: process.env.HR_SYNC_ENABLED === 'true', + complete: missingFields.length === 0, + missingFields, + scheduleTimes: getConfiguredScheduleTimes() + } +} + const getSqlServerConfig = (): any => { const server = process.env.SQLSERVER_HOST const port = Number(process.env.SQLSERVER_PORT || '1433') @@ -60,8 +144,10 @@ const getSqlServerConfig = (): any => { const encrypt = process.env.SQLSERVER_ENCRYPT === 'true' const trustServerCertificate = process.env.SQLSERVER_TRUST_CERT !== 'false' - if (!server || !database || !user || !password) { - throw new Error('SQL Server 同步配置不完整,请设置 SQLSERVER_HOST / DATABASE / USER / PASSWORD') + const syncStatus = getHrSyncConfigStatus() + + if (!syncStatus.complete) { + throw new Error(`SQL Server 同步配置不完整,请设置:${syncStatus.missingFields.join('、')}`) } return { @@ -88,6 +174,30 @@ const safeTrim = (value: unknown) => { return text ? text : null } +const toJsonText = (value: unknown) => JSON.stringify(value ?? null) + +const normalizeNullableText = (value: string | null | undefined) => safeTrim(value) + +const areNumberArraysEqual = (left: number[], right: number[]) => + left.length === right.length && left.every((value, index) => value === right[index]) + +const formatNameList = (names: string[]) => (names.length > 0 ? names.join('、') : '未设置') + +const formatDepartmentName = (value: string | null | undefined) => value || '未设置' + +const inferTriggerMode = (operator: string): SyncTriggerMode => + operator.includes('scheduler') ? 'scheduler' : 'manual' + +const getConfiguredScheduleTimes = () => + Array.from( + new Set( + (process.env.HR_SYNC_SCHEDULES || process.env.HR_SYNC_SCHEDULE || '03:00') + .split(',') + .map((item) => item.trim()) + .filter((item) => /^\d{2}:\d{2}$/.test(item)) + ) + ) + const normalizePhone = (value: unknown) => { const text = safeTrim(value) if (!text) return null @@ -288,45 +398,524 @@ const filterRelevantDepartments = ( return allDepartments.filter((item) => scopedIds.has(item.sourceGroupId)) } -const syncDepartments = async (sourceDepartments: SourceDepartment[]) => { - const existing = await prisma.department.findMany({ - where: { source_system: SOURCE_SYSTEM }, +type LocalDepartmentMap = Map + +const getDepartmentSnapshotMap = async () => { + const departments = await prisma.department.findMany({ + where: { + source_system: SOURCE_SYSTEM, + source_group_id: { + not: null + } + }, select: { id: true, - source_group_id: true + name: true, + status: true, + parent_id: true, + source_group_id: true, + source_parent_group_id: true, + parent: { + select: { name: true } + } } }) - const existingIds = new Set(existing.map((item) => item.source_group_id).filter(Boolean) as number[]) + return new Map( + departments.map((department) => [ + department.source_group_id as number, + { + id: department.id, + sourceGroupId: department.source_group_id as number, + name: department.name, + status: department.status, + sourceParentGroupId: department.source_parent_group_id, + parentId: department.parent_id, + parentName: department.parent?.name || null + } + ]) + ) +} + +const getPersonSnapshotMap = async () => { + const persons = await prisma.person.findMany({ + where: { + source_system: SOURCE_SYSTEM, + source_userid: { + not: null + } + }, + select: { + id: true, + source_userid: true, + nickname: true, + real_name: true, + status: true, + position: true, + department: true, + main_department_id: true, + mainDepartment: { + select: { + id: true, + name: true + } + }, + personDepartments: { + select: { + department: { + select: { + id: true, + name: true + } + } + } + } + } + }) + + return new Map( + persons.map((person) => { + const relatedDepartments = person.personDepartments + .map((item) => item.department) + .filter(Boolean) + + if ( + person.mainDepartment && + !relatedDepartments.some((department) => department.id === person.mainDepartment?.id) + ) { + relatedDepartments.unshift(person.mainDepartment) + } + + const departmentIds = Array.from(new Set(relatedDepartments.map((department) => department.id))).sort( + (left, right) => left - right + ) + const departmentNames = Array.from( + new Set(relatedDepartments.map((department) => department.name).filter(Boolean)) + ) + + return [ + person.source_userid as string, + { + id: person.id, + sourceUserId: person.source_userid as string, + nickname: person.nickname, + realName: person.real_name, + status: person.status, + position: person.position, + department: person.mainDepartment?.name || person.department || departmentNames[0] || null, + mainDepartmentId: person.main_department_id, + mainDepartmentName: person.mainDepartment?.name || null, + departmentIds, + departmentNames + } + ] + }) + ) +} + +const buildSyncChange = (change: HrSyncChangeDraft) => change + +const diffDepartmentSnapshots = ( + beforeMap: Map, + afterMap: Map +) => { + const changes: HrSyncChangeDraft[] = [] + const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) => left - right) + + for (const key of keys) { + const before = beforeMap.get(key) + const after = afterMap.get(key) + + if (!before && after && after.status === 'active') { + changes.push( + buildSyncChange({ + entityType: 'department', + entityKey: String(key), + entityName: after.name, + changeType: 'department_created', + title: `新增部门:${after.name}`, + detail: `新部门已同步到系统,上级部门为 ${formatDepartmentName(after.parentName)}。`, + beforeSnapshot: null, + afterSnapshot: after + }) + ) + continue + } + + if (!before || !after) { + continue + } + + if (before.status !== after.status) { + changes.push( + buildSyncChange({ + entityType: 'department', + entityKey: String(key), + entityName: after.name, + changeType: after.status === 'inactive' ? 'department_deactivated' : 'department_reactivated', + title: `${after.status === 'inactive' ? '部门停用' : '部门重新启用'}:${after.name}`, + detail: + after.status === 'inactive' + ? '该部门已不在本次同步范围内,系统已自动停用。' + : '该部门重新进入同步范围,系统已重新启用。', + beforeSnapshot: before, + afterSnapshot: after + }) + ) + } + + if (before.name !== after.name) { + changes.push( + buildSyncChange({ + entityType: 'department', + entityKey: String(key), + entityName: after.name, + changeType: 'department_renamed', + title: `部门名称变动:${before.name} -> ${after.name}`, + detail: `部门名称已从“${before.name}”调整为“${after.name}”。`, + beforeSnapshot: before, + afterSnapshot: after + }) + ) + } + + if (before.sourceParentGroupId !== after.sourceParentGroupId) { + changes.push( + buildSyncChange({ + entityType: 'department', + entityKey: String(key), + entityName: after.name, + changeType: 'department_hierarchy_changed', + title: `部门层级变动:${after.name}`, + detail: `上级部门已从“${formatDepartmentName(before.parentName)}”调整为“${formatDepartmentName( + after.parentName + )}”。`, + beforeSnapshot: before, + afterSnapshot: after + }) + ) + } + } + + return changes +} + +const diffPersonSnapshots = ( + beforeMap: Map, + afterMap: Map +) => { + const changes: HrSyncChangeDraft[] = [] + const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) => + left.localeCompare(right) + ) + + for (const key of keys) { + const before = beforeMap.get(key) + const after = afterMap.get(key) + + if (!before && after && after.status === 'active') { + changes.push( + buildSyncChange({ + entityType: 'person', + entityKey: key, + entityName: after.realName, + changeType: 'person_created', + title: `新人入职:${after.realName}`, + detail: `主部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`, + beforeSnapshot: null, + afterSnapshot: after + }) + ) + continue + } + + if (!before || !after) { + continue + } + + if (before.status !== after.status) { + changes.push( + buildSyncChange({ + entityType: 'person', + entityKey: key, + entityName: after.realName, + changeType: after.status === 'inactive' ? 'person_deactivated' : 'person_reactivated', + title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${after.realName}`, + detail: + after.status === 'inactive' + ? '该人员已从本次同步名单中移除或状态变为停用,请行政确认是否需要处理资产交接。' + : '该人员重新进入同步名单,可重新参与借还流程。', + beforeSnapshot: before, + afterSnapshot: after + }) + ) + } + + if (before.status !== 'active' || after.status !== 'active') { + continue + } + + if (before.mainDepartmentId !== after.mainDepartmentId) { + changes.push( + buildSyncChange({ + entityType: 'person', + entityKey: key, + entityName: after.realName, + changeType: 'person_main_department_changed', + title: `主部门变动:${after.realName}`, + detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName( + after.mainDepartmentName + )}”。`, + beforeSnapshot: before, + afterSnapshot: after + }) + ) + } + + if (!areNumberArraysEqual(before.departmentIds, after.departmentIds)) { + changes.push( + buildSyncChange({ + entityType: 'person', + entityKey: key, + entityName: after.realName, + changeType: 'person_departments_changed', + title: `关联部门变动:${after.realName}`, + detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList( + after.departmentNames + )}”。`, + beforeSnapshot: before, + afterSnapshot: after + }) + ) + } + + if (normalizeNullableText(before.position) !== normalizeNullableText(after.position)) { + changes.push( + buildSyncChange({ + entityType: 'person', + entityKey: key, + entityName: after.realName, + changeType: 'person_position_changed', + title: `岗位变动:${after.realName}`, + detail: `岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。`, + beforeSnapshot: before, + afterSnapshot: after + }) + ) + } + } + + return changes +} + +const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary => { + const counts = changes.reduce>((acc, change) => { + acc[change.changeType] = (acc[change.changeType] || 0) + 1 + return acc + }, {}) + + if (changes.length === 0) { + return { + title: '组织同步完成', + content: '本次组织同步已完成,未发现需要行政处理的人员或部门变动。', + counts, + previewChanges: [] + } + } + + const summaryItems = Object.entries(counts) + .map(([changeType, count]) => ({ + label: CHANGE_TYPE_LABELS[changeType] || changeType, + count + })) + .sort((left, right) => right.count - left.count) + .map((item) => `${item.label} ${item.count} 项`) + + const previewChanges = changes.slice(0, 8).map((change) => ({ + title: change.title, + detail: change.detail, + change_type: change.changeType + })) + + const previewText = previewChanges + .map((change, index) => `${index + 1}. ${change.title}`) + .join('\n') + + return { + title: `组织同步发现 ${changes.length} 项变化`, + content: + `本次组织同步已完成,共发现 ${changes.length} 项变化。` + + `\n${summaryItems.join(',')}。` + + (previewText ? `\n重点变化:\n${previewText}` : ''), + counts, + previewChanges + } +} + +const persistHrSyncArtifacts = async ({ + operator, + triggerMode, + status, + result, + changes, + errorMessage +}: { + operator: string + triggerMode: SyncTriggerMode + status: 'success' | 'failed' + result: HrSyncResult | null + changes: HrSyncChangeDraft[] + errorMessage?: string +}) => { + const summary = + status === 'failed' + ? { + title: '组织同步失败', + content: `组织同步执行失败,请尽快检查同步配置或源库连接。错误信息:${errorMessage || '未知错误'}`, + counts: {}, + previewChanges: [] + } + : summarizeHrSyncChanges(changes) + + return prisma.$transaction(async (tx) => { + const syncJob = await tx.syncJob.create({ + data: { + sync_type: 'hr', + trigger_mode: triggerMode, + status, + operator, + summary: summary.content, + result_payload: toJsonText({ + result, + counts: summary.counts, + error_message: errorMessage || null, + changes_count: changes.length + }) + } + }) + + if (changes.length > 0) { + await tx.syncChange.createMany({ + data: changes.map((change) => ({ + sync_job_id: syncJob.id, + entity_type: change.entityType, + entity_key: change.entityKey, + entity_name: change.entityName, + change_type: change.changeType, + title: change.title, + detail: change.detail, + before_snapshot: toJsonText(change.beforeSnapshot), + after_snapshot: toJsonText(change.afterSnapshot) + })) + }) + } + + let notificationId: number | null = null + if (status === 'failed' || changes.length > 0) { + const notification = await tx.notification.create({ + data: { + title: summary.title, + content: summary.content, + type: 'hr_sync', + level: status === 'failed' ? 'error' : 'warning', + action_url: '/settings?tab=sync', + related_sync_job_id: syncJob.id, + metadata: toJsonText({ + status, + changes_count: changes.length, + counts: summary.counts, + preview_changes: summary.previewChanges + }) + } + }) + notificationId = notification.id + + const adminUsers = await tx.user.findMany({ + where: { + role: 'admin', + status: 'active' + }, + select: { id: true } + }) + + if (adminUsers.length > 0) { + await tx.userNotification.createMany({ + data: adminUsers.map((user) => ({ + user_id: user.id, + notification_id: notification.id + })), + skipDuplicates: true + }) + } + } + + return { + syncJobId: syncJob.id, + notificationId, + summary + } + }) +} + +const syncDepartments = async (sourceDepartments: SourceDepartment[]) => { + const now = new Date() + const existing = await prisma.department.findMany({ + select: { + id: true, + name: true, + source_group_id: true, + source_system: true + } + }) + + const existingBySourceGroupId = new Map( + existing + .filter((item) => item.source_group_id !== null) + .map((item) => [item.source_group_id as number, item]) + ) + const manualByName = new Map( + existing + .filter((item) => item.source_group_id === null && !item.source_system) + .map((item) => [item.name, item]) + ) + let created = 0 let updated = 0 for (const department of sourceDepartments) { - const exists = existingIds.has(department.sourceGroupId) + const matchedBySource = existingBySourceGroupId.get(department.sourceGroupId) + const matchedManual = matchedBySource ? null : manualByName.get(department.name) || null - await prisma.department.upsert({ - where: { source_group_id: department.sourceGroupId }, - update: { - name: department.name, - status: 'active', - source_parent_group_id: department.sourceParentGroupId, - source_system: SOURCE_SYSTEM, - last_synced_at: new Date() - }, - create: { - name: department.name, - status: 'active', - source_group_id: department.sourceGroupId, - source_parent_group_id: department.sourceParentGroupId, - source_system: SOURCE_SYSTEM, - last_synced_at: new Date() - } - }) + const payload = { + name: department.name, + status: 'active', + source_group_id: department.sourceGroupId, + source_parent_group_id: department.sourceParentGroupId, + source_system: SOURCE_SYSTEM, + last_synced_at: now + } - if (exists) { + if (matchedBySource || matchedManual) { + await prisma.department.update({ + where: { id: (matchedBySource || matchedManual)!.id }, + data: payload + }) updated++ + + if (matchedManual) { + existingBySourceGroupId.set(department.sourceGroupId, { + ...matchedManual, + source_group_id: department.sourceGroupId, + source_system: SOURCE_SYSTEM + }) + manualByName.delete(department.name) + } } else { + const createdDepartment = await prisma.department.create({ + data: payload + }) created++ + existingBySourceGroupId.set(department.sourceGroupId, createdDepartment) } } @@ -366,7 +955,7 @@ const syncDepartments = async (sourceDepartments: SourceDepartment[]) => { }, data: { status: 'inactive', - last_synced_at: new Date() + last_synced_at: now } }) @@ -378,13 +967,72 @@ const syncDepartments = async (sourceDepartments: SourceDepartment[]) => { } } -const resolveUniqueIdNumber = async (sourceUserId: string, idNumber: string | null) => { +const buildLocalDepartmentMap = async (): Promise => { + const departments = await prisma.department.findMany({ + where: { + source_system: SOURCE_SYSTEM, + source_group_id: { + not: null + } + }, + select: { + id: true, + name: true, + source_group_id: true + } + }) + + return new Map( + departments.map((item) => [ + item.source_group_id as number, + { + id: item.id, + name: item.name + } + ]) + ) +} + +const resolvePersonDepartments = ( + person: SourcePerson, + localDepartmentMap: LocalDepartmentMap +) => { + const relatedDepartments = parseSourceDeptIds(person.sourceDeptIds) + .map((deptId) => localDepartmentMap.get(deptId) || null) + .filter((item): item is { id: number; name: string } => Boolean(item)) + + const mainDepartment = + person.sourceMainDepartmentId !== null + ? localDepartmentMap.get(person.sourceMainDepartmentId) || null + : null + + if (mainDepartment && !relatedDepartments.some((item) => item.id === mainDepartment.id)) { + relatedDepartments.unshift(mainDepartment) + } + + const departmentNames = Array.from(new Set(relatedDepartments.map((item) => item.name))) + + return { + mainDepartmentId: mainDepartment?.id || null, + relatedDepartmentIds: Array.from(new Set(relatedDepartments.map((item) => item.id))), + displayDepartment: mainDepartment?.name || person.departmentName || departmentNames[0] || null + } +} + +const resolveUniqueIdNumber = async ( + sourceUserId: string, + idNumber: string | null, + targetPersonId?: number +) => { if (!idNumber) return null const conflict = await prisma.person.findFirst({ where: { id_number: idNumber, NOT: { - source_userid: sourceUserId + OR: [ + { source_userid: sourceUserId }, + ...(targetPersonId ? [{ id: targetPersonId }] : []) + ] } }, select: { id: true } @@ -393,65 +1041,110 @@ const resolveUniqueIdNumber = async (sourceUserId: string, idNumber: string | nu return conflict ? null : idNumber } -const syncPersons = async (sourcePersons: SourcePerson[]) => { +const syncPersons = async ( + sourcePersons: SourcePerson[], + localDepartmentMap: LocalDepartmentMap +) => { + const now = new Date() const existing = await prisma.person.findMany({ - where: { source_system: SOURCE_SYSTEM }, select: { id: true, - source_userid: true + source_userid: true, + id_number: true, + source_system: true } }) - const existingIds = new Set(existing.map((item) => item.source_userid).filter(Boolean) as string[]) + const existingBySourceUserId = new Map( + existing + .filter((item) => item.source_userid) + .map((item) => [item.source_userid as string, item]) + ) + const manualByIdNumber = new Map( + existing + .filter((item) => item.id_number && !item.source_userid) + .map((item) => [item.id_number as string, item]) + ) + let created = 0 let updated = 0 for (const person of sourcePersons) { - const normalizedIdNumber = await resolveUniqueIdNumber(person.sourceUserId, person.idNumber) - const exists = existingIds.has(person.sourceUserId) + const matchedBySource = existingBySourceUserId.get(person.sourceUserId) || null + const matchedManualByIdNumber = + !matchedBySource && person.idNumber ? manualByIdNumber.get(person.idNumber) || null : null + const targetPersonId = matchedBySource?.id || matchedManualByIdNumber?.id + const normalizedIdNumber = await resolveUniqueIdNumber( + person.sourceUserId, + person.idNumber, + targetPersonId + ) + const departmentBinding = resolvePersonDepartments(person, localDepartmentMap) - await prisma.person.upsert({ - where: { source_userid: person.sourceUserId }, - update: { - nickname: person.nickname, - real_name: person.realName, - id_number: normalizedIdNumber, - department: person.departmentName, - position: person.position, - phone: person.phone, - email: person.email, - status: person.status, - source_system: SOURCE_SYSTEM, - source_main_department_id: person.sourceMainDepartmentId, - source_dept_ids: person.sourceDeptIds, - manager_source_userid: person.managerSourceUserId, - source_employee_status: person.sourceEmployeeStatus, - last_synced_at: new Date() - }, - create: { - nickname: person.nickname, - real_name: person.realName, - id_number: normalizedIdNumber, - department: person.departmentName, - position: person.position, - phone: person.phone, - email: person.email, - status: person.status, - source_userid: person.sourceUserId, - source_system: SOURCE_SYSTEM, - source_main_department_id: person.sourceMainDepartmentId, - source_dept_ids: person.sourceDeptIds, - manager_source_userid: person.managerSourceUserId, - source_employee_status: person.sourceEmployeeStatus, - last_synced_at: new Date() + const payload = { + nickname: person.nickname, + real_name: person.realName, + id_number: normalizedIdNumber, + department: departmentBinding.displayDepartment, + main_department_id: departmentBinding.mainDepartmentId, + position: person.position, + phone: person.phone, + email: person.email, + status: person.status, + source_userid: person.sourceUserId, + source_system: SOURCE_SYSTEM, + source_main_department_id: person.sourceMainDepartmentId, + source_dept_ids: person.sourceDeptIds, + manager_source_userid: person.managerSourceUserId, + source_employee_status: person.sourceEmployeeStatus, + last_synced_at: now + } + + let syncedPersonId = targetPersonId || 0 + await prisma.$transaction(async (tx) => { + if (targetPersonId) { + await tx.person.update({ + where: { id: targetPersonId }, + data: payload + }) + } else { + const createdPerson = await tx.person.create({ + data: payload + }) + syncedPersonId = createdPerson.id + } + + await tx.personDepartment.deleteMany({ + where: { person_id: syncedPersonId } + }) + + if (departmentBinding.relatedDepartmentIds.length > 0) { + await tx.personDepartment.createMany({ + data: departmentBinding.relatedDepartmentIds.map((departmentId) => ({ + person_id: syncedPersonId, + department_id: departmentId + })), + skipDuplicates: true + }) } }) - if (exists) { + if (targetPersonId) { updated++ } else { created++ } + + existingBySourceUserId.set(person.sourceUserId, { + id: syncedPersonId, + source_userid: person.sourceUserId, + id_number: normalizedIdNumber, + source_system: SOURCE_SYSTEM + }) + + if (person.idNumber) { + manualByIdNumber.delete(person.idNumber) + } } const sourceUserIds = sourcePersons.map((item) => item.sourceUserId) @@ -464,7 +1157,7 @@ const syncPersons = async (sourcePersons: SourcePerson[]) => { }, data: { status: 'inactive', - last_synced_at: new Date() + last_synced_at: now } }) @@ -476,12 +1169,12 @@ const syncPersons = async (sourcePersons: SourcePerson[]) => { } } -const writeSyncLog = async (result: HrSyncResult, operator = 'system-sync') => { +const writeSyncLog = async (result: unknown, operator = 'system-sync', success = true) => { await prisma.operationLog.create({ data: { user_id: null, username: operator, - operation_type: '同步钉钉组织与人员', + operation_type: success ? '同步钉钉组织与人员' : '同步钉钉组织与人员失败', operation_details: JSON.stringify(result), ip_address: null } @@ -513,9 +1206,18 @@ export const previewHrSync = async () => { } } -export const runHrSync = async (operator = 'system-sync') => { - const pool = await sql.connect(getSqlServerConfig()) +export const runHrSync = async ( + operator = 'system-sync', + triggerMode: SyncTriggerMode = inferTriggerMode(operator) +) => { + let pool: any = null try { + const [beforeDepartmentMap, beforePersonMap] = await Promise.all([ + getDepartmentSnapshotMap(), + getPersonSnapshotMap() + ]) + + pool = await sql.connect(getSqlServerConfig()) const [allDepartments, persons] = await Promise.all([ getSourceDepartments(pool), getSourcePersons(pool) @@ -523,16 +1225,63 @@ export const runHrSync = async (operator = 'system-sync') => { const departments = filterRelevantDepartments(allDepartments, persons) const departmentResult = await syncDepartments(departments) - const personResult = await syncPersons(persons) + const localDepartmentMap = await buildLocalDepartmentMap() + const personResult = await syncPersons(persons, localDepartmentMap) const result = { departments: departmentResult, persons: personResult } - await writeSyncLog(result, operator) - return result + const [afterDepartmentMap, afterPersonMap] = await Promise.all([ + getDepartmentSnapshotMap(), + getPersonSnapshotMap() + ]) + const changes = [ + ...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap), + ...diffPersonSnapshots(beforePersonMap, afterPersonMap) + ] + const artifact = await persistHrSyncArtifacts({ + operator, + triggerMode, + status: 'success', + result, + changes + }) + const finalResult: HrSyncResult = { + ...result, + sync_job_id: artifact.syncJobId, + changes_count: changes.length + } + + await writeSyncLog(finalResult, operator, true) + return finalResult + } catch (error: any) { + try { + const artifact = await persistHrSyncArtifacts({ + operator, + triggerMode, + status: 'failed', + result: null, + changes: [], + errorMessage: error?.message || String(error) + }) + await writeSyncLog( + { + sync_job_id: artifact.syncJobId, + message: error?.message || String(error) + }, + operator, + false + ) + } catch (artifactError) { + console.error('[hr-sync] persist artifact failed:', artifactError) + } + + throw error } finally { - await pool.close() + if (pool) { + await pool.close() + } } } @@ -548,23 +1297,29 @@ export const startHrSyncScheduler = () => { return } - const scheduleTime = process.env.HR_SYNC_SCHEDULE || '03:00' + const syncStatus = getHrSyncConfigStatus() + if (!syncStatus.complete) { + console.log(`[hr-sync] scheduler skipped, missing config: ${syncStatus.missingFields.join(',')}`) + return + } + + const scheduleTimes = getConfiguredScheduleTimes() setInterval(async () => { const now = new Date() const hh = String(now.getHours()).padStart(2, '0') const mm = String(now.getMinutes()).padStart(2, '0') const currentTime = `${hh}:${mm}` - const runKey = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}` + const runKey = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}@${currentTime}` - if (currentTime !== scheduleTime || schedulerRunning || lastSchedulerRunKey === runKey) { + if (!scheduleTimes.includes(currentTime) || schedulerRunning || lastSchedulerRunKeys.has(runKey)) { return } schedulerRunning = true try { - await runHrSync('system-scheduler') - lastSchedulerRunKey = runKey + await runHrSync('system-scheduler', 'scheduler') + lastSchedulerRunKeys.add(runKey) console.log(`[hr-sync] completed at ${now.toISOString()}`) } catch (error) { console.error('[hr-sync] failed:', error) @@ -573,5 +1328,5 @@ export const startHrSyncScheduler = () => { } }, 60 * 1000) - console.log(`[hr-sync] scheduler started, schedule=${scheduleTime}`) + console.log(`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}`) } diff --git a/backend/src/utils/assetValue.ts b/backend/src/utils/assetValue.ts new file mode 100644 index 0000000..5b18595 --- /dev/null +++ b/backend/src/utils/assetValue.ts @@ -0,0 +1,238 @@ +import type { Prisma, PrismaClient } from '@prisma/client' + +type EquipmentClient = PrismaClient | Prisma.TransactionClient + +const NON_AVAILABLE_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏']) + +const toSafeNumber = (value: unknown) => { + if (value === null || value === undefined || value === '') { + return 0 + } + + const numericValue = Number(value) + return Number.isNaN(numericValue) ? 0 : numericValue +} + +const clampRemainingQuantity = (totalQuantity: number, remainingQuantity: number) => { + if (remainingQuantity < 0) return 0 + if (remainingQuantity > totalQuantity) return totalQuantity + return remainingQuantity +} + +export const calculateEquipmentValues = ({ + unitPrice, + totalQuantity, + remainingQuantity, + status +}: { + unitPrice: number + totalQuantity: number + remainingQuantity: number + status?: string | null +}) => { + const normalizedUnitPrice = toSafeNumber(unitPrice) + const normalizedTotalQuantity = Math.max(0, Math.floor(toSafeNumber(totalQuantity))) + const normalizedRemainingQuantity = clampRemainingQuantity( + normalizedTotalQuantity, + Math.floor(toSafeNumber(remainingQuantity)) + ) + const totalValue = normalizedUnitPrice * normalizedTotalQuantity + + if (status && NON_AVAILABLE_STATUSES.has(status)) { + return { + total_value: totalValue, + borrowed_value: 0, + available_value: 0 + } + } + + const borrowedQuantity = Math.max(normalizedTotalQuantity - normalizedRemainingQuantity, 0) + + return { + total_value: totalValue, + borrowed_value: normalizedUnitPrice * borrowedQuantity, + available_value: normalizedUnitPrice * normalizedRemainingQuantity + } +} + +export const sumBundleChildrenValue = ( + children: Array<{ + unit_price?: number | string | null + quantity?: number | string | null + }> +) => { + return children.reduce((sum, child) => { + const quantity = Math.max(1, Math.floor(toSafeNumber(child.quantity) || 1)) + return sum + toSafeNumber(child.unit_price) * quantity + }, 0) +} + +export const deriveParentBaseUnitPrice = (bundleTotalPrice: unknown, childrenTotalValue: number) => { + const normalizedBundleTotalPrice = toSafeNumber(bundleTotalPrice) + const normalizedChildrenTotalValue = toSafeNumber(childrenTotalValue) + + if (normalizedChildrenTotalValue > normalizedBundleTotalPrice) { + throw new Error('整机总价不能小于子资产总价,请先调整价格后再保存') + } + + return normalizedBundleTotalPrice - normalizedChildrenTotalValue +} + +export const calculateChildrenTotalValueFromRecords = ( + children: Array<{ unit_price?: unknown }> +) => { + return children.reduce((sum, child) => sum + toSafeNumber(child.unit_price), 0) +} + +export const refreshParentTotalValueWithChildren = async (client: EquipmentClient, parentId: number) => { + const parent = await client.equipment.findUnique({ + where: { id: parentId }, + include: { + children: { + select: { + unit_price: true + } + } + } + }) + + if (!parent) { + return + } + + const totalValueWithChildren = + toSafeNumber(parent.unit_price) + calculateChildrenTotalValueFromRecords(parent.children) + + await client.equipment.update({ + where: { id: parentId }, + data: { + total_value_with_children: totalValueWithChildren + } + }) +} + +export const syncMountedChildrenState = async ( + client: EquipmentClient, + parentId: number, + targetStatus: '可用' | '已借出' | '维护中' | '待报废' | '已报废' +) => { + const children = await client.equipment.findMany({ + where: { parent_id: parentId }, + select: { + id: true, + unit_price: true, + total_quantity: true + } + }) + + for (const child of children) { + const remainingQuantity = targetStatus === '已借出' ? 0 : Number(child.total_quantity) + const values = calculateEquipmentValues({ + unitPrice: Number(child.unit_price), + totalQuantity: Number(child.total_quantity), + remainingQuantity, + status: targetStatus + }) + + await client.equipment.update({ + where: { id: child.id }, + data: { + status: targetStatus, + remaining_quantity: remainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } + }) + } +} + +export const getDisplayEquipmentValue = (equipment: { + unit_price?: unknown + total_value_with_children?: unknown +}) => { + const totalValueWithChildren = toSafeNumber(equipment.total_value_with_children) + if (totalValueWithChildren > 0) { + return totalValueWithChildren + } + + return toSafeNumber(equipment.unit_price) +} + +export const getEquipmentDisplayTotalValue = (equipment: { + total_value?: unknown + total_value_with_children?: unknown +}) => { + const totalValueWithChildren = toSafeNumber(equipment.total_value_with_children) + if (totalValueWithChildren > 0) { + return totalValueWithChildren + } + + return toSafeNumber(equipment.total_value) +} + +export const getEquipmentDisplayItemValue = (equipment: { + unit_price?: unknown + total_quantity?: unknown + total_value?: unknown + total_value_with_children?: unknown +}) => { + const displayTotalValue = getEquipmentDisplayTotalValue(equipment) + const totalQuantity = Math.max(1, Math.floor(toSafeNumber(equipment.total_quantity) || 1)) + + if (displayTotalValue <= 0) { + return toSafeNumber(equipment.unit_price) + } + + return displayTotalValue / totalQuantity +} + +export const getEquipmentDisplayBorrowedValue = (equipment: { + borrowed_value?: unknown + total_value_with_children?: unknown + status?: unknown +}) => { + const totalValueWithChildren = toSafeNumber(equipment.total_value_with_children) + + if (totalValueWithChildren > 0) { + return equipment.status === '已借出' ? totalValueWithChildren : 0 + } + + return toSafeNumber(equipment.borrowed_value) +} + +export const getEquipmentDisplayAvailableValue = (equipment: { + available_value?: unknown + total_value_with_children?: unknown + status?: unknown +}) => { + const totalValueWithChildren = toSafeNumber(equipment.total_value_with_children) + + if (totalValueWithChildren > 0) { + return equipment.status === '可用' ? totalValueWithChildren : 0 + } + + return toSafeNumber(equipment.available_value) +} + +export const getEquipmentMountedChildrenValue = (equipment: { + total_value?: unknown + total_value_with_children?: unknown +}) => { + const displayTotalValue = getEquipmentDisplayTotalValue(equipment) + const ownTotalValue = toSafeNumber(equipment.total_value) + return Math.max(displayTotalValue - ownTotalValue, 0) +} + +export const sumSnapshotChildrenValue = (snapshot?: string | null) => { + if (!snapshot) { + return 0 + } + + try { + const children = JSON.parse(snapshot) as Array<{ unit_price?: unknown }> + return calculateChildrenTotalValueFromRecords(children) + } catch { + return 0 + } +} diff --git a/backend/src/utils/equipmentCode.ts b/backend/src/utils/equipmentCode.ts index b1be17d..081b7a6 100644 --- a/backend/src/utils/equipmentCode.ts +++ b/backend/src/utils/equipmentCode.ts @@ -1,4 +1,6 @@ -const EQUIPMENT_CODE_PATTERN = /^[A-Z0-9]{2,20}-\d{4}-\d{5}$/ +const CURRENT_EQUIPMENT_CODE_PATTERN = /^JS-[A-Z0-9]{1,20}-\d{4}$/ + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const normalizeShortName = (value?: string | null) => { const normalized = value?.trim().toUpperCase() || '' @@ -25,17 +27,17 @@ const resolveTypeShortName = async (equipmentType: string, prisma: any) => { /** * 生成资产编码 - * 格式: {类型简称}-{地址ID(4位)}-{序号(5位)} - * 例如: PC-0001-00001 + * 当前格式: JS-{类型简称}-{序号(4位)} + * 例如: JS-PC-0001 */ export const generateEquipmentCode = async ( equipmentType: string, prisma: any, - storageLocationId?: number | null + _storageLocationId?: number | null ): Promise => { const typeCode = await resolveTypeShortName(equipmentType, prisma) - const locationCode = String(storageLocationId || 0).padStart(4, '0') - const prefix = `${typeCode}-${locationCode}-` + const prefix = `JS-${typeCode}-` + const sequencePattern = new RegExp(`^${escapeRegExp(prefix)}(\\d{4})$`) const existingEquipment = await prisma.equipment.findMany({ where: { @@ -55,22 +57,22 @@ export const generateEquipmentCode = async ( let startSequence = 1 if (existingEquipment.length > 0) { const maxCode = existingEquipment[0].equipment_code - if (maxCode.startsWith(prefix)) { - const sequencePart = maxCode.substring(prefix.length) - const maxSequence = parseInt(sequencePart, 10) + const matched = maxCode.match(sequencePattern) + if (matched) { + const maxSequence = parseInt(matched[1], 10) if (!Number.isNaN(maxSequence)) { startSequence = maxSequence + 1 } } } - if (startSequence > 99999) { - throw new Error(`资产编码序号已溢出,该类型和地址的资产数量已超过 99999`) + if (startSequence > 9999) { + throw new Error(`资产编码序号已溢出,该类型资产数量已超过 9999`) } const maxRetries = 20 for (let attempt = 0; attempt < maxRetries; attempt++) { - const sequence = String(startSequence + attempt).padStart(5, '0') + const sequence = String(startSequence + attempt).padStart(4, '0') const code = `${prefix}${sequence}` const existing = await prisma.equipment.findUnique({ @@ -86,5 +88,5 @@ export const generateEquipmentCode = async ( } export const validateEquipmentCode = (code: string): boolean => { - return EQUIPMENT_CODE_PATTERN.test(code.trim().toUpperCase()) + return CURRENT_EQUIPMENT_CODE_PATTERN.test(code.trim().toUpperCase()) } diff --git a/certbot/www/.gitkeep b/certbot/www/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/certbot/www/.gitkeep @@ -0,0 +1 @@ + diff --git a/deploy.sh b/deploy.sh index 8d95bbc..99aaa1f 100644 --- a/deploy.sh +++ b/deploy.sh @@ -54,9 +54,11 @@ for container in "${LEGACY_CONTAINERS[@]}"; do echo " $container => $status" done -echo ">>> 仅更新固定资产系统容器..." -"${COMPOSE_CMD[@]}" down +echo ">>> 先构建固定资产系统镜像..." "${COMPOSE_CMD[@]}" build + +echo ">>> 镜像构建成功,开始更新固定资产系统容器..." +"${COMPOSE_CMD[@]}" down "${COMPOSE_CMD[@]}" up -d echo ">>> 等待服务启动..." diff --git a/docker-compose.yml b/docker-compose.yml index 65cf245..703af93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,7 @@ services: SQLSERVER_TRUST_CERT: ${SQLSERVER_TRUST_CERT:-true} HR_SYNC_ENABLED: ${HR_SYNC_ENABLED:-false} HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00} + HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-} HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-3} HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5} HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流} @@ -54,6 +55,7 @@ services: condition: service_healthy volumes: - backend_uploads:/app/uploads + - ./frontend/public/branding:/app/branding:ro # 前端服务 frontend: @@ -67,8 +69,13 @@ services: restart: always ports: - "80:80" + - "443:443" depends_on: - backend + volumes: + - /etc/letsencrypt:/etc/letsencrypt:ro + - ./certbot/www:/var/www/certbot + - ./frontend/public/branding:/usr/share/nginx/html/branding:ro volumes: mysql_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..5d26299 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +coverage +*.log +.env +.env.* +.git diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 640ea6e..59f9cba 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,20 +1,30 @@ server { listen 80; - server_name localhost; + server_name www.jsgdzc.cn jsgdzc.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 80 default_server; + server_name _; charset utf-8; root /usr/share/nginx/html; index index.html; - # Gzip压缩 gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - # 前端路由支持 location / { try_files $uri $uri/ /index.html; } - # API代理 location /api { proxy_pass http://backend:3000; proxy_http_version 1.1; @@ -26,14 +36,61 @@ server { proxy_cache_bypass $http_upgrade; } - # 上传文件代理 location /uploads { proxy_pass http://backend:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } - # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} + +server { + listen 443 ssl; + http2 on; + server_name www.jsgdzc.cn jsgdzc.cn; + charset utf-8; + root /usr/share/nginx/html; + index index.html; + + ssl_certificate /etc/letsencrypt/live/www.jsgdzc.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/www.jsgdzc.cn/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + location /uploads { + proxy_pass http://backend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ec0881..a5d5471 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "jsbarcode": "^3.12.1", "keycloak-js": "^23.0.7", "pinia": "^3.0.4", + "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", "vue": "^3.5.24", "vue-router": "^4.6.3", @@ -994,6 +995,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmmirror.com/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -3049,6 +3056,15 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/qr-scanner": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/qr-scanner/-/qr-scanner-1.4.2.tgz", + "integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==", + "license": "MIT", + "dependencies": { + "@types/offscreencanvas": "^2019.6.4" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index d9badb1..ea56edf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "jsbarcode": "^3.12.1", "keycloak-js": "^23.0.7", "pinia": "^3.0.4", + "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", "vue": "^3.5.24", "vue-router": "^4.6.3", diff --git a/frontend/public/branding/favicon.svg b/frontend/public/branding/favicon.svg new file mode 100644 index 0000000..0d61eb0 --- /dev/null +++ b/frontend/public/branding/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + 资 + diff --git a/frontend/public/branding/logo.svg b/frontend/public/branding/logo.svg new file mode 100644 index 0000000..ff8c806 --- /dev/null +++ b/frontend/public/branding/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + 资 + ASSET + diff --git a/frontend/public/branding/未标题-1.jpg b/frontend/public/branding/未标题-1.jpg new file mode 100644 index 0000000..466a0dc Binary files /dev/null and b/frontend/public/branding/未标题-1.jpg differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9a91297..521216d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/api/barcode.ts b/frontend/src/api/barcode.ts index 0d4c847..d5d182f 100644 --- a/frontend/src/api/barcode.ts +++ b/frontend/src/api/barcode.ts @@ -24,6 +24,11 @@ export const getEquipmentBatchForBarcode = async (equipmentIds: number[]) => { equipment_name: string equipment_model?: string equipment_type: string + created_at?: string + purchase_date?: string + department?: { + name: string + } | null storage_location_id?: number | null storageLocation?: { id: number @@ -38,4 +43,3 @@ export const getEquipmentBatchForBarcode = async (equipmentIds: number[]) => { return response.data } - diff --git a/frontend/src/api/batch.ts b/frontend/src/api/batch.ts index e5d3c3d..6f635a7 100644 --- a/frontend/src/api/batch.ts +++ b/frontend/src/api/batch.ts @@ -1,4 +1,5 @@ import request from './index' +import type { ApiResponse } from '@/types' export interface BatchResult { success_count: number @@ -19,7 +20,7 @@ export const batchBorrow = (data: { is_long_term?: boolean notes?: string }) => { - return request.post('/batch/borrow', data) + return request.post>('/batch/borrow', data).then((response) => response.data) } // 批量归还 @@ -28,7 +29,7 @@ export const batchReturn = (data: { return_condition?: string notes?: string }) => { - return request.post('/batch/return', data) + return request.post>('/batch/return', data).then((response) => response.data) } // 批量转移部门 @@ -36,5 +37,5 @@ export const batchTransfer = (data: { equipment_ids: number[] department_id: number }) => { - return request.post('/batch/transfer', data) + return request.post>('/batch/transfer', data).then((response) => response.data) } diff --git a/frontend/src/api/borrow.ts b/frontend/src/api/borrow.ts index 1fd55eb..a86d91c 100644 --- a/frontend/src/api/borrow.ts +++ b/frontend/src/api/borrow.ts @@ -146,6 +146,7 @@ export const getBorrowRecordList = async (params: { status?: string person_name?: string person_id?: number + keyword?: string }) => { const response = await api.get>>('/borrow/records', { params diff --git a/frontend/src/api/equipment.ts b/frontend/src/api/equipment.ts index 35027af..206b79e 100644 --- a/frontend/src/api/equipment.ts +++ b/frontend/src/api/equipment.ts @@ -1,7 +1,30 @@ import api from './index' import type { Equipment, PaginatedResponse, ApiResponse } from '@/types' -const transformEquipmentPayload = (data: Partial) => { +export interface EquipmentBundleChildPayload { + equipment_name: string + equipment_model?: string + equipment_type: string + code_generation_mode?: 'auto' | 'manual' + quantity?: number + unit_price?: number + purchase_date?: string + supplier?: string + description?: string + storage_location_id?: number + department_id?: number + asset_type_id?: number + notes?: string +} + +export type EquipmentCreatePayload = Omit, 'children'> & { + entry_mode?: 'single' | 'parent' | 'child' + children?: EquipmentBundleChildPayload[] +} + +export type EquipmentUpdatePayload = Omit, 'children'> + +const transformEquipmentPayload = (data: EquipmentCreatePayload | EquipmentUpdatePayload) => { const payload: Record = { ...data } if (payload.storageLocation) { payload.storage_location_id = payload.storageLocation?.id @@ -44,13 +67,13 @@ export const getEquipmentDetail = async (id: number) => { } // 新增资产 -export const createEquipment = async (data: Partial) => { +export const createEquipment = async (data: EquipmentCreatePayload) => { const response = await api.post>('/equipment', transformEquipmentPayload(data)) return response.data } // 更新资产 -export const updateEquipment = async (id: number, data: Partial) => { +export const updateEquipment = async (id: number, data: EquipmentUpdatePayload) => { const response = await api.put>(`/equipment/${id}`, transformEquipmentPayload(data)) return response.data } @@ -94,4 +117,3 @@ export const scanEquipment = async (equipmentCode: string) => { const response = await api.get>(`/equipment/scan/${equipmentCode}`) return response.data } - diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4217794..4ab7253 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -33,6 +33,9 @@ const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' // 响应拦截器 api.interceptors.response.use( (response: AxiosResponse) => { + if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') { + return response + } const data = response.data as ApiResponse if (data?.code === 200) { return response @@ -78,4 +81,3 @@ api.interceptors.response.use( ) export default api - diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 0000000..7a9e7d4 --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,30 @@ +import api from './index' +import type { ApiResponse, NotificationItem, PaginatedResponse } from '@/types' + +export interface NotificationListResponse extends PaginatedResponse { + unread_count: number +} + +export const getNotificationList = async (params?: { + page?: number + pageSize?: number + status?: 'all' | 'unread' +}) => { + const response = await api.get>('/notifications', { params }) + return response.data +} + +export const getUnreadNotificationCount = async () => { + const response = await api.get>('/notifications/unread-count') + return response.data +} + +export const markNotificationAsRead = async (id: number) => { + const response = await api.patch>(`/notifications/${id}/read`) + return response.data +} + +export const markAllNotificationsAsRead = async () => { + const response = await api.patch>('/notifications/read-all') + return response.data +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 4c849df..8ad2d10 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -19,8 +19,7 @@ export const getSiteConfig = async () => { return response.data } -export const updateSiteConfig = async (data: { site_name: string; theme_color?: string }) => { +export const updateSiteConfig = async (data: { site_name: string }) => { const response = await api.put>('/settings/site', data) return response.data } - diff --git a/frontend/src/api/sync.ts b/frontend/src/api/sync.ts new file mode 100644 index 0000000..ae24bc2 --- /dev/null +++ b/frontend/src/api/sync.ts @@ -0,0 +1,44 @@ +import api from './index' +import type { + ApiResponse, + HrSyncChangeItem, + HrSyncJobDetail, + HrSyncJobHistoryResponse, + HrSyncPreview, + HrSyncResult, + HrSyncStatus, + UpdateHrSyncChangeProcessingPayload +} from '@/types' + +export const getHrSyncStatus = async () => { + const response = await api.get>('/sync/hr/status') + return response.data +} + +export const previewHrSync = async () => { + const response = await api.get>('/sync/hr/preview') + return response.data +} + +export const runHrSync = async () => { + const response = await api.post>('/sync/hr/run') + return response.data +} + +export const getHrSyncJobs = async (params?: { + page?: number + pageSize?: number +}) => { + const response = await api.get>('/sync/hr/jobs', { params }) + return response.data +} + +export const getHrSyncJobDetail = async (id: number) => { + const response = await api.get>(`/sync/hr/jobs/${id}`) + return response.data +} + +export const updateHrSyncChangeProcessing = async (id: number, payload: UpdateHrSyncChangeProcessingPayload) => { + const response = await api.patch>(`/sync/hr/changes/${id}/processing`, payload) + return response.data +} diff --git a/frontend/src/components/BarcodePrint.vue b/frontend/src/components/BarcodePrint.vue index bf76ea0..804f2fe 100644 --- a/frontend/src/components/BarcodePrint.vue +++ b/frontend/src/components/BarcodePrint.vue @@ -1,36 +1,48 @@ diff --git a/frontend/src/components/GlobalSearch.vue b/frontend/src/components/GlobalSearch.vue index b0fcdd5..6ed0cc4 100644 --- a/frontend/src/components/GlobalSearch.vue +++ b/frontend/src/components/GlobalSearch.vue @@ -1,50 +1,50 @@ @@ -66,12 +66,14 @@ let searchTimer: ReturnType | null = null const handleSearch = () => { if (searchTimer) clearTimeout(searchTimer) - + + showResults.value = true + if (!keyword.value.trim()) { results.value = [] return } - + searchTimer = setTimeout(async () => { loading.value = true try { @@ -109,12 +111,12 @@ const goToDetail = (item: SearchResult) => { const getStatusClass = (status: string) => { const map: Record = { - '可用': 'bg-system-green/10 text-system-green', - '已借出': 'bg-system-orange/10 text-system-orange', - '维护中': 'bg-system-blue/10 text-system-blue', - '待报废': 'bg-system-yellow/10 text-system-yellow', - '已报废': 'bg-system-red/10 text-system-red' + '可用': 'badge-success', + '已借出': 'badge-warning', + '维护中': 'badge-neutral', + '待报废': 'badge-warning', + '已报废': 'badge-error' } - return map[status] || 'bg-system-gray-5 text-system-label-secondary' + return map[status] || 'badge-neutral' } diff --git a/frontend/src/components/InfoHint.vue b/frontend/src/components/InfoHint.vue new file mode 100644 index 0000000..1d256a1 --- /dev/null +++ b/frontend/src/components/InfoHint.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/MountHistory.vue b/frontend/src/components/MountHistory.vue index 4cbbdf9..ac1092f 100644 --- a/frontend/src/components/MountHistory.vue +++ b/frontend/src/components/MountHistory.vue @@ -1,53 +1,53 @@