diff --git a/.env.example b/.env.example index e72f7de..add7d1b 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,9 @@ HR_SYNC_ALLOWED_STATUS_VALUES=3 HR_SYNC_INACTIVE_STATUS_VALUES=5 HR_SYNC_EXCLUDED_DEPARTMENTS=供应商,快递公司,外部交流 HR_SYNC_EXCLUDED_NAME_KEYWORDS= +DINGTALK_APP_KEY= +DINGTALK_APP_SECRET= +DINGTALK_AGENT_ID= +DINGTALK_CORP_ID= +DINGTALK_PUBLIC_BASE_URL=https://asset.example.com +DINGTALK_SEND_ENABLED=false diff --git a/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/migration.sql b/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/migration.sql new file mode 100644 index 0000000..7e0d2c9 --- /dev/null +++ b/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/migration.sql @@ -0,0 +1,291 @@ +ALTER TABLE `user` + ADD COLUMN `person_id` INT NULL; + +CREATE UNIQUE INDEX `user_person_id_key` + ON `user`(`person_id`); + +ALTER TABLE `user` + ADD CONSTRAINT `user_person_id_fkey` + FOREIGN KEY (`person_id`) REFERENCES `person`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE; + +ALTER TABLE `disposal_record` + ADD COLUMN `completed_by` INT NULL, + ADD COLUMN `pre_status` VARCHAR(20) NOT NULL DEFAULT '可用', + ADD COLUMN `reject_reason` TEXT NULL, + ADD COLUMN `completion_notes` TEXT NULL, + ADD COLUMN `reminded_at` DATETIME(3) NULL; + +CREATE INDEX `disposal_record_applied_by_created_at_idx` + ON `disposal_record`(`applied_by`, `created_at`); +CREATE INDEX `disposal_record_approved_by_approved_at_idx` + ON `disposal_record`(`approved_by`, `approved_at`); +CREATE INDEX `disposal_record_completed_by_completed_at_idx` + ON `disposal_record`(`completed_by`, `completed_at`); + +ALTER TABLE `disposal_record` + ADD CONSTRAINT `disposal_record_completed_by_fkey` + FOREIGN KEY (`completed_by`) REFERENCES `user`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE; + +CREATE TABLE `disposal_item` ( + `id` INT NOT NULL AUTO_INCREMENT, + `disposal_id` INT NOT NULL, + `equipment_id` INT NOT NULL, + `equipment_code` VARCHAR(50) NOT NULL, + `equipment_name` VARCHAR(100) NOT NULL, + `parent_equipment_id` INT NULL, + `parent_equipment_code` VARCHAR(50) NULL, + `pre_status` VARCHAR(20) NOT NULL, + `snapshot_json` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `disposal_item_disposal_id_equipment_id_key`(`disposal_id`, `equipment_id`), + INDEX `disposal_item_equipment_id_idx`(`equipment_id`), + INDEX `disposal_item_parent_equipment_id_idx`(`parent_equipment_id`), + PRIMARY KEY (`id`), + CONSTRAINT `disposal_item_disposal_id_fkey` + FOREIGN KEY (`disposal_id`) REFERENCES `disposal_record`(`id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `disposal_item_equipment_id_fkey` + FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `disposal_action` ( + `id` INT NOT NULL AUTO_INCREMENT, + `disposal_id` INT NOT NULL, + `action_type` VARCHAR(30) NOT NULL, + `operator_id` INT NULL, + `from_status` VARCHAR(20) NULL, + `to_status` VARCHAR(20) NULL, + `detail` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `disposal_action_disposal_id_created_at_idx`(`disposal_id`, `created_at`), + INDEX `disposal_action_operator_id_idx`(`operator_id`), + PRIMARY KEY (`id`), + CONSTRAINT `disposal_action_disposal_id_fkey` + FOREIGN KEY (`disposal_id`) REFERENCES `disposal_record`(`id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `disposal_action_operator_id_fkey` + FOREIGN KEY (`operator_id`) REFERENCES `user`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `asset_transfer_record` ( + `id` INT NOT NULL AUTO_INCREMENT, + `root_equipment_id` INT NOT NULL, + `action_type` VARCHAR(20) NOT NULL, + `from_department_id` INT NULL, + `from_department_name` VARCHAR(100) NULL, + `to_department_id` INT NULL, + `to_department_name` VARCHAR(100) NULL, + `reason` TEXT NULL, + `operated_by` INT NOT NULL, + `operated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `asset_transfer_record_root_equipment_id_operated_at_idx`(`root_equipment_id`, `operated_at`), + INDEX `asset_transfer_record_operated_by_idx`(`operated_by`), + INDEX `asset_transfer_record_action_type_operated_at_idx`(`action_type`, `operated_at`), + PRIMARY KEY (`id`), + CONSTRAINT `asset_transfer_record_root_equipment_id_fkey` + FOREIGN KEY (`root_equipment_id`) REFERENCES `equipment`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE, + CONSTRAINT `asset_transfer_record_operated_by_fkey` + FOREIGN KEY (`operated_by`) REFERENCES `user`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `asset_transfer_item` ( + `id` INT NOT NULL AUTO_INCREMENT, + `transfer_id` INT NOT NULL, + `equipment_id` INT NOT NULL, + `equipment_code` VARCHAR(50) NOT NULL, + `equipment_name` VARCHAR(100) NOT NULL, + `parent_equipment_id` INT NULL, + `from_department_id` INT NULL, + `to_department_id` INT NULL, + `storage_location_id` INT NULL, + `snapshot_status` VARCHAR(20) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `asset_transfer_item_transfer_id_equipment_id_key`(`transfer_id`, `equipment_id`), + INDEX `asset_transfer_item_equipment_id_created_at_idx`(`equipment_id`, `created_at`), + INDEX `asset_transfer_item_parent_equipment_id_idx`(`parent_equipment_id`), + PRIMARY KEY (`id`), + CONSTRAINT `asset_transfer_item_transfer_id_fkey` + FOREIGN KEY (`transfer_id`) REFERENCES `asset_transfer_record`(`id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `asset_transfer_item_equipment_id_fkey` + FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `dingtalk_delivery` ( + `id` INT NOT NULL AUTO_INCREMENT, + `idempotency_key` VARCHAR(191) NOT NULL, + `event_type` VARCHAR(50) NOT NULL, + `business_type` VARCHAR(30) NOT NULL, + `business_id` INT NOT NULL, + `recipient_user_id` INT NOT NULL, + `recipient_dingtalk_userid` VARCHAR(100) NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'pending', + `attempt_count` INT NOT NULL DEFAULT 0, + `max_attempts` INT NOT NULL DEFAULT 5, + `next_attempt_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `lease_owner` VARCHAR(100) NULL, + `lease_expires_at` DATETIME(3) NULL, + `dingtalk_task_id` VARCHAR(100) NULL, + `payload_json` TEXT NOT NULL, + `last_error_code` VARCHAR(100) NULL, + `last_error_message` TEXT NULL, + `sent_at` DATETIME(3) NULL, + `read_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `dingtalk_delivery_idempotency_key_key`(`idempotency_key`), + INDEX `dingtalk_delivery_status_next_attempt_at_idx`(`status`, `next_attempt_at`), + INDEX `dingtalk_delivery_lease_expires_at_idx`(`lease_expires_at`), + INDEX `dingtalk_delivery_business_type_business_id_idx`(`business_type`, `business_id`), + INDEX `dingtalk_delivery_recipient_user_id_created_at_idx`(`recipient_user_id`, `created_at`), + INDEX `dingtalk_delivery_dingtalk_task_id_idx`(`dingtalk_task_id`), + PRIMARY KEY (`id`), + CONSTRAINT `dingtalk_delivery_recipient_user_id_fkey` + FOREIGN KEY (`recipient_user_id`) REFERENCES `user`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +INSERT INTO `disposal_item` ( + `disposal_id`, + `equipment_id`, + `equipment_code`, + `equipment_name`, + `parent_equipment_id`, + `parent_equipment_code`, + `pre_status`, + `snapshot_json` +) +SELECT + disposal.`id`, + equipment.`id`, + equipment.`equipment_code`, + equipment.`equipment_name`, + equipment.`parent_id`, + parent.`equipment_code`, + disposal.`pre_status`, + JSON_OBJECT( + 'equipment_code', equipment.`equipment_code`, + 'equipment_name', equipment.`equipment_name`, + 'equipment_model', equipment.`equipment_model`, + 'equipment_type', equipment.`equipment_type`, + 'department_id', equipment.`department_id`, + 'storage_location_id', equipment.`storage_location_id`, + 'parent_id', equipment.`parent_id` + ) +FROM `disposal_record` disposal +INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id` +LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id` +WHERE NOT EXISTS ( + SELECT 1 + FROM `disposal_item` existing + WHERE existing.`disposal_id` = disposal.`id` + AND existing.`equipment_id` = equipment.`id` +); + +-- 旧版报废申请会将根资产及当时挂载的后代统一进入待报废, +-- 驳回时又统一恢复为“可用”。因此只对仍未完成的历史单按当前关系递归补录, +-- 后代 pre_status 按旧版可逆行为记为“可用”;已驳回/已完成单不猜测后续可能已变化的挂载关系。 +INSERT INTO `disposal_item` ( + `disposal_id`, + `equipment_id`, + `equipment_code`, + `equipment_name`, + `parent_equipment_id`, + `parent_equipment_code`, + `pre_status`, + `snapshot_json` +) +WITH RECURSIVE `active_disposal_tree` AS ( + SELECT + disposal.`id` AS `disposal_id`, + equipment.`id` AS `equipment_id`, + equipment.`parent_id` AS `parent_equipment_id`, + 0 AS `depth`, + CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path` + FROM `disposal_record` disposal + INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id` + WHERE disposal.`status` IN ('pending', 'approved') + + UNION ALL + + SELECT + tree.`disposal_id`, + child.`id`, + child.`parent_id`, + tree.`depth` + 1, + CONCAT(tree.`visited_path`, child.`id`, '/') + FROM `active_disposal_tree` tree + INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id` + WHERE tree.`depth` < 100 + AND LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) = 0 +) +SELECT + tree.`disposal_id`, + equipment.`id`, + equipment.`equipment_code`, + equipment.`equipment_name`, + equipment.`parent_id`, + parent.`equipment_code`, + '可用', + JSON_OBJECT( + 'equipment_code', equipment.`equipment_code`, + 'equipment_name', equipment.`equipment_name`, + 'equipment_model', equipment.`equipment_model`, + 'equipment_type', equipment.`equipment_type`, + 'department_id', equipment.`department_id`, + 'storage_location_id', equipment.`storage_location_id`, + 'parent_id', equipment.`parent_id` + ) +FROM `active_disposal_tree` tree +INNER JOIN `equipment` equipment ON equipment.`id` = tree.`equipment_id` +LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id` +WHERE tree.`depth` > 0 + AND NOT EXISTS ( + SELECT 1 + FROM `disposal_item` existing + WHERE existing.`disposal_id` = tree.`disposal_id` + AND existing.`equipment_id` = tree.`equipment_id` + ); + +INSERT INTO `disposal_action` ( + `disposal_id`, + `action_type`, + `operator_id`, + `from_status`, + `to_status`, + `detail`, + `created_at` +) +SELECT + disposal.`id`, + 'legacy_imported', + disposal.`applied_by`, + NULL, + disposal.`status`, + '历史报废单迁移补录', + disposal.`created_at` +FROM `disposal_record` disposal; diff --git a/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql b/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql new file mode 100644 index 0000000..30b7ea2 --- /dev/null +++ b/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql @@ -0,0 +1,58 @@ +-- 只读核查:在执行迁移前,列出所有未完成的历史报废单。 +-- 如查询有结果,应先由业务负责人确认当前挂载树是否就是本次报废范围。 +SELECT + disposal.`id` AS `disposal_id`, + disposal.`status`, + disposal.`equipment_id` AS `root_equipment_id`, + disposal.`equipment_code` AS `root_equipment_code`, + disposal.`applied_at`, + disposal.`applied_by` +FROM `disposal_record` disposal +WHERE disposal.`status` IN ('pending', 'approved') +ORDER BY disposal.`applied_at`, disposal.`id`; + +-- 只读核查:展开未完成报废单的当前资产树。 +-- cycle_detected = 1 表示存在环;depth = 100 表示已达自动补录上限,两者都必须先修复数据。 +WITH RECURSIVE `active_disposal_tree` AS ( + SELECT + disposal.`id` AS `disposal_id`, + equipment.`id` AS `equipment_id`, + equipment.`equipment_code`, + equipment.`equipment_name`, + equipment.`parent_id`, + equipment.`status` AS `current_status`, + 0 AS `depth`, + CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`, + 0 AS `cycle_detected` + FROM `disposal_record` disposal + INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id` + WHERE disposal.`status` IN ('pending', 'approved') + + UNION ALL + + SELECT + tree.`disposal_id`, + child.`id`, + child.`equipment_code`, + child.`equipment_name`, + child.`parent_id`, + child.`status`, + tree.`depth` + 1, + CONCAT(tree.`visited_path`, child.`id`, '/'), + LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) > 0 + FROM `active_disposal_tree` tree + INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id` + WHERE tree.`depth` < 100 + AND tree.`cycle_detected` = 0 +) +SELECT + `disposal_id`, + `equipment_id`, + `equipment_code`, + `equipment_name`, + `parent_id`, + `current_status`, + `depth`, + `cycle_detected` +FROM `active_disposal_tree` +ORDER BY `disposal_id`, `depth`, `equipment_id`; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5339c3e..54c08ba 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -97,9 +97,12 @@ model Equipment { mountHistories MountHistory[] // 报废记录关联 disposalRecords DisposalRecord[] + disposalItems DisposalItem[] // 维修记录关联 maintenanceRecords MaintenanceRecord[] inventoryTaskItems InventoryTaskItem[] + transferRootRecords AssetTransferRecord[] @relation("AssetTransferRoot") + transferItems AssetTransferItem[] @@index([equipment_code]) @@index([equipment_type]) @@ -152,6 +155,7 @@ model User { phone String? @db.VarChar(20) role String @default("user") @db.VarChar(20) status String @default("active") @db.VarChar(20) + person_id Int? @unique last_login_at DateTime? @db.DateTime created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -162,6 +166,8 @@ model User { // 报废记录关联 disposal_applied DisposalRecord[] @relation("DisposalApplier") disposal_approved DisposalRecord[] @relation("DisposalApprover") + disposal_completed DisposalRecord[] @relation("DisposalCompleter") + disposal_actions DisposalAction[] @relation("DisposalActionOperator") // 维修记录关联 maintenance_records MaintenanceRecord[] notifications UserNotification[] @@ -170,6 +176,9 @@ model User { inventory_actions InventoryTaskAction[] @relation("InventoryTaskActionOperator") print_terminal_pair_codes PrintTerminalPairCode[] @relation("PrintTerminalPairCodeCreator") requested_print_jobs PrintJob[] @relation("PrintJobRequester") + asset_transfers_operated AssetTransferRecord[] @relation("AssetTransferOperator") + dingtalk_deliveries DingTalkDelivery[] @relation("DingTalkDeliveryRecipient") + person Person? @relation(fields: [person_id], references: [id], onDelete: SetNull) @@index([role]) @@index([status]) @@ -295,6 +304,7 @@ model Person { personDepartments PersonDepartment[] inventoryTaskParticipants InventoryTaskParticipant[] inventoryTaskItems InventoryTaskItem[] @relation("InventoryTaskItemSnapshotPerson") + user User? @@index([nickname]) @@index([real_name]) @@ -607,6 +617,11 @@ model DisposalRecord { approved_by Int? // 审批人 approved_at DateTime? completed_at DateTime? // 完成时间 + completed_by Int? + pre_status String @default("可用") @db.VarChar(20) + reject_reason String? @db.Text + completion_notes String? @db.Text + reminded_at DateTime? notes String? @db.Text created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -614,12 +629,135 @@ model DisposalRecord { equipment Equipment @relation(fields: [equipment_id], references: [id]) applier User @relation("DisposalApplier", fields: [applied_by], references: [id]) approver User? @relation("DisposalApprover", fields: [approved_by], references: [id]) + completer User? @relation("DisposalCompleter", fields: [completed_by], references: [id]) + items DisposalItem[] + actions DisposalAction[] @@index([equipment_id]) @@index([status]) + @@index([applied_by, created_at]) + @@index([approved_by, approved_at]) + @@index([completed_by, completed_at]) @@map("disposal_record") } +model DisposalItem { + id Int @id @default(autoincrement()) + disposal_id Int + equipment_id Int + equipment_code String @db.VarChar(50) + equipment_name String @db.VarChar(100) + parent_equipment_id Int? + parent_equipment_code String? @db.VarChar(50) + pre_status String @db.VarChar(20) + snapshot_json String? @db.Text + created_at DateTime @default(now()) + + disposal DisposalRecord @relation(fields: [disposal_id], references: [id], onDelete: Cascade) + equipment Equipment @relation(fields: [equipment_id], references: [id]) + + @@unique([disposal_id, equipment_id]) + @@index([equipment_id]) + @@index([parent_equipment_id]) + @@map("disposal_item") +} + +model DisposalAction { + id Int @id @default(autoincrement()) + disposal_id Int + action_type String @db.VarChar(30) + operator_id Int? + from_status String? @db.VarChar(20) + to_status String? @db.VarChar(20) + detail String? @db.Text + created_at DateTime @default(now()) + + disposal DisposalRecord @relation(fields: [disposal_id], references: [id], onDelete: Cascade) + operator User? @relation("DisposalActionOperator", fields: [operator_id], references: [id], onDelete: SetNull) + + @@index([disposal_id, created_at]) + @@index([operator_id]) + @@map("disposal_action") +} + +model AssetTransferRecord { + id Int @id @default(autoincrement()) + root_equipment_id Int + action_type String @db.VarChar(20) + from_department_id Int? + from_department_name String? @db.VarChar(100) + to_department_id Int? + to_department_name String? @db.VarChar(100) + reason String? @db.Text + operated_by Int + operated_at DateTime @default(now()) + created_at DateTime @default(now()) + + rootEquipment Equipment @relation("AssetTransferRoot", fields: [root_equipment_id], references: [id]) + operator User @relation("AssetTransferOperator", fields: [operated_by], references: [id]) + items AssetTransferItem[] + + @@index([root_equipment_id, operated_at]) + @@index([operated_by]) + @@index([action_type, operated_at]) + @@map("asset_transfer_record") +} + +model AssetTransferItem { + id Int @id @default(autoincrement()) + transfer_id Int + equipment_id Int + equipment_code String @db.VarChar(50) + equipment_name String @db.VarChar(100) + parent_equipment_id Int? + from_department_id Int? + to_department_id Int? + storage_location_id Int? + snapshot_status String @db.VarChar(20) + created_at DateTime @default(now()) + + transfer AssetTransferRecord @relation(fields: [transfer_id], references: [id], onDelete: Cascade) + equipment Equipment @relation(fields: [equipment_id], references: [id]) + + @@unique([transfer_id, equipment_id]) + @@index([equipment_id, created_at]) + @@index([parent_equipment_id]) + @@map("asset_transfer_item") +} + +model DingTalkDelivery { + id Int @id @default(autoincrement()) + idempotency_key String @unique @db.VarChar(191) + event_type String @db.VarChar(50) + business_type String @db.VarChar(30) + business_id Int + recipient_user_id Int + recipient_dingtalk_userid String? @db.VarChar(100) + status String @default("pending") @db.VarChar(20) + attempt_count Int @default(0) + max_attempts Int @default(5) + next_attempt_at DateTime @default(now()) + lease_owner String? @db.VarChar(100) + lease_expires_at DateTime? + dingtalk_task_id String? @db.VarChar(100) + payload_json String @db.Text + last_error_code String? @db.VarChar(100) + last_error_message String? @db.Text + sent_at DateTime? + read_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + recipient User @relation("DingTalkDeliveryRecipient", fields: [recipient_user_id], references: [id]) + + @@index([status, next_attempt_at]) + @@index([lease_expires_at]) + @@index([business_type, business_id]) + @@index([recipient_user_id, created_at]) + @@index([dingtalk_task_id]) + @@map("dingtalk_delivery") +} + // 维修记录 model MaintenanceRecord { id Int @id @default(autoincrement()) diff --git a/backend/scripts/__tests__/deployPrecheckSafety.test.js b/backend/scripts/__tests__/deployPrecheckSafety.test.js new file mode 100644 index 0000000..9bfc118 --- /dev/null +++ b/backend/scripts/__tests__/deployPrecheckSafety.test.js @@ -0,0 +1,59 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const deployPath = path.resolve(__dirname, '../../../deploy.sh') +const deployScript = fs.readFileSync(deployPath, 'utf8') + +test('标准部署在 build/up 前必须通过报废迁移预检', () => { + const gateCall = deployScript.lastIndexOf('\nrun_disposal_migration_precheck\n') + const buildCall = deployScript.indexOf('"${COMPOSE_CMD[@]}" build') + const upCall = deployScript.indexOf('"${COMPOSE_CMD[@]}" up -d --remove-orphans') + + assert.ok(gateCall > 0, '部署脚本必须调用迁移前预检') + assert.ok(buildCall > gateCall, '预检必须早于镜像构建') + assert.ok(upCall > gateCall, '预检必须早于容器更新/迁移') + assert.match( + deployScript, + /backend\/prisma\/migrations\/20260714_asset_permissions_disposal_dingtalk\/precheck\.sql/ + ) +}) + +test('预检只连接固定资产 MySQL,不在命令行暴露密码', () => { + assert.match(deployScript, /MYSQL_CONTAINER="equipment-mysql"/) + assert.match(deployScript, /--defaults-extra-file="\$defaults_file"/) + assert.doesNotMatch(deployScript, /mysql\s+[^\n]*--password=/) + assert.doesNotMatch(deployScript, /shebeiguanli|\/opt\/shebeiguanli/) + assert.doesNotMatch( + deployScript, + /LEGACY_CONTAINERS|record_legacy_container_statuses|verify_legacy_container_statuses/ + ) +}) + +test('无法确认或存在未核对报废单时默认阻断,仅允许本次显式确认', () => { + assert.ok( + deployScript.includes( + "SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;" + ), + '预检必须稳定读取待审批和已审批报废单的指纹字段' + ) + assert.match(deployScript, /printf '%s\\n' "\$active_rows" \| sha256sum/) + assert.match( + deployScript, + /CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "\$active_fingerprint"/ + ) + assert.match(deployScript, /已默认阻止部署/) +}) + +test('未完成报废资产树存在环或深度达到 100 时禁止 override', () => { + const anomalyBlock = deployScript.indexOf('if [ "$tree_anomaly_count" -gt 0 ]') + const overrideCheck = deployScript.indexOf( + 'if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]' + ) + + assert.match(deployScript, /WHERE cycle_detected = 1 OR depth >= 100;/) + assert.ok(anomalyBlock > 0, '必须对资产树异常数量做硬阻断') + assert.ok(overrideCheck > anomalyBlock, '资产树异常阻断必须早于指纹 override') + assert.match(deployScript, /不允许通过确认指纹绕过/) +}) diff --git a/backend/src/controllers/__tests__/batchController.return-concurrency.test.ts b/backend/src/controllers/__tests__/batchController.return-concurrency.test.ts new file mode 100644 index 0000000..c28b98f --- /dev/null +++ b/backend/src/controllers/__tests__/batchController.return-concurrency.test.ts @@ -0,0 +1,307 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { batchReturn } from '../batchController' + +const initialUpdatedAt = new Date('2026-07-14T05:00:00.000Z') +const mountedUpdatedAt = new Date('2026-07-14T05:00:01.000Z') +const borrowedRoot = { + id: 30, + equipment_code: 'JS-PC-0030', + equipment_name: '借出中主机', + parent_id: null, + department_id: 3, + storage_location_id: 1, + status: '已借出', + mount_status: 'none', + total_quantity: 1, + remaining_quantity: 0, + unit_price: 1000, + updated_at: initialUpdatedAt +} +// 父节点快照故意保持不变,确保测试必须靠 current read 发现新子件。 +const currentRoot = { ...borrowedRoot } +const newlyMountedChild = { + id: 31, + equipment_code: 'JS-RAM-0031', + equipment_name: '新挂载内存', + parent_id: borrowedRoot.id, + department_id: 3, + storage_location_id: 1, + status: '已借出', + mount_status: 'mounted', + total_quantity: 1, + remaining_quantity: 0, + unit_price: 0, + updated_at: mountedUpdatedAt +} + +test('RR 旧快照漏掉并发新配件时,归还用 current read 中止且零业务/状态写入', async () => { + const originalEquipmentFindUnique = prisma.equipment.findUnique + const originalBorrowFindMany = prisma.borrowRecord.findMany + const originalTransaction = prisma.$transaction + let mountedAfterSnapshot = false + let movementWriteCount = 0 + let borrowWriteCount = 0 + let equipmentWriteCount = 0 + const lockQueries: any[] = [] + const currentReadQueries: any[] = [] + + ;(prisma.equipment.findUnique as any) = async () => borrowedRoot + ;(prisma.borrowRecord.findMany as any) = async () => [{ + id: 301, + equipment_id: borrowedRoot.id, + status: '已借出' + }] + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) { + lockQueries.push(query) + mountedAfterSnapshot = true + return [] + } + currentReadQueries.push(query) + if (query.sql.includes('WHERE equipment.id = ?')) return [currentRoot] + if (query.sql.includes('WHERE equipment.parent_id IN')) { + return mountedAfterSnapshot && query.values.includes(borrowedRoot.id) + ? [newlyMountedChild] + : [] + } + return [] + }, + equipment: { + findUnique: async () => borrowedRoot, + // 故意固定为 RR 事务初始快照,普通复读仍看不到新配件。 + findMany: async () => [], + updateMany: async () => { + equipmentWriteCount += 1 + return { count: 1 } + }, + update: async () => { + equipmentWriteCount += 1 + return borrowedRoot + } + }, + borrowRecord: { + updateMany: async () => { + borrowWriteCount += 1 + return { count: 1 } + } + }, + assetTransferRecord: { + create: async () => { + movementWriteCount += 1 + return { id: 1, items: [] } + } + } + }) + + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 6, role: 'it', name: '测试 IT' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + + try { + await batchReturn( + { + body: { + equipment_ids: [borrowedRoot.id], + return_condition: '正常' + } + } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.equal(state.body.data.success_count, 0) + assert.equal(state.body.data.failed_count, 1) + assert.match(state.body.data.results[0].message, /资产树状态或挂载关系已变化/) + assert.match(lockQueries[0].sql, /IN \(\?\) ORDER BY id FOR UPDATE/) + assert.deepEqual(lockQueries[0].values, [borrowedRoot.id]) + assert.match(currentReadQueries[0].sql, /WHERE equipment\.id = \?/) + assert.deepEqual(currentReadQueries[0].values, [borrowedRoot.id]) + assert.match(currentReadQueries[1].sql, /WHERE equipment\.parent_id IN \(\?\)/) + assert.deepEqual(currentReadQueries[1].values, [borrowedRoot.id]) + assert.equal(movementWriteCount, 0) + assert.equal(borrowWriteCount, 0) + assert.equal(equipmentWriteCount, 0) + } finally { + ;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique + ;(prisma.borrowRecord.findMany as any) = originalBorrowFindMany + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('总量 2 并发借出 B 先提交后归还 A,使用锁后库存恢复为 1 且配件仍保持已借出', async () => { + const originalEquipmentFindUnique = prisma.equipment.findUnique + const originalBorrowFindMany = prisma.borrowRecord.findMany + const originalTransaction = prisma.$transaction + const originalOperationLogCreate = prisma.operationLog.create + const outsideEquipment = { + ...borrowedRoot, + id: 40, + equipment_code: 'JS-PC-0040', + total_quantity: 2, + remaining_quantity: 1, + status: '可用', + department_id: 9, + updated_at: initialUpdatedAt + } + const lockedRoot = { + ...outsideEquipment, + remaining_quantity: 0, + status: '已借出', + updated_at: mountedUpdatedAt + } + const mountedChild = { + ...newlyMountedChild, + id: 41, + equipment_code: 'JS-RAM-0041', + parent_id: lockedRoot.id, + department_id: 9, + unit_price: 100 + } + const borrowRecords = [ + { id: 401, equipment_id: lockedRoot.id, status: '已借出', person_id: 1 }, + { id: 402, equipment_id: lockedRoot.id, status: '已借出', person_id: 2 } + ] + const rootUpdates: any[] = [] + const childUpdates: any[] = [] + + // 事务外查询时 B 还未提交,因此页面只选中归还 A。 + ;(prisma.equipment.findUnique as any) = async () => outsideEquipment + ;(prisma.borrowRecord.findMany as any) = async () => [borrowRecords[0]] + ;(prisma.operationLog.create as any) = async () => ({ id: 1 }) + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) return [] + if (query.sql.includes('WHERE equipment.id = ?')) return [lockedRoot] + if (query.sql.includes('WHERE equipment.parent_id IN')) { + return query.values.includes(lockedRoot.id) ? [mountedChild] : [] + } + return [] + }, + equipment: { + findUnique: async () => lockedRoot, + findMany: async (args: any) => { + const parentIds = args.where.parent_id.in as number[] + return parentIds.includes(lockedRoot.id) ? [mountedChild] : [] + }, + updateMany: async (args: any) => { + rootUpdates.push(args) + return { count: 1 } + }, + update: async (args: any) => { + childUpdates.push(args) + return mountedChild + } + }, + borrowRecord: { + updateMany: async (args: any) => { + const target = borrowRecords.find((record) => + record.id === args.where.id && + record.equipment_id === args.where.equipment_id && + record.status === args.where.status + ) + if (!target) return { count: 0 } + target.status = args.data.status + return { count: 1 } + } + }, + department: { findUnique: async () => ({ name: '在用部门' }) }, + assetTransferRecord: { + create: async () => ({ id: 1, items: [] }) + } + }) + + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 6, role: 'it', name: '测试 IT', username: 'tester' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + + try { + await batchReturn( + { + body: { equipment_ids: [lockedRoot.id], return_condition: '正常' }, + ip: '127.0.0.1' + } as any, + res as any + ) + + assert.equal(state.body.data.success_count, 1) + assert.equal(borrowRecords.filter((record) => record.status === '已借出').length, 1) + assert.equal(rootUpdates.length, 1) + assert.equal(rootUpdates[0].where.remaining_quantity, 0) + assert.equal(rootUpdates[0].where.total_quantity, 2) + assert.equal(rootUpdates[0].where.updated_at, mountedUpdatedAt) + assert.equal(rootUpdates[0].data.remaining_quantity, 1) + assert.equal(rootUpdates[0].data.status, '可用') + assert.equal(rootUpdates[0].data.department_id, 9) + assert.equal(childUpdates.length, 1) + assert.equal(childUpdates[0].data.status, '已借出') + assert.equal(childUpdates[0].data.remaining_quantity, 0) + assert.equal(Object.prototype.hasOwnProperty.call(childUpdates[0].data, 'department_id'), false) + } finally { + ;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique + ;(prisma.borrowRecord.findMany as any) = originalBorrowFindMany + ;(prisma.$transaction as any) = originalTransaction + ;(prisma.operationLog.create as any) = originalOperationLogCreate + } +}) + +test('批量归还接口也拒绝客户端自选部门处理', async () => { + const originalEquipmentFindUnique = prisma.equipment.findUnique + let equipmentLookupCount = 0 + ;(prisma.equipment.findUnique as any) = async () => { + equipmentLookupCount += 1 + return borrowedRoot + } + + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 6, role: 'it' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + + try { + await batchReturn( + { + body: { + equipment_ids: [borrowedRoot.id], + department_action: 'keep' + } + } as any, + res as any + ) + + assert.equal(state.statusCode, 422) + assert.match(state.body.message, /归还入仓统一清空部门/) + assert.equal(equipmentLookupCount, 0) + } finally { + ;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique + } +}) diff --git a/backend/src/controllers/__tests__/borrowController.return-concurrency.test.ts b/backend/src/controllers/__tests__/borrowController.return-concurrency.test.ts new file mode 100644 index 0000000..f660f58 --- /dev/null +++ b/backend/src/controllers/__tests__/borrowController.return-concurrency.test.ts @@ -0,0 +1,190 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { returnEquipment } from '../borrowController' + +const now = new Date('2026-07-14T06:00:00.000Z') +const root = { + id: 50, + equipment_code: 'JS-PC-0050', + equipment_name: '双数量主机', + parent_id: null, + department_id: 7, + storage_location_id: 1, + status: '可用', + mount_status: 'none', + total_quantity: 2, + remaining_quantity: 1, + unit_price: 1000, + updated_at: now, + storageLocation: null +} +const child = { + id: 51, + equipment_code: 'JS-RAM-0051', + equipment_name: '挂载内存', + parent_id: root.id, + department_id: 7, + storage_location_id: 1, + status: '已借出', + mount_status: 'mounted', + total_quantity: 1, + remaining_quantity: 0, + unit_price: 100, + updated_at: now +} + +test('单笔归还省略部门动作或显式 clear 均按入仓规则清空部门', async () => { + const originalEquipmentFindUnique = prisma.equipment.findUnique + const originalBorrowFindFirst = prisma.borrowRecord.findFirst + const originalTransaction = prisma.$transaction + const borrowRecord = { + id: 501, + equipment_id: root.id, + person_id: 5, + status: '已借出', + children_snapshot: null + } + const rootUpdates: any[] = [] + const childUpdates: any[] = [] + + ;(prisma.equipment.findUnique as any) = async () => root + ;(prisma.borrowRecord.findFirst as any) = async () => borrowRecord + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) return [] + if (query.sql.includes('WHERE equipment.id = ?')) return [root] + if (query.sql.includes('WHERE equipment.parent_id IN')) { + return query.values.includes(root.id) ? [child] : [] + } + return [] + }, + equipment: { + findUnique: async () => root, + findMany: async (args: any) => { + const parentIds = args.where.parent_id.in as number[] + return parentIds.includes(root.id) ? [child] : [] + }, + updateMany: async (args: any) => { + rootUpdates.push(args) + return { count: 1 } + }, + update: async (args: any) => { + childUpdates.push(args) + return child + } + }, + borrowRecord: { + updateMany: async (args: any) => { + if (args.where.id !== borrowRecord.id || + args.where.equipment_id !== root.id || + borrowRecord.status !== args.where.status) { + return { count: 0 } + } + borrowRecord.status = args.data.status + return { count: 1 } + } + }, + department: { findUnique: async () => ({ name: '原部门' }) }, + assetTransferRecord: { create: async () => ({ id: 1, items: [] }) } + }) + + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 6, role: 'it', name: '测试 IT' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + + try { + for (const contractInput of [{}, { department_action: 'clear' }]) { + borrowRecord.status = '已借出' + state.statusCode = 200 + state.body = null + await returnEquipment( + { + body: { + equipment_id: root.id, + borrow_record_id: borrowRecord.id, + return_condition: '正常', + ...contractInput + } + } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.equal(borrowRecord.status, '已归还') + } + + assert.equal(rootUpdates.length, 2) + for (const update of rootUpdates) { + assert.equal(update.data.remaining_quantity, 2) + assert.equal(update.data.status, '可用') + assert.equal(update.data.department_id, null) + } + assert.equal(childUpdates.length, 2) + for (const update of childUpdates) { + assert.equal(update.data.status, '可用') + assert.equal(update.data.remaining_quantity, 1) + assert.equal(update.data.department_id, null) + } + } finally { + ;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique + ;(prisma.borrowRecord.findFirst as any) = originalBorrowFindFirst + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('单笔归还接口拒绝 keep、set 或单独 department_id,且不访问资产', async () => { + const originalEquipmentFindUnique = prisma.equipment.findUnique + let equipmentLookupCount = 0 + ;(prisma.equipment.findUnique as any) = async () => { + equipmentLookupCount += 1 + return root + } + + try { + for (const invalidInput of [ + { department_action: 'keep' }, + { department_action: 'set', department_id: 8 }, + { department_id: 8 } + ]) { + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 6, role: 'it' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + await returnEquipment( + { + body: { + equipment_id: root.id, + borrow_record_id: 501, + ...invalidInput + } + } as any, + res as any + ) + + assert.equal(state.statusCode, 422) + assert.match(state.body.message, /归还入仓统一清空部门/) + } + assert.equal(equipmentLookupCount, 0) + } finally { + ;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique + } +}) diff --git a/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts b/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts new file mode 100644 index 0000000..085540c --- /dev/null +++ b/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { normalizeInternalRedirect } from '../../utils/dingtalkRedirect' + +test('钉钉免登只接受白名单内的系统路径', () => { + assert.equal( + normalizeInternalRedirect('/workflows/disposals/123?source=ding'), + '/workflows/disposals/123?source=ding' + ) + assert.equal(normalizeInternalRedirect('/my-assets'), '/my-assets') +}) + +test('钉钉免登拒绝外链、协议相对地址和非白名单路径', () => { + assert.throws(() => normalizeInternalRedirect('https://evil.example/path'), /允许范围/) + assert.throws(() => normalizeInternalRedirect('//evil.example/path'), /允许范围/) + assert.throws(() => normalizeInternalRedirect('/admin/secrets'), /允许范围/) + assert.throws(() => normalizeInternalRedirect('/workflows/disposals-evil'), /允许范围/) +}) diff --git a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts new file mode 100644 index 0000000..17636f8 --- /dev/null +++ b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts @@ -0,0 +1,288 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { approveDisposal, createDisposal } from '../disposalController' + +const now = new Date('2026-07-14T03:00:00.000Z') +const rootEquipment = { + id: 10, + equipment_code: 'JS-PC-0010', + equipment_name: '整机', + parent_id: null, + department_id: 3, + storage_location_id: 1, + status: '可用', + mount_status: 'none', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 1000, + updated_at: now +} +const childEquipment = { + id: 11, + equipment_code: 'JS-RAM-0011', + equipment_name: '内存', + parent_id: 10, + department_id: 3, + storage_location_id: 1, + status: '可用', + mount_status: 'mounted', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 200, + updated_at: now +} + +const createResponse = (role = 'it') => { + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 6, role, name: '测试用户', username: 'tester' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + return { res, state } +} + +test('主资产仍挂载未勾选配件时申请报废返回 409 且不写状态', async () => { + const originalTransaction = prisma.$transaction + let stateWriteCount = 0 + let disposalCreateCount = 0 + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => { + if (query.sql.includes('WHERE equipment.id = ?')) return [rootEquipment] + if (query.sql.includes('WHERE equipment.parent_id IN')) { + return query.values.includes(rootEquipment.id) ? [childEquipment] : [] + } + return [] + }, + equipment: { + findUnique: async () => rootEquipment, + findMany: async (args: any) => { + const parentIds = args.where.parent_id.in as number[] + return parentIds.includes(10) ? [childEquipment] : [] + }, + updateMany: async () => { + stateWriteCount += 1 + return { count: 1 } + } + }, + disposalRecord: { + create: async () => { + disposalCreateCount += 1 + return { id: 1 } + } + } + }) + + try { + const { res, state } = createResponse() + await createDisposal( + { + params: {}, + body: { + equipment_id: 10, + item_ids: [10], + disposal_reason: '设备老化', + disposal_method: '报废', + estimated_value: 0 + } + } as any, + res as any + ) + + assert.equal(state.statusCode, 409) + assert.match(state.body.message, /请先卸载未勾选配件/) + assert.equal(disposalCreateCount, 0) + assert.equal(stateWriteCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('初读资产树后并发挂载新配件时报废申请中止且不写数据', async () => { + const originalTransaction = prisma.$transaction + let mountedAfterInitialRead = false + let stateWriteCount = 0 + let disposalCreateCount = 0 + const lockQueries: any[] = [] + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) { + lockQueries.push(query) + mountedAfterInitialRead = true + return [] + } + if (query.sql.includes('WHERE equipment.id = ?')) return [rootEquipment] + if (query.sql.includes('WHERE equipment.parent_id IN')) { + return mountedAfterInitialRead && query.values.includes(rootEquipment.id) + ? [childEquipment] + : [] + } + return [] + }, + equipment: { + findUnique: async () => rootEquipment, + // 模拟 RR 旧快照:锁后再做 ORM 普通读仍会漏掉并发新配件。 + findMany: async () => [], + updateMany: async () => { + stateWriteCount += 1 + return { count: 1 } + } + }, + disposalRecord: { + create: async () => { + disposalCreateCount += 1 + return { id: 1 } + } + } + }) + + try { + const { res, state } = createResponse() + await createDisposal( + { + params: {}, + body: { + equipment_id: rootEquipment.id, + item_ids: [rootEquipment.id], + disposal_reason: '设备老化', + disposal_method: '报废', + estimated_value: 0 + } + } as any, + res as any + ) + + assert.equal(state.statusCode, 409) + assert.match(state.body.message, /资产树状态或挂载关系已变化/) + assert.match(lockQueries[0].sql, /IN \(\?\) ORDER BY id FOR UPDATE/) + assert.deepEqual(lockQueries[0].values, [10]) + assert.equal(disposalCreateCount, 0) + assert.equal(stateWriteCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('并发审批 CAS 失败时返回 409,不追加动作也不改资产', async () => { + const originalTransaction = prisma.$transaction + let actionCount = 0 + let equipmentWriteCount = 0 + const disposal = { + id: 91, + equipment_id: 10, + equipment_code: rootEquipment.equipment_code, + applied_by: 4, + status: 'pending', + items: [{ + equipment_id: 10, + equipment_code: rootEquipment.equipment_code, + parent_equipment_id: null, + pre_status: '可用', + equipment: { ...rootEquipment, status: '待报废' } + }] + } + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + disposalRecord: { + findUnique: async () => disposal, + updateMany: async () => ({ count: 0 }) + }, + disposalAction: { + create: async () => { + actionCount += 1 + } + }, + equipment: { + updateMany: async () => { + equipmentWriteCount += 1 + return { count: 1 } + } + } + }) + + try { + const { res, state } = createResponse('administrative') + await approveDisposal( + { params: { id: '91' }, body: { approved: true } } as any, + res as any + ) + + assert.equal(state.statusCode, 409) + assert.equal(actionCount, 0) + assert.equal(equipmentWriteCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('驳回报废时按每个 item.pre_status 分别恢复', async () => { + const originalTransaction = prisma.$transaction + const originalLogCreate = prisma.operationLog.create + const restoredStatuses: string[] = [] + const disposal = { + id: 92, + equipment_id: 10, + equipment_code: rootEquipment.equipment_code, + applied_by: 4, + status: 'pending', + items: [ + { + equipment_id: 10, + equipment_code: rootEquipment.equipment_code, + parent_equipment_id: null, + pre_status: '可用', + equipment: { ...rootEquipment, status: '待报废' } + }, + { + equipment_id: 11, + equipment_code: childEquipment.equipment_code, + parent_equipment_id: 10, + pre_status: '已损坏', + equipment: { ...childEquipment, status: '待报废' } + } + ] + } + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + disposalRecord: { + findUnique: async () => disposal, + updateMany: async () => ({ count: 1 }) + }, + equipment: { + updateMany: async (args: any) => { + restoredStatuses.push(args.data.status) + return { count: 1 } + } + }, + disposalAction: { create: async () => ({ id: 1 }) }, + user: { findMany: async () => [] } + }) + ;(prisma.operationLog.create as any) = async () => ({ id: 1 }) + + try { + const { res, state } = createResponse('administrative') + await approveDisposal( + { + params: { id: '92' }, + body: { approved: false, reject_reason: '尚可继续使用' } + } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.deepEqual(restoredStatuses, ['可用', '已损坏']) + } finally { + ;(prisma.$transaction as any) = originalTransaction + ;(prisma.operationLog.create as any) = originalLogCreate + } +}) diff --git a/backend/src/controllers/__tests__/equipmentController.update-status.test.ts b/backend/src/controllers/__tests__/equipmentController.update-status.test.ts index ccecc46..8c55cf1 100644 --- a/backend/src/controllers/__tests__/equipmentController.update-status.test.ts +++ b/backend/src/controllers/__tests__/equipmentController.update-status.test.ts @@ -4,13 +4,14 @@ import assert from 'node:assert/strict' import prisma from '../../utils/prisma' import { updateEquipment } from '../equipmentController' -const createMockResponse = () => { +const createMockResponse = (role = 'admin') => { const responseBody: { statusCode: number; body: any } = { statusCode: 200, body: null } const res = { + locals: { user: { id: 1, role } }, status(code: number) { responseBody.statusCode = code return this @@ -24,64 +25,57 @@ const createMockResponse = () => { return { res, responseBody } } +const currentEquipment = { + id: 903, + equipment_code: 'JS-PC-0119', + code_generation_mode: 'auto', + equipment_name: '主机', + equipment_model: '', + equipment_type: '主机', + unit_price: 0, + total_quantity: 1, + remaining_quantity: 1, + total_value: 0, + borrowed_value: 0, + available_value: 0, + purchase_date: null, + supplier: '', + description: '', + status: '待报废', + storage_location_id: null, + department_id: null, + asset_type_id: null, + parent_id: null, + updated_at: new Date('2026-07-14T01:00:00.000Z') +} + test('普通编辑资产时不应把待报废资产冲回可用', async () => { const originalFindUnique = prisma.equipment.findUnique - const originalUpdate = prisma.equipment.update - const originalCount = prisma.equipment.count + const originalTransaction = prisma.$transaction - const currentEquipment = { - id: 903, - equipment_code: 'JS-PC-0119', - equipment_name: '主机', - equipment_model: '', - equipment_type: '主机', - unit_price: 0, - total_quantity: 1, - remaining_quantity: 1, - total_value: 0, - borrowed_value: 0, - available_value: 0, - purchase_date: null, - supplier: '', - description: '', - status: '待报废', - storage_location_id: null, - department_id: null, - asset_type_id: null, - parent_id: null - } - - let findUniqueCallCount = 0 let capturedUpdateData: any = null - - ;(prisma.equipment.findUnique as any) = async () => { - findUniqueCallCount += 1 - return currentEquipment - } - - ;(prisma.equipment.update as any) = async (args: any) => { - capturedUpdateData = args.data - return { - ...currentEquipment, - ...args.data + ;(prisma.equipment.findUnique as any) = async () => currentEquipment + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRawUnsafe: async () => [], + equipment: { + updateMany: async (args: any) => { + capturedUpdateData = args.data + return { count: 1 } + }, + count: async () => 0 } - } - - ;(prisma.equipment.count as any) = async () => 0 + }) try { const { res, responseBody } = createMockResponse() - await updateEquipment( { params: { id: '903' }, body: { equipment_name: '主机', equipment_model: '', - equipment_type: '主机', unit_price: 0, total_quantity: 1, - status: '可用', purchase_date: '', supplier: '', description: '' @@ -91,12 +85,253 @@ test('普通编辑资产时不应把待报废资产冲回可用', async () => { ) assert.equal(responseBody.statusCode, 200) - assert.ok(capturedUpdateData, '应当调用 equipment.update') - assert.equal(findUniqueCallCount >= 1, true) assert.equal(capturedUpdateData.status, '待报废') } finally { ;(prisma.equipment.findUnique as any) = originalFindUnique - ;(prisma.equipment.update as any) = originalUpdate - ;(prisma.equipment.count as any) = originalCount + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('管理员也不能通过基础资料接口直接改部门或状态', async () => { + const originalFindUnique = prisma.equipment.findUnique + const originalTransaction = prisma.$transaction + let transactionCount = 0 + + ;(prisma.equipment.findUnique as any) = async () => ({ ...currentEquipment, status: '可用' }) + ;(prisma.$transaction as any) = async () => { + transactionCount += 1 + } + + try { + for (const body of [{ department_id: 8 }, { status: '维护中' }]) { + const { res, responseBody } = createMockResponse('admin') + await updateEquipment({ params: { id: '903' }, body } as any, res as any) + assert.equal(responseBody.statusCode, 422) + } + assert.equal(transactionCount, 0) + } finally { + ;(prisma.equipment.findUnique as any) = originalFindUnique + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('资产状态在保存前变化时 CAS 失败并返回 409', async () => { + const originalFindUnique = prisma.equipment.findUnique + const originalTransaction = prisma.$transaction + let capturedWhere: any = null + let findUniqueCount = 0 + + ;(prisma.equipment.findUnique as any) = async () => { + findUniqueCount += 1 + return currentEquipment + } + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRawUnsafe: async () => [], + equipment: { + updateMany: async (args: any) => { + capturedWhere = args.where + return { count: 0 } + } + } + }) + + try { + const { res, responseBody } = createMockResponse() + await updateEquipment( + { params: { id: '903' }, body: { equipment_name: '新名称' } } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 409) + assert.equal(findUniqueCount, 1, '冲突后不应读取并返回伪成功数据') + assert.equal(capturedWhere.status, currentEquipment.status) + assert.equal(capturedWhere.department_id, currentEquipment.department_id) + assert.equal(capturedWhere.parent_id, currentEquipment.parent_id) + assert.equal(capturedWhere.remaining_quantity, currentEquipment.remaining_quantity) + assert.equal(capturedWhere.updated_at, currentEquipment.updated_at) + } finally { + ;(prisma.equipment.findUnique as any) = originalFindUnique + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('行政纠错只写入本次提交的基础字段', async () => { + const originalFindUnique = prisma.equipment.findUnique + const originalTransaction = prisma.$transaction + const originalBorrowCount = prisma.borrowRecord.count + const originalMaintenanceCount = prisma.maintenanceRecord.count + const originalDisposalItemCount = prisma.disposalItem.count + const originalDepartmentFindFirst = prisma.department.findFirst + const administrativeEquipment = { + ...currentEquipment, + status: '可用', + department_id: 7, + parent_id: null + } + let capturedUpdateData: any = null + + ;(prisma.equipment.findUnique as any) = async () => administrativeEquipment + ;(prisma.borrowRecord.count as any) = async () => 0 + ;(prisma.maintenanceRecord.count as any) = async () => 0 + ;(prisma.disposalItem.count as any) = async () => 0 + ;(prisma.department.findFirst as any) = async () => ({ id: 7, name: '行政部' }) + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRawUnsafe: async () => [], + borrowRecord: { count: async () => 0 }, + maintenanceRecord: { count: async () => 0 }, + disposalItem: { count: async () => 0 }, + equipment: { + updateMany: async (args: any) => { + capturedUpdateData = args.data + return { count: 1 } + }, + count: async () => 0 + } + }) + + try { + const { res, responseBody } = createMockResponse('administrative') + await updateEquipment( + { params: { id: '903' }, body: { equipment_name: '更正后的主机名称' } } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 200) + assert.deepEqual(capturedUpdateData, { equipment_name: '更正后的主机名称' }) + assert.equal(Object.prototype.hasOwnProperty.call(capturedUpdateData, 'status'), false) + assert.equal(Object.prototype.hasOwnProperty.call(capturedUpdateData, 'department_id'), false) + assert.equal(Object.prototype.hasOwnProperty.call(capturedUpdateData, 'parent_id'), false) + assert.equal(Object.prototype.hasOwnProperty.call(capturedUpdateData, 'remaining_quantity'), false) + } finally { + ;(prisma.equipment.findUnique as any) = originalFindUnique + ;(prisma.$transaction as any) = originalTransaction + ;(prisma.borrowRecord.count as any) = originalBorrowCount + ;(prisma.maintenanceRecord.count as any) = originalMaintenanceCount + ;(prisma.disposalItem.count as any) = originalDisposalItemCount + ;(prisma.department.findFirst as any) = originalDepartmentFindFirst + } +}) + +test('行政不能用非正整数数量纠错间接改变资产状态', async () => { + const originalFindUnique = prisma.equipment.findUnique + const originalTransaction = prisma.$transaction + const originalBorrowCount = prisma.borrowRecord.count + const originalMaintenanceCount = prisma.maintenanceRecord.count + const originalDisposalItemCount = prisma.disposalItem.count + let transactionCount = 0 + + ;(prisma.equipment.findUnique as any) = async () => ({ ...currentEquipment, status: '可用' }) + ;(prisma.borrowRecord.count as any) = async () => 0 + ;(prisma.maintenanceRecord.count as any) = async () => 0 + ;(prisma.disposalItem.count as any) = async () => 0 + ;(prisma.$transaction as any) = async () => { + transactionCount += 1 + } + + try { + for (const invalidQuantity of [0, -1, 1.5]) { + const { res, responseBody } = createMockResponse('administrative') + await updateEquipment( + { params: { id: '903' }, body: { total_quantity: invalidQuantity } } as any, + res as any + ) + assert.equal(responseBody.statusCode, 422) + assert.match(responseBody.body.message, /总数量必须/) + } + assert.equal(transactionCount, 0) + } finally { + ;(prisma.equipment.findUnique as any) = originalFindUnique + ;(prisma.$transaction as any) = originalTransaction + ;(prisma.borrowRecord.count as any) = originalBorrowCount + ;(prisma.maintenanceRecord.count as any) = originalMaintenanceCount + ;(prisma.disposalItem.count as any) = originalDisposalItemCount + } +}) + +test('修改资产类型名称时同步更新 asset_type_id', async () => { + const originalFindUnique = prisma.equipment.findUnique + const originalCount = prisma.equipment.count + const originalAssetTypeFindFirst = prisma.assetType.findFirst + const originalTransaction = prisma.$transaction + const equipment = { ...currentEquipment, status: '可用', asset_type_id: 1 } + const newType = { + id: 42, + name: '配件', + status: 'active', + can_be_parent: true, + can_be_child: true + } + let capturedUpdateData: any = null + + ;(prisma.equipment.findUnique as any) = async () => equipment + ;(prisma.equipment.count as any) = async () => 0 + ;(prisma.assetType.findFirst as any) = async () => newType + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRawUnsafe: async () => [], + assetType: { findUnique: async () => newType }, + equipment: { + count: async () => 0, + updateMany: async (args: any) => { + capturedUpdateData = args.data + return { count: 1 } + } + } + }) + + try { + const { res, responseBody } = createMockResponse('admin') + await updateEquipment( + { params: { id: '903' }, body: { equipment_type: '配件' } } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 200) + assert.deepEqual(capturedUpdateData, { + equipment_type: '配件', + asset_type_id: 42 + }) + } finally { + ;(prisma.equipment.findUnique as any) = originalFindUnique + ;(prisma.equipment.count as any) = originalCount + ;(prisma.assetType.findFirst as any) = originalAssetTypeFindFirst + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('有子资产的设备不能改成不支持父资产的类型', async () => { + const originalFindUnique = prisma.equipment.findUnique + const originalCount = prisma.equipment.count + const originalAssetTypeFindFirst = prisma.assetType.findFirst + const originalTransaction = prisma.$transaction + let transactionCount = 0 + + ;(prisma.equipment.findUnique as any) = async () => ({ ...currentEquipment, status: '可用' }) + ;(prisma.equipment.count as any) = async () => 1 + ;(prisma.assetType.findFirst as any) = async () => ({ + id: 43, + name: '普通配件', + status: 'active', + can_be_parent: false, + can_be_child: true + }) + ;(prisma.$transaction as any) = async () => { + transactionCount += 1 + } + + try { + const { res, responseBody } = createMockResponse('admin') + await updateEquipment( + { params: { id: '903' }, body: { equipment_type: '普通配件' } } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 422) + assert.match(responseBody.body.message, /必须允许作为父资产/) + assert.equal(transactionCount, 0) + } finally { + ;(prisma.equipment.findUnique as any) = originalFindUnique + ;(prisma.equipment.count as any) = originalCount + ;(prisma.assetType.findFirst as any) = originalAssetTypeFindFirst + ;(prisma.$transaction as any) = originalTransaction } }) diff --git a/backend/src/controllers/__tests__/maintenanceController.concurrency.test.ts b/backend/src/controllers/__tests__/maintenanceController.concurrency.test.ts new file mode 100644 index 0000000..79e77b1 --- /dev/null +++ b/backend/src/controllers/__tests__/maintenanceController.concurrency.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import prisma from '../../utils/prisma' +import { completeMaintenance, createMaintenance } from '../maintenanceController' + +const createMockResponse = () => { + const responseBody = { statusCode: 200, body: null as any } + const res = { + locals: { + user: { id: 9, username: 'it-user', name: 'IT用户' } + }, + status(code: number) { + responseBody.statusCode = code + return this + }, + json(payload: any) { + responseBody.body = payload + return this + } + } + return { res, responseBody } +} + +const equipment = { + id: 101, + equipment_code: 'JS-PC-0101', + equipment_name: '主机', + parent_id: null, + department_id: 3, + storage_location_id: 2, + status: '可用', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 1000 +} + +test('并发送修未抢到资产状态时不创建第二条维修单', async () => { + const originalTransaction = prisma.$transaction + let maintenanceCreated = false + let acquireWhere: any = null + + const tx = { + equipment: { + findUnique: async () => equipment, + findMany: async () => [], + updateMany: async (args: any) => { + acquireWhere = args.where + return { count: 0 } + } + }, + borrowRecord: { count: async () => 0 }, + disposalItem: { count: async () => 0 }, + maintenanceRecord: { + count: async () => 0, + create: async () => { + maintenanceCreated = true + return { id: 1 } + } + } + } + + ;(prisma.$transaction as any) = async (callback: any) => callback(tx) + try { + const { res, responseBody } = createMockResponse() + await createMaintenance( + { + body: { + equipment_id: equipment.id, + issue_description: '无法开机', + estimated_cost: 100 + } + } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 409) + assert.equal(maintenanceCreated, false) + assert.deepEqual(acquireWhere, { + id: equipment.id, + status: '可用', + parent_id: null, + remaining_quantity: 1, + department_id: 3 + }) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) +test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', async () => { + const originalTransaction = prisma.$transaction + let equipmentUpdated = false + let maintenanceWhere: any = null + + const tx = { + maintenanceRecord: { + findUnique: async () => ({ + id: 20, + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + status: 'in_progress', + estimated_cost: 100 + }), + updateMany: async (args: any) => { + maintenanceWhere = args.where + return { count: 0 } + } + }, + equipment: { + findUnique: async () => ({ ...equipment, status: '维护中' }), + updateMany: async () => { + equipmentUpdated = true + return { count: 1 } + } + } + } + + ;(prisma.$transaction as any) = async (callback: any) => callback(tx) + try { + const { res, responseBody } = createMockResponse() + await completeMaintenance( + { params: { id: '20' }, body: { repair_result: '已修复' } } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 409) + assert.deepEqual(maintenanceWhere, { id: 20, status: 'in_progress' }) + assert.equal(equipmentUpdated, false) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) diff --git a/backend/src/controllers/__tests__/mountController.concurrency.test.ts b/backend/src/controllers/__tests__/mountController.concurrency.test.ts new file mode 100644 index 0000000..7e1e3b8 --- /dev/null +++ b/backend/src/controllers/__tests__/mountController.concurrency.test.ts @@ -0,0 +1,290 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { mountChild, unmountChild } from '../mountController' + +const now = new Date('2026-07-14T02:00:00.000Z') +const parent = { + id: 1, + equipment_code: 'JS-PC-0001', + equipment_name: '主机', + parent_id: null, + department_id: 5, + storage_location_id: 1, + status: '可用', + mount_status: 'none', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 1000, + updated_at: now, + assetType: { can_be_parent: true, can_be_child: false } +} +const child = { + id: 2, + equipment_code: 'JS-RAM-0002', + equipment_name: '内存组', + parent_id: null, + department_id: null, + storage_location_id: 1, + status: '可用', + mount_status: 'unmounted', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 300, + updated_at: now, + assetType: { can_be_parent: true, can_be_child: true } +} +const grandchild = { + id: 3, + equipment_code: 'JS-CHIP-0003', + equipment_name: '内存颗粒', + parent_id: 2, + department_id: null, + storage_location_id: 1, + status: '可用', + mount_status: 'mounted', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 50, + updated_at: now, + assetType: { can_be_parent: false, can_be_child: true } +} + +const readCurrentTree = (query: any, items: any[]) => { + if (query.sql.includes('WHERE equipment.id = ?')) { + return items.filter((item) => item.id === Number(query.values[0])) + } + if (query.sql.includes('WHERE equipment.parent_id IN')) { + const parentIds = new Set(query.values.map(Number)) + return items + .filter((item) => item.parent_id !== null && parentIds.has(item.parent_id)) + .sort((left, right) => left.id - right.id) + } + return [] +} + +const createResponse = () => { + const state = { statusCode: 200, body: null as any } + const res = { + locals: { user: { id: 8, role: 'it' } }, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + return { res, state } +} + +test('同一配件被并发挂载时 CAS 失败返回 409,不记挂载历史', async () => { + const originalTransaction = prisma.$transaction + let historyCount = 0 + const lockQueries: any[] = [] + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) lockQueries.push(query) + return readCurrentTree(query, [parent, child]) + }, + equipment: { + findUnique: async (args: any) => args.where.id === 1 ? parent : child, + findMany: async () => [], + updateMany: async () => ({ count: 0 }) + }, + borrowRecord: { count: async () => 0 }, + mountHistory: { + create: async () => { + historyCount += 1 + } + } + }) + + try { + const { res, state } = createResponse() + await mountChild( + { params: { parentId: '1' }, body: { child_id: 2 } } as any, + res as any + ) + + assert.equal(state.statusCode, 409) + assert.equal(historyCount, 0) + assert.match(lockQueries[0].sql, /IN \(\?,\?\) ORDER BY id FOR UPDATE/) + assert.deepEqual(lockQueries[0].values, [1, 2]) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('A-B / B-A 交叉挂载会被祖先链校验拦截', async () => { + const originalTransaction = prisma.$transaction + let updateCount = 0 + const parentUnderChild = { ...parent, parent_id: 2 } + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => readCurrentTree(query, [parentUnderChild, child]), + equipment: { + findUnique: async (args: any) => args.where.id === 1 ? parentUnderChild : child, + updateMany: async () => { + updateCount += 1 + return { count: 1 } + } + }, + borrowRecord: { count: async () => 0 } + }) + + try { + const { res, state } = createResponse() + await mountChild( + { params: { parentId: '1' }, body: { child_id: 2 } } as any, + res as any + ) + + assert.equal(state.statusCode, 422) + assert.match(state.body.message, /不能把资产挂载到自己的下级/) + assert.equal(updateCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('挂载带后代的配件树时同步父资产部门和借出状态', async () => { + const originalTransaction = prisma.$transaction + const borrowedParent = { ...parent, status: '已借出', remaining_quantity: 0 } + const updates: any[] = [] + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => readCurrentTree( + query, + [borrowedParent, child, grandchild] + ), + equipment: { + findUnique: async (args: any) => { + if (args.where.id === 1) { + return args.include?.children + ? { ...borrowedParent, children: [{ unit_price: child.unit_price }] } + : borrowedParent + } + return args.where.id === 2 ? child : grandchild + }, + findMany: async (args: any) => { + const parentIds = args.where.parent_id.in as number[] + return parentIds.includes(2) ? [grandchild] : [] + }, + updateMany: async (args: any) => { + updates.push(args) + return { count: 1 } + }, + update: async () => borrowedParent + }, + borrowRecord: { count: async () => 0 }, + mountHistory: { create: async () => ({ id: 1 }) } + }) + + try { + const { res, state } = createResponse() + await mountChild( + { params: { parentId: '1' }, body: { child_id: 2 } } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.equal(updates.length, 2) + assert.equal(updates[0].data.parent_id, 1) + assert.equal(updates[0].data.mount_status, 'mounted') + assert.equal(updates[1].data.parent_id, undefined) + for (const update of updates) { + assert.equal(update.data.department_id, 5) + assert.equal(update.data.status, '已借出') + assert.equal(update.data.remaining_quantity, 0) + } + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('卸载配件树时整树恢复可用并清空部门', async () => { + const originalTransaction = prisma.$transaction + const mountedChild = { ...child, parent_id: 1, department_id: 5, mount_status: 'mounted' } + const mountedGrandchild = { ...grandchild, department_id: 5 } + const updates: any[] = [] + + ;(prisma.$transaction as any) = async (callback: any) => callback({ + $queryRaw: async (query: any) => readCurrentTree( + query, + [parent, mountedChild, mountedGrandchild] + ), + equipment: { + findUnique: async (args: any) => { + if (args.where.id === 1) { + return args.include?.children + ? { ...parent, children: [] } + : parent + } + return args.where.id === 2 ? mountedChild : mountedGrandchild + }, + findMany: async (args: any) => { + const parentIds = args.where.parent_id.in as number[] + return parentIds.includes(2) ? [mountedGrandchild] : [] + }, + updateMany: async (args: any) => { + updates.push(args) + return { count: 1 } + }, + update: async () => parent + }, + mountHistory: { create: async () => ({ id: 1 }) } + }) + + try { + const { res, state } = createResponse() + await unmountChild( + { + params: { parentId: '1' }, + body: { child_id: 2, released_child_status: '可用' } + } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.equal(updates.length, 2) + assert.equal(updates[0].data.parent_id, null) + assert.equal(updates[0].data.mount_status, 'unmounted') + for (const update of updates) { + assert.equal(update.data.department_id, null) + assert.equal(update.data.status, '可用') + assert.equal(update.data.remaining_quantity, 1) + } + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('卸载接口拒绝直接把配件设为维修中或待报废', async () => { + const originalTransaction = prisma.$transaction + let transactionCount = 0 + ;(prisma.$transaction as any) = async () => { + transactionCount += 1 + } + + try { + for (const releasedStatus of ['维护中', '待报废', '已报废']) { + const { res, state } = createResponse() + await unmountChild( + { + params: { parentId: '1' }, + body: { child_id: 2, released_child_status: releasedStatus } + } as any, + res as any + ) + assert.equal(state.statusCode, 422) + assert.match(state.body.message, /正式业务流程/) + } + assert.equal(transactionCount, 0) + } finally { + ;(prisma.$transaction as any) = originalTransaction + } +}) diff --git a/backend/src/controllers/batchController.ts b/backend/src/controllers/batchController.ts index 1094780..cb5b767 100644 --- a/backend/src/controllers/batchController.ts +++ b/backend/src/controllers/batchController.ts @@ -1,9 +1,16 @@ import { Request, Response } from 'express' +import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' +import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment' import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' +import { + finalizeNormalReturnState, + recordReturnToStorage, + transferAssetTree +} from '../services/assetMovementService' const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => { const longTerm = Boolean(isLongTerm) @@ -84,7 +91,12 @@ const batchBorrowTransaction = async ( throw new Error('资产库存已变化,请刷新后重试') } - await syncMountedChildrenState(tx, equipment.id, '已借出') + await syncMountedChildrenState( + tx, + equipment.id, + '已借出', + person.main_department_id ? { departmentId: person.main_department_id } : {} + ) }) } @@ -92,12 +104,20 @@ const batchReturnTransaction = async ( equipment: any, borrowRecord: any, returnCondition: string, - notes?: string + notes: string | undefined, + operatedBy: number ) => { return prisma.$transaction(async (tx) => { + const { items } = await recordReturnToStorage(tx, { + rootEquipmentId: equipment.id, + operatedBy, + reason: notes || '批量归还入仓' + }) + const currentRoot = items[0] const updatedRecord = await tx.borrowRecord.updateMany({ where: { id: borrowRecord.id, + equipment_id: currentRoot.id, status: '已借出' }, data: { @@ -112,35 +132,7 @@ 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: nextRemainingQuantity, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value, - status: '可用' - } - }) - - if (updatedEquipment.count !== 1) { - throw new Error('资产库存已达到上限,请检查归还记录') - } - - await syncMountedChildrenState(tx, equipment.id, '可用') + await finalizeNormalReturnState(tx, currentRoot) }) } @@ -232,11 +224,18 @@ export const batchReturn = async (req: Request, res: Response) => { try { const { equipment_ids, return_condition = '正常', notes } = req.body + try { + assertReturnStorageDepartmentContract(req.body || {}) + } catch (error: any) { + return errorResponse(res, error.message || '归还入仓部门契约无效', 422) + } + if (!Array.isArray(equipment_ids) || equipment_ids.length === 0) { return errorResponse(res, '请选择要归还的资产') } const results: any[] = [] + const operatedBy = Number(res.locals.user?.id) let successCount = 0 let failedCount = 0 @@ -269,7 +268,7 @@ export const batchReturn = async (req: Request, res: Response) => { continue } - await batchReturnTransaction(equipment, borrowRecord, return_condition, notes) + await batchReturnTransaction(equipment, borrowRecord, return_condition, notes, operatedBy) results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '归还成功' }) successCount++ @@ -304,22 +303,33 @@ export const batchReturn = async (req: Request, res: Response) => { // 批量转移部门 export const batchTransfer = async (req: Request, res: Response) => { try { - const { equipment_ids, department_id } = req.body + const { equipment_ids, department_id, target_type = 'department', reason } = req.body if (!Array.isArray(equipment_ids) || equipment_ids.length === 0) { return errorResponse(res, '请选择要转移的资产') } - const department = await prisma.department.findFirst({ - where: { - id: Number(department_id), - status: 'active', - ...buildSyncedBusinessDepartmentWhere() - } - }) - if (!department) { - return errorResponse(res, '目标部门不存在或已停用') + if (!reason || typeof reason !== 'string' || !reason.trim()) { + return errorResponse(res, '调拨原因不能为空', 422) } + if (!['department', 'idle'].includes(target_type)) { + return errorResponse(res, '调拨目标类型无效', 422) + } + + const department = target_type === 'department' + ? await prisma.department.findFirst({ + where: { + id: Number(department_id), + status: 'active', + ...buildSyncedBusinessDepartmentWhere() + } + }) + : null + if (target_type === 'department' && !department) { + return errorResponse(res, '目标部门不存在或已停用', 422) + } + + const operatedBy = Number(res.locals.user?.id) const results: any[] = [] let successCount = 0 @@ -327,20 +337,24 @@ export const batchTransfer = async (req: Request, res: Response) => { for (const equipmentId of equipment_ids) { try { - const equipment = await prisma.equipment.findUnique({ where: { id: Number(equipmentId) } }) - - if (!equipment) { - results.push({ equipment_id: equipmentId, status: 'failed', message: '资产不存在' }) - failedCount++ - continue - } + const movement = await prisma.$transaction( + (tx) => transferAssetTree(tx, { + rootEquipmentId: Number(equipmentId), + actionType: target_type === 'idle' ? 'idle' : 'transfer', + toDepartmentId: department?.id || null, + toDepartmentName: department?.name || null, + operatedBy, + reason: reason.trim() + }), + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted } + ) - await prisma.equipment.update({ - where: { id: equipment.id }, - data: { department_id: department.id } + results.push({ + equipment_id: equipmentId, + equipment_code: movement.items[0]?.equipment_code, + status: 'success', + message: target_type === 'idle' ? '已转为闲置' : '转移成功' }) - - results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '转移成功' }) successCount++ } catch (error: any) { results.push({ equipment_id: equipmentId, status: 'failed', message: error.message }) @@ -349,7 +363,7 @@ export const batchTransfer = async (req: Request, res: Response) => { } // 记录操作日志 - const userId = res.locals.user?.id + const userId = operatedBy const username = res.locals.user?.username || res.locals.user?.name || 'unknown' const successCodes = results.filter(r => r.status === 'success').map(r => r.equipment_code).join(', ') if (successCount > 0) { @@ -357,7 +371,9 @@ export const batchTransfer = async (req: Request, res: Response) => { userId, username, '批量转移', - `批量转移 ${successCount} 件资产到部门 ${department.name},资产编码:${successCodes}`, + target_type === 'idle' + ? `批量将 ${successCount} 件资产转为闲置,资产编码:${successCodes}` + : `批量转移 ${successCount} 件资产到部门 ${department?.name || ''},资产编码:${successCodes}`, undefined, undefined, req.ip diff --git a/backend/src/controllers/batchImportController.ts b/backend/src/controllers/batchImportController.ts index 5ae1940..4bbf644 100644 --- a/backend/src/controllers/batchImportController.ts +++ b/backend/src/controllers/batchImportController.ts @@ -200,6 +200,11 @@ export const batchImportEquipments = async (req: Request, res: Response) => { // 查找部门(可选) let departmentId: number | null = null if (eq.department) { + if (res.locals.user?.role === 'administrative') { + results.errors.push({ row: rowNum, message: '行政导入资产时不能直接分配部门,请导入后由 IT 调拨' }) + results.failed++ + continue + } departmentId = departmentMap.get(eq.department) || null if (!departmentId) { results.errors.push({ row: rowNum, message: `部门 "${eq.department}" 不存在` }) diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index 723c138..64dc40e 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -8,15 +8,19 @@ import { } from '../utils/assetValue' import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly' import { - normalizeReturnDepartmentInput, + assertReturnStorageDepartmentContract, resolveBorrowDepartmentUpdate } from '../utils/borrowDepartment' import { buildParentAssetFirstOrderBy, sortParentAssetsFirst } from '../utils/equipmentSearchPriority' -import { buildBusinessDepartmentWhere } from '../utils/organization' import { logOperation } from './operationLogController' +import { + finalizeBorrowAnomalyReturnState, + finalizeNormalReturnState, + recordReturnToStorage +} from '../services/assetMovementService' const parseNumber = (value: any): number | undefined => { const num = Number(value) @@ -173,33 +177,6 @@ const ensureBorrowAnomalyBorrowTime = (value?: any) => { return borrowTime } -const validateReturnDepartmentUpdate = async (input: Record) => { - const normalized = normalizeReturnDepartmentInput(input) - - if (normalized.departmentAction !== 'set' || !normalized.departmentId) { - return normalized - } - - const department = await prisma.department.findFirst({ - where: { - id: normalized.departmentId, - AND: [ - buildBusinessDepartmentWhere(), - { status: 'active' } - ] - }, - select: { - id: true - } - }) - - if (!department) { - throw new Error('所选业务部门不存在或已停用') - } - - return normalized -} - const ensureEquipmentBorrowable = (equipment: any) => { if (equipment.parent_id) { throw new Error('该子资产当前已挂载,请先卸载后再借用') @@ -302,7 +279,12 @@ const borrowEquipmentInTransaction = async ( throw new Error('资产库存已变化,请刷新后重试') } - await syncMountedChildrenState(tx, equipment.id, '已借出') + await syncMountedChildrenState( + tx, + equipment.id, + '已借出', + departmentUpdateData ? { departmentId: departmentUpdateData.department_id } : {} + ) const updatedEquipment = await tx.equipment.findUnique({ where: { id: equipment.id }, @@ -390,15 +372,23 @@ const returnEquipmentInTransaction = async ( borrowRecord: any, returnCondition: string, notes?: string, - equipmentUpdateData?: { - department_id: number | null - } | null + operatedBy?: number ) => { return prisma.$transaction(async (tx) => { + if (!operatedBy) { + throw new Error('未获取到归还操作人') + } + const { items } = await recordReturnToStorage(tx, { + rootEquipmentId: equipment.id, + operatedBy, + reason: notes || '正常归还入仓' + }) + const currentRoot = items[0] const returnTime = new Date() const updatedBorrowRecord = await tx.borrowRecord.updateMany({ where: { id: borrowRecord.id, + equipment_id: currentRoot.id, status: '已借出' }, data: { @@ -413,36 +403,7 @@ 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: nextRemainingQuantity, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value, - status: '可用', - ...(equipmentUpdateData || {}) - } - }) - - if (equipmentUpdate.count !== 1) { - throw new Error('资产库存已达到上限,请检查归还记录') - } - - await syncMountedChildrenState(tx, equipment.id, '可用') + await finalizeNormalReturnState(tx, currentRoot) return { id: borrowRecord.id, @@ -453,29 +414,20 @@ const returnEquipmentInTransaction = async ( }) } -const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any) => { +const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any, operatedBy: number) => { return prisma.$transaction(async (tx) => { - const nextRemainingQuantity = Number(equipment.total_quantity) - const values = calculateEquipmentValues({ - unitPrice: Number(equipment.unit_price), - totalQuantity: Number(equipment.total_quantity), - remainingQuantity: nextRemainingQuantity, - status: '可用' + const { items } = await recordReturnToStorage(tx, { + rootEquipmentId: equipment.id, + operatedBy, + reason: '修正借用单缺失异常并归还入仓' }) - - const updatedEquipment = await tx.equipment.update({ - where: { id: equipment.id }, - data: { - status: '可用', - remaining_quantity: nextRemainingQuantity, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value - }, + const currentRoot = items[0] + await finalizeBorrowAnomalyReturnState(tx, currentRoot) + const updatedEquipment = await tx.equipment.findUnique({ + where: { id: currentRoot.id }, include: equipmentInclude }) - - await syncMountedChildrenState(tx, equipment.id, '可用') + if (!updatedEquipment) throw new Error('资产不存在') return updatedEquipment }) @@ -513,8 +465,18 @@ const registerBorrowAnomalyInTransaction = async ( } }) - const updatedEquipment = await tx.equipment.update({ - where: { id: equipment.id }, + const updated = await tx.equipment.updateMany({ + where: { + id: equipment.id, + parent_id: equipment.parent_id, + department_id: equipment.department_id, + status: equipment.status, + mount_status: equipment.mount_status, + total_quantity: equipment.total_quantity, + remaining_quantity: equipment.remaining_quantity, + unit_price: equipment.unit_price, + updated_at: equipment.updated_at + }, data: { status: '已借出', remaining_quantity: nextRemainingQuantity, @@ -522,11 +484,24 @@ const registerBorrowAnomalyInTransaction = async ( borrowed_value: values.borrowed_value, available_value: values.available_value, ...(departmentUpdateData || {}) - }, + } + }) + if (updated.count !== 1) { + throw new Error('资产状态已变化,请刷新后重试') + } + + const updatedEquipment = await tx.equipment.findUnique({ + where: { id: equipment.id }, include: equipmentInclude }) + if (!updatedEquipment) throw new Error('资产不存在') - await syncMountedChildrenState(tx, equipment.id, '已借出') + await syncMountedChildrenState( + tx, + equipment.id, + '已借出', + departmentUpdateData ? { departmentId: departmentUpdateData.department_id } : {} + ) return { record, @@ -801,11 +776,10 @@ export const returnEquipment = async (req: Request, res: Response) => { confirm_children_diff } = req.body as Record - let departmentUpdate try { - departmentUpdate = await validateReturnDepartmentUpdate(req.body || {}) + assertReturnStorageDepartmentContract(req.body || {}) } catch (error: any) { - return errorResponse(res, error.message || '部门归属处理无效', 422) + return errorResponse(res, error.message || '归还入仓部门契约无效', 422) } const equipmentIdNum = parseNumber(equipment_id) @@ -850,7 +824,7 @@ export const returnEquipment = async (req: Request, res: Response) => { borrowRecord, return_condition, finalNotes, - departmentUpdate.equipmentUpdateData + Number(res.locals.user?.id) ) successResponse( @@ -885,11 +859,10 @@ export const scanReturn = async (req: Request, res: Response) => { confirm_children_diff } = req.body as Record - let departmentUpdate try { - departmentUpdate = await validateReturnDepartmentUpdate(req.body || {}) + assertReturnStorageDepartmentContract(req.body || {}) } catch (error: any) { - return errorResponse(res, error.message || '部门归属处理无效', 422) + return errorResponse(res, error.message || '归还入仓部门契约无效', 422) } const equipment = await getEquipmentByIdentifier(undefined, equipment_code) @@ -933,7 +906,7 @@ export const scanReturn = async (req: Request, res: Response) => { borrowRecord, return_condition, finalNotes, - departmentUpdate.equipmentUpdateData + Number(res.locals.user?.id) ) successResponse( @@ -968,6 +941,12 @@ export const scanReturnBatch = async (req: Request, res: Response) => { notes } = req.body as { equipment_codes: string[]; return_condition?: string; notes?: string } + try { + assertReturnStorageDepartmentContract(req.body || {}) + } catch (error: any) { + return errorResponse(res, error.message || '归还入仓部门契约无效', 422) + } + if (!Array.isArray(equipment_codes) || equipment_codes.length === 0) { return errorResponse(res, '资产编码列表不能为空', 422) } @@ -1015,7 +994,13 @@ export const scanReturnBatch = async (req: Request, res: Response) => { continue } - await returnEquipmentInTransaction(equipment, borrowRecord, return_condition, notes) + await returnEquipmentInTransaction( + equipment, + borrowRecord, + return_condition, + notes, + Number(res.locals.user?.id) + ) results.push({ equipment_code: code, status: 'success', message: '入库成功' }) successCount++ @@ -1350,7 +1335,10 @@ export const resolveBorrowAnomalyAsReturned = async (req: Request, res: Response return errorResponse(res, error.message || '该资产当前不支持异常借出处理', 422) } - const updatedEquipment = await resolveBorrowAnomalyReturnedInTransaction(equipment) + const updatedEquipment = await resolveBorrowAnomalyReturnedInTransaction( + equipment, + Number(res.locals.user?.id) + ) const userId = res.locals.user?.id || null const username = res.locals.user?.username || res.locals.user?.name || 'unknown' await logOperation( diff --git a/backend/src/controllers/dingtalkController.ts b/backend/src/controllers/dingtalkController.ts new file mode 100644 index 0000000..dac5a59 --- /dev/null +++ b/backend/src/controllers/dingtalkController.ts @@ -0,0 +1,188 @@ +import type { Request, Response } from 'express' +import prisma from '../utils/prisma' +import { errorResponse, successResponse } from '../utils/response' +import { resolveAuthUserById } from '../utils/authIdentity' +import { + exchangeDingTalkLoginCode, + getDingTalkConfigStatus, + getDingTalkPublicConfig +} from '../services/dingtalkClient' +import { normalizeInternalRedirect } from '../utils/dingtalkRedirect' + +export const getPublicDingTalkConfig = (_req: Request, res: Response) => { + successResponse(res, getDingTalkPublicConfig(), '获取成功') +} + +export const loginWithDingTalkCode = async (req: Request, res: Response) => { + try { + const { code, redirectPath, redirect_path } = req.body as Record + if (typeof code !== 'string' || !code.trim()) { + return errorResponse(res, '免登码不能为空', 422) + } + const safeRedirect = normalizeInternalRedirect(redirectPath ?? redirect_path) + const dingTalkUserId = await exchangeDingTalkLoginCode(code.trim()) + + const user = await prisma.user.findFirst({ + where: { + status: 'active', + person_id: { not: null }, + person: { + is: { + source_userid: dingTalkUserId, + status: 'active' + } + } + }, + select: { id: true } + }) + if (!user) { + return errorResponse(res, '未找到启用且绑定在职员工档案的系统账号', 403) + } + + const authUser = await resolveAuthUserById(user.id) + await new Promise((resolve, reject) => { + ;(req.session as any).regenerate((error?: Error) => { + if (error) return reject(error) + resolve() + }) + }) + ;(req.session as any).user = { id: user.id } + + successResponse(res, { + user: authUser, + redirectPath: safeRedirect, + redirect_path: safeRedirect + }, '免登成功') + } catch (error: any) { + const status = /跳转地址/.test(error?.message || '') ? 422 : (error?.status || 401) + errorResponse(res, error.message || '钉钉免登失败', status) + } +} + +export const getDingTalkDeliveryStatus = async (_req: Request, res: Response) => { + try { + const [pending, failed, sent, read, unread, errorGroups] = await Promise.all([ + prisma.dingTalkDelivery.count({ where: { status: { in: ['pending', 'retrying', 'sending'] } } }), + prisma.dingTalkDelivery.count({ where: { status: 'failed' } }), + prisma.dingTalkDelivery.count({ where: { status: 'sent' } }), + prisma.dingTalkDelivery.count({ where: { status: 'read' } }), + prisma.dingTalkDelivery.count({ where: { status: 'unread' } }), + prisma.dingTalkDelivery.groupBy({ + by: ['last_error_code', 'last_error_message'], + where: { status: 'failed' }, + _count: { _all: true }, + orderBy: { _count: { id: 'desc' } }, + take: 10 + }) + ]) + const config = getDingTalkConfigStatus() + successResponse(res, { + configured: config.configured, + send_enabled: config.send_enabled, + enabled: config.send_enabled, + missing: config.missing, + pending, + failed, + sent, + read, + unread, + error_summary: errorGroups.map((group) => + `${group.last_error_code || 'UNKNOWN'}:${group.last_error_message || '未知错误'}(${group._count._all})` + ) + }, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '获取钉钉投递状态失败') + } +} + +export const getDingTalkDeliveries = async (req: Request, res: Response) => { + try { + const { page = 1, pageSize = 20, status, business_id, event_type } = req.query + const where: any = {} + if (status) where.status = String(status) + if (business_id) where.business_id = Number(business_id) + if (event_type) where.event_type = String(event_type) + const skip = (Number(page) - 1) * Number(pageSize) + const [list, total] = await Promise.all([ + prisma.dingTalkDelivery.findMany({ + where, + skip, + take: Number(pageSize), + orderBy: { created_at: 'desc' }, + include: { + recipient: { + select: { id: true, username: true, name: true, role: true, status: true } + } + } + }), + prisma.dingTalkDelivery.count({ where }) + ]) + successResponse(res, { + list: list.map((item) => ({ + id: item.id, + event_type: item.event_type, + business_type: item.business_type, + business_id: item.business_id, + recipient_user_id: item.recipient_user_id, + recipient_name: item.recipient.name || item.recipient.username, + recipient: item.recipient, + status: item.status, + attempt_count: item.attempt_count, + max_attempts: item.max_attempts, + next_attempt_at: item.next_attempt_at, + dingtalk_task_id: item.dingtalk_task_id, + last_error_code: item.last_error_code, + last_error: item.last_error_message, + sent_at: item.sent_at, + read_at: item.read_at, + created_at: item.created_at, + updated_at: item.updated_at + })), + total, + page: Number(page), + pageSize: Number(pageSize) + }, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '获取钉钉投递记录失败') + } +} + +export const retryDingTalkDelivery = async (req: Request, res: Response) => { + try { + const id = Number(req.params.id) + const delivery = await prisma.dingTalkDelivery.findUnique({ + where: { id }, + include: { + recipient: { + include: { person: { select: { source_userid: true } } } + } + } + }) + if (!delivery) { + return errorResponse(res, '投递任务不存在', 404) + } + if (delivery.status !== 'failed') { + return errorResponse(res, '只有失败的投递任务可以手工补发', 409) + } + const updated = await prisma.dingTalkDelivery.updateMany({ + where: { id, status: 'failed' }, + data: { + status: 'pending', + recipient_dingtalk_userid: delivery.recipient.person?.source_userid || null, + attempt_count: 0, + next_attempt_at: new Date(), + lease_owner: null, + lease_expires_at: null, + dingtalk_task_id: null, + sent_at: null, + read_at: null, + last_error_code: null, + last_error_message: null + } + }) + if (updated.count !== 1) return errorResponse(res, '投递任务状态已变化,请刷新后重试', 409) + successResponse(res, null, '已加入补发队列') + } catch (error: any) { + errorResponse(res, error.message || '补发失败') + } +} diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index 893089f..770b1a8 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -1,111 +1,319 @@ -import { Request, Response } from 'express' +import type { Request, Response } from 'express' +import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' -import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' -import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' +import { calculateEquipmentValues } from '../utils/assetValue' +import { loadAndLockAssetTree } from '../services/assetMovementService' +import { + createBusinessNotification, + findActiveUsersByIds, + findActiveUsersByRoles, + mergeRecipients +} from '../services/businessNotificationService' -// 创建报废申请 -export const createDisposal = async (req: Request, res: Response) => { - try { - const { equipment_id, disposal_reason, disposal_method, estimated_value, notes } = req.body - const userId = res.locals.user?.id +class DisposalConflictError extends Error { + status = 409 +} - if (!userId) { - return errorResponse(res, '用户未登录', 401) - } +const blockedStatuses = new Set(['已借出', '维护中', '待报废', '已报废']) - const equipment = await prisma.equipment.findUnique({ - where: { id: Number(equipment_id) } - }) - - if (!equipment) { - return errorResponse(res, '资产不存在', 404) - } - - if (equipment.parent_id) { - return errorResponse(res, '该子资产当前已挂载,请先卸载后再申请报废', 422) - } - - if (equipment.status === '待报废' || equipment.status === '已报废') { - return errorResponse(res, '资产已在报废流程中') - } - - try { - await ensureNoActiveBorrowRecords(equipment.id, '资产存在未归还记录,无法申请报废') - } catch (error: any) { - return errorResponse(res, error.message || '资产借用状态校验失败', 422) - } - - const disposal = await prisma.$transaction(async (tx) => { - const 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, - equipment_code: equipment.equipment_code, - disposal_reason, - disposal_method, - estimated_value: Number(estimated_value) || 0, - applied_by: userId, - notes +const disposalInclude = { + equipment: true, + applier: { select: { id: true, name: true, username: true } }, + approver: { select: { id: true, name: true, username: true } }, + completer: { select: { id: true, name: true, username: true } }, + items: { + orderBy: { id: 'asc' as const }, + include: { + equipment: { + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + status: true, + parent_id: true, + storage_location_id: true, + department_id: true } - }) - - await tx.equipment.update({ - where: { id: equipment.id }, - data: { - status: '待报废', - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value - } - }) - - await syncMountedChildrenState(tx, equipment.id, '待报废') - - return created - }) - - // 记录操作日志 - const username = res.locals.user?.username || res.locals.user?.name || 'unknown' - await logOperation( - userId, - username, - '报废申请', - `申请报废资产 ${equipment.equipment_name}(${equipment.equipment_code}),原因:${disposal_reason},处置方式:${disposal_method}`, - equipment.id, - equipment.equipment_code, - req.ip - ) - - successResponse(res, disposal, '报废申请创建成功') - } catch (error: any) { - console.error('[创建报废申请失败]', error) - errorResponse(res, error.message || '创建报废申请失败') + } + } + }, + actions: { + orderBy: [{ created_at: 'asc' as const }, { id: 'asc' as const }], + include: { operator: { select: { id: true, name: true, username: true } } } } } -// 获取报废记录列表 -export const getDisposalList = async (req: Request, res: Response) => { - try { - const { page = 1, pageSize = 10, status, keyword, equipment_id } = req.query - const skip = (Number(page) - 1) * Number(pageSize) +const updateEquipmentLifecycle = async ( + tx: Prisma.TransactionClient, + equipment: { + id: number + unit_price: Prisma.Decimal + total_quantity: number + remaining_quantity: number + status: string + parent_id: number | null + department_id: number | null + mount_status: string + updated_at: Date + }, + targetStatus: string, + expectedStatus: string, + expectedParentId: number | null = equipment.parent_id +) => { + const remainingQuantity = targetStatus === '已借出' ? 0 : equipment.remaining_quantity + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: equipment.total_quantity, + remainingQuantity, + status: targetStatus + }) + const updated = await tx.equipment.updateMany({ + where: { + id: equipment.id, + status: expectedStatus, + parent_id: expectedParentId, + department_id: equipment.department_id, + mount_status: equipment.mount_status, + remaining_quantity: equipment.remaining_quantity, + updated_at: equipment.updated_at + }, + data: { + status: targetStatus, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } + }) + if (updated.count !== 1) { + throw new DisposalConflictError('资产状态或挂载关系已变化,请刷新后重试') + } +} - const where: any = {} - if (status) where.status = status - if (equipment_id) where.equipment_id = Number(equipment_id) - if (keyword) { - where.OR = [ - { equipment_code: { contains: String(keyword) } }, - { disposal_reason: { contains: String(keyword) } } - ] +const getSubmissionRecipients = async (tx: Prisma.TransactionClient) => { + const administrativeUsers = await findActiveUsersByRoles(tx, ['administrative']) + if (administrativeUsers.length > 0) return administrativeUsers + return findActiveUsersByRoles(tx, ['admin']) +} + +const getResultRecipients = async (tx: Prisma.TransactionClient, applierId: number) => { + const [applier, administrativeUsers] = await Promise.all([ + findActiveUsersByIds(tx, [applierId]), + findActiveUsersByRoles(tx, ['administrative']) + ]) + return mergeRecipients(applier, administrativeUsers) +} + +const getCurrentUser = (res: Response) => ({ + id: Number(res.locals.user?.id), + role: String(res.locals.user?.role || '') +}) + +export const createDisposal = async (req: Request, res: Response) => { + try { + const { + equipment_id, + item_ids, + disposal_reason, + disposal_method, + estimated_value, + notes + } = req.body as Record + const userId = Number(res.locals.user?.id) + const rootId = Number(equipment_id) + if (!userId) return errorResponse(res, '用户未登录', 401) + if (!Number.isInteger(rootId) || rootId <= 0) { + return errorResponse(res, '请选择要报废的主资产', 422) + } + if (!disposal_reason?.trim() || !disposal_method?.trim()) { + return errorResponse(res, '报废原因和处置方式不能为空', 422) + } + const estimatedValue = Number(estimated_value ?? 0) + if (!Number.isFinite(estimatedValue) || estimatedValue < 0) { + return errorResponse(res, '残值估价必须是大于或等于0的有效数字', 422) } + const createdId = await prisma.$transaction(async (tx) => { + const tree = await loadAndLockAssetTree(tx, rootId) + const root = tree[0] + if (root.parent_id) { + throw new Error('挂载中的子资产不能单独申请报废,请先卸载') + } + + const requestedIds = Array.isArray(item_ids) + ? [...new Set(item_ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))] + : [rootId] + if (!requestedIds.includes(rootId)) { + throw new Error('报废明细必须包含主资产') + } + + const treeIds = new Set(tree.map((item) => item.id)) + const foreignId = requestedIds.find((id) => !treeIds.has(id)) + if (foreignId) { + throw new Error('报废明细包含不属于当前资产树的资产') + } + const omitted = tree.filter((item) => !requestedIds.includes(item.id)) + if (omitted.length > 0) { + throw new DisposalConflictError( + `配件 ${omitted.map((item) => item.equipment_code).join('、')} 仍处于挂载状态;请先卸载未勾选配件` + ) + } + + const blocked = tree.find((item) => blockedStatuses.has(item.status)) + if (blocked) { + const message = blocked.status === '维护中' + ? '资产正在维修中,请先完成或取消维修' + : `资产 ${blocked.equipment_code} 当前为${blocked.status},不能申请报废` + throw new Error(message) + } + + const ids = tree.map((item) => item.id) + const [activeBorrowCount, activeMaintenanceCount, activeDisposalCount] = await Promise.all([ + tx.borrowRecord.count({ where: { equipment_id: { in: ids }, status: '已借出' } }), + tx.maintenanceRecord.count({ where: { equipment_id: { in: ids }, status: 'in_progress' } }), + tx.disposalItem.count({ + where: { + equipment_id: { in: ids }, + disposal: { status: { in: ['pending', 'approved'] } } + } + }) + ]) + if (activeBorrowCount > 0) throw new Error('资产树存在未归还记录,不能申请报废') + if (activeMaintenanceCount > 0) throw new Error('资产树存在进行中的维修,请先完成或取消维修') + if (activeDisposalCount > 0) throw new Error('资产树已有进行中的报废申请') + + const disposal = await tx.disposalRecord.create({ + data: { + equipment_id: root.id, + equipment_code: root.equipment_code, + disposal_reason: disposal_reason.trim(), + disposal_method: disposal_method.trim(), + estimated_value: estimatedValue, + applied_by: userId, + pre_status: root.status, + notes: notes?.trim() || null, + items: { + create: tree.map((item) => { + const parent = item.parent_id + ? tree.find((candidate) => candidate.id === item.parent_id) + : null + return { + equipment_id: item.id, + equipment_code: item.equipment_code, + equipment_name: item.equipment_name, + parent_equipment_id: item.parent_id, + parent_equipment_code: parent?.equipment_code || null, + pre_status: item.status, + snapshot_json: JSON.stringify({ + equipment_code: item.equipment_code, + equipment_name: item.equipment_name, + parent_id: item.parent_id, + department_id: item.department_id, + storage_location_id: item.storage_location_id, + status: item.status + }) + } + }) + }, + actions: { + create: { + action_type: 'applied', + operator_id: userId, + from_status: null, + to_status: 'pending', + detail: disposal_reason.trim() + } + } + } + }) + + for (const item of tree) { + await updateEquipmentLifecycle(tx, item, '待报废', item.status) + } + + const recipients = await getSubmissionRecipients(tx) + await createBusinessNotification(tx, { + eventType: 'disposal_applied', + businessType: 'disposal', + businessId: disposal.id, + title: `待审批报废:${root.equipment_name}`, + content: `${res.locals.user?.name || res.locals.user?.username} 提交了 ${root.equipment_code} 的报废申请`, + level: 'warning', + actionUrl: `/workflows/disposals/${disposal.id}`, + recipients, + metadata: { equipment_code: root.equipment_code } + }) + + return disposal.id + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) + + const disposal = await prisma.disposalRecord.findUnique({ + where: { id: createdId }, + include: disposalInclude + }) + await logOperation( + userId, + res.locals.user?.username || res.locals.user?.name || 'unknown', + '报废申请', + `申请报废资产 ${disposal?.equipment_code},原因:${disposal_reason}`, + rootId, + disposal?.equipment_code, + req.ip + ) + successResponse(res, disposal, '报废申请创建成功') + } catch (error: any) { + console.error('[创建报废申请失败]', error?.message || error) + errorResponse(res, error.message || '创建报废申请失败', error.status || 422) + } +} + +export const getDisposalList = async (req: Request, res: Response) => { + try { + const { page = 1, pageSize = 10, status, keyword, equipment_id, scope } = req.query + const currentUser = getCurrentUser(res) + const resolvedScope = String(scope || (currentUser.role === 'admin' + ? 'all' + : currentUser.role === 'administrative' + ? 'pending' + : 'mine')) + const where: Prisma.DisposalRecordWhereInput = {} + const scopeConditions: Prisma.DisposalRecordWhereInput[] = [] + + if (currentUser.role === 'it') { + scopeConditions.push({ applied_by: currentUser.id }) + } else if (resolvedScope === 'mine') { + scopeConditions.push({ applied_by: currentUser.id }) + } else if (resolvedScope === 'pending') { + scopeConditions.push({ status: 'pending' }) + } else if (resolvedScope === 'processed') { + scopeConditions.push({ + OR: [ + { approved_by: currentUser.id }, + { completed_by: currentUser.id } + ] + }) + } else if (resolvedScope === 'all' && currentUser.role !== 'admin') { + return errorResponse(res, '只有管理员可以查看全部报废流程', 403) + } else if (!['all', 'mine', 'pending', 'processed'].includes(resolvedScope)) { + return errorResponse(res, '报废列表范围无效', 422) + } + + if (scopeConditions.length > 0) where.AND = scopeConditions + if (status) where.status = String(status) + if (equipment_id) where.equipment_id = Number(equipment_id) + if (keyword) { + const keywordWhere = [ + { equipment_code: { contains: String(keyword) } }, + { disposal_reason: { contains: String(keyword) } }, + { items: { some: { equipment_code: { contains: String(keyword) } } } } + ] + where.AND = [...(Array.isArray(where.AND) ? where.AND : []), { OR: keywordWhere }] + } + + const skip = (Number(page) - 1) * Number(pageSize) const [list, total] = await Promise.all([ prisma.disposalRecord.findMany({ where, @@ -115,201 +323,229 @@ export const getDisposalList = async (req: Request, res: Response) => { include: { equipment: { select: { equipment_name: true, equipment_type: true, unit_price: true } }, applier: { select: { id: true, name: true, username: true } }, - approver: { select: { id: true, name: true, username: true } } + approver: { select: { id: true, name: true, username: true } }, + completer: { select: { id: true, name: true, username: true } }, + _count: { select: { items: true } } } }), prisma.disposalRecord.count({ where }) ]) - successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取报废记录列表失败') } } -// 获取报废记录详情 export const getDisposalDetail = async (req: Request, res: Response) => { try { - const { id } = req.params const disposal = await prisma.disposalRecord.findUnique({ - where: { id: Number(id) }, - include: { - equipment: true, - applier: { select: { id: true, name: true, username: true } }, - approver: { select: { id: true, name: true, username: true } } - } + where: { id: Number(req.params.id) }, + include: disposalInclude }) - - if (!disposal) { - return errorResponse(res, '报废记录不存在', 404) + if (!disposal) return errorResponse(res, '报废记录不存在', 404) + const currentUser = getCurrentUser(res) + if (currentUser.role === 'it' && disposal.applied_by !== currentUser.id) { + return errorResponse(res, '无权查看该报废申请', 403) } - successResponse(res, disposal, '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取报废记录详情失败') } } -// 审批报废申请 export const approveDisposal = async (req: Request, res: Response) => { try { - const { id } = req.params - const { approved, reject_reason } = req.body - const userId = res.locals.user?.id - - if (!userId) { - return errorResponse(res, '用户未登录', 401) + const disposalId = Number(req.params.id) + const { approved, reject_reason } = req.body as { approved?: boolean; reject_reason?: string } + const userId = Number(res.locals.user?.id) + if (typeof approved !== 'boolean') { + return errorResponse(res, '审批结果必须明确为通过或驳回', 422) + } + if (!approved && !reject_reason?.trim()) { + return errorResponse(res, '驳回原因不能为空', 422) } - const disposal = await prisma.disposalRecord.findUnique({ - where: { id: Number(id) }, - include: { equipment: true } - }) - - if (!disposal) { - return errorResponse(res, '报废记录不存在', 404) - } - - if (disposal.status !== 'pending') { - return errorResponse(res, '该申请已处理') - } - - await prisma.$transaction(async (tx) => { - if (approved) { - await tx.disposalRecord.update({ - where: { id: Number(id) }, - data: { status: 'approved', approved_by: userId, approved_at: new Date() } - }) - return + const result = await prisma.$transaction(async (tx) => { + const disposal = await tx.disposalRecord.findUnique({ + where: { id: disposalId }, + include: { items: { include: { equipment: true } } } + }) + if (!disposal) throw Object.assign(new Error('报废记录不存在'), { status: 404 }) + const changedItem = disposal.items.find((item) => + item.equipment.status !== '待报废' || + item.equipment.parent_id !== item.parent_equipment_id + ) + if (changedItem) { + throw new DisposalConflictError( + `资产 ${changedItem.equipment_code} 的状态或挂载关系已变化,请先核查` + ) } - 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 }, + const nextStatus = approved ? 'approved' : 'rejected' + const updated = await tx.disposalRecord.updateMany({ + where: { id: disposalId, status: 'pending' }, data: { - status: '可用', - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value + status: nextStatus, + approved_by: userId, + approved_at: new Date(), + reject_reason: approved ? null : reject_reason?.trim() + } + }) + if (updated.count !== 1) { + throw new DisposalConflictError('该申请已被其他人处理,请刷新后查看') + } + + if (!approved) { + for (const item of disposal.items) { + await updateEquipmentLifecycle( + tx, + item.equipment, + item.pre_status, + '待报废', + item.parent_equipment_id + ) + } + } + + await tx.disposalAction.create({ + data: { + disposal_id: disposalId, + action_type: approved ? 'approved' : 'rejected', + operator_id: userId, + from_status: 'pending', + to_status: nextStatus, + detail: approved ? null : reject_reason?.trim() } }) - await syncMountedChildrenState(tx, disposal.equipment_id, '可用') + const recipients = await getResultRecipients(tx, disposal.applied_by) + await createBusinessNotification(tx, { + eventType: approved ? 'disposal_approved' : 'disposal_rejected', + businessType: 'disposal', + businessId: disposal.id, + title: approved ? `报废已审批:${disposal.equipment_code}` : `报废已驳回:${disposal.equipment_code}`, + content: approved ? '报废申请已审批通过,等待完成处置' : `驳回原因:${reject_reason?.trim()}`, + level: approved ? 'success' : 'warning', + actionUrl: `/workflows/disposals/${disposal.id}`, + recipients + }) + return disposal }) - // 记录操作日志 - const username = res.locals.user?.username || res.locals.user?.name || 'unknown' await logOperation( userId, - username, + res.locals.user?.username || res.locals.user?.name || 'unknown', approved ? '报废审批通过' : '报废审批驳回', - `${approved ? '批准' : '驳回'}资产 ${disposal.equipment?.equipment_name || ''}(${disposal.equipment_code}) 的报废申请${!approved && reject_reason ? `,原因:${reject_reason}` : ''}`, - disposal.equipment_id, - disposal.equipment_code, + `${approved ? '批准' : '驳回'}资产 ${result.equipment_code} 的报废申请`, + result.equipment_id, + result.equipment_code, req.ip ) - successResponse(res, null, approved ? '审批通过' : '已驳回') } catch (error: any) { - errorResponse(res, error.message || '审批失败') + errorResponse(res, error.message || '审批失败', error.status || 500) } } -// 完成报废处置 export const completeDisposal = async (req: Request, res: Response) => { try { - const { id } = req.params - const { actual_value, notes } = req.body - - const disposal = await prisma.disposalRecord.findUnique({ - where: { id: Number(id) } - }) - - if (!disposal) { - return errorResponse(res, '报废记录不存在', 404) + const disposalId = Number(req.params.id) + const { actual_value, completion_notes, notes } = req.body as Record + const userId = Number(res.locals.user?.id) + const actualValue = actual_value === undefined ? undefined : Number(actual_value) + if (actualValue !== undefined && (!Number.isFinite(actualValue) || actualValue < 0)) { + return errorResponse(res, '实际处置价值必须是大于或等于0的有效数字', 422) } - if (disposal.status !== 'approved') { - return errorResponse(res, '只有已审批的申请才能完成处置') - } - - await prisma.$transaction(async (tx) => { - const equipment = await tx.equipment.findUnique({ - where: { id: disposal.equipment_id } + const result = await prisma.$transaction(async (tx) => { + const disposal = await tx.disposalRecord.findUnique({ + where: { id: disposalId }, + include: { items: { include: { equipment: true } } } }) + if (!disposal) throw Object.assign(new Error('报废记录不存在'), { status: 404 }) - const 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) }, + const updated = await tx.disposalRecord.updateMany({ + where: { id: disposalId, status: 'approved' }, data: { status: 'completed', - actual_value: actual_value !== undefined ? Number(actual_value) : disposal.estimated_value, + actual_value: actualValue !== undefined ? actualValue : disposal.estimated_value, completed_at: new Date(), - notes: notes || disposal.notes + completed_by: userId, + completion_notes: completion_notes?.trim() || notes?.trim() || null } }) + if (updated.count !== 1) { + throw new DisposalConflictError('该申请状态已变化,请刷新后查看') + } - await tx.equipment.update({ - where: { id: disposal.equipment_id }, + for (const item of disposal.items) { + await updateEquipmentLifecycle( + tx, + item.equipment, + '已报废', + '待报废', + item.parent_equipment_id + ) + } + await tx.disposalAction.create({ data: { - status: '已报废', - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value + disposal_id: disposalId, + action_type: 'completed', + operator_id: userId, + from_status: 'approved', + to_status: 'completed', + detail: completion_notes?.trim() || notes?.trim() || null } }) - await syncMountedChildrenState(tx, disposal.equipment_id, '已报废') + const recipients = await getResultRecipients(tx, disposal.applied_by) + await createBusinessNotification(tx, { + eventType: 'disposal_completed', + businessType: 'disposal', + businessId: disposal.id, + title: `报废处置已完成:${disposal.equipment_code}`, + content: completion_notes?.trim() || notes?.trim() || '资产已完成报废处置', + level: 'success', + actionUrl: `/workflows/disposals/${disposal.id}`, + recipients + }) + return disposal }) - // 记录操作日志 - const userId = res.locals.user?.id - const username = res.locals.user?.username || res.locals.user?.name || 'unknown' await logOperation( userId, - username, + res.locals.user?.username || res.locals.user?.name || 'unknown', '报废完成', - `完成资产 (${disposal.equipment_code}) 的报废处置,实际残值:¥${actual_value !== undefined ? actual_value : disposal.estimated_value}`, - disposal.equipment_id, - disposal.equipment_code, + `完成资产 ${result.equipment_code} 的报废处置`, + result.equipment_id, + result.equipment_code, req.ip ) - successResponse(res, null, '报废处置完成') } catch (error: any) { - errorResponse(res, error.message || '完成报废处置失败') + errorResponse(res, error.message || '完成报废处置失败', error.status || 500) } } -// 获取资产的报废历史 export const getEquipmentDisposalHistory = async (req: Request, res: Response) => { try { - const { equipment_id } = req.params + const equipmentId = Number(req.params.equipment_id) const history = await prisma.disposalRecord.findMany({ - where: { equipment_id: Number(equipment_id) }, + where: { + OR: [ + { equipment_id: equipmentId }, + { items: { some: { equipment_id: equipmentId } } } + ] + }, orderBy: { created_at: 'desc' }, include: { applier: { select: { id: true, name: true } }, - approver: { select: { id: true, name: true } } + approver: { select: { id: true, name: true } }, + completer: { select: { id: true, name: true } }, + items: true, + actions: { orderBy: { created_at: 'asc' } } } }) - successResponse(res, history, '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取报废历史失败') diff --git a/backend/src/controllers/equipmentController.ts b/backend/src/controllers/equipmentController.ts index 640f44d..0c82d5e 100644 --- a/backend/src/controllers/equipmentController.ts +++ b/backend/src/controllers/equipmentController.ts @@ -121,6 +121,94 @@ const includeEquipmentRelations = { ...activeBorrowSelect } +const loadRecursiveEquipmentDetail = async (rootId: number) => { + const root = await prisma.equipment.findUnique({ + where: { id: rootId }, + include: { + ...includeStorageLocation, + parent: { + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + unit_price: true + } + }, + ...activeBorrowSelect + } + }) + if (!root) return null + + const all: any[] = [{ ...root, children: [] }] + const visited = new Set([root.id]) + let parentIds = [root.id] + let depth = 0 + while (parentIds.length > 0) { + if (depth >= 100) { + throw new Error('资产父子关系层级过深,请先检查资产关系') + } + const children = await prisma.equipment.findMany({ + where: { parent_id: { in: parentIds } }, + orderBy: { id: 'asc' }, + include: { + ...includeStorageLocation, + parent: { + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + unit_price: true + } + }, + ...activeBorrowSelect + } + }) + if (children.length === 0) break + + const repeated = children.find((child) => visited.has(child.id)) + if (repeated) { + throw new Error(`资产 ${repeated.equipment_code} 存在循环父子关系,请先修复关系`) + } + + children.forEach((child) => visited.add(child.id)) + all.push(...children.map((child) => ({ ...child, children: [] }))) + parentIds = children.map((child) => child.id) + depth += 1 + } + + const byId = new Map(all.map((item) => [item.id, item])) + for (const item of all.slice(1)) { + byId.get(item.parent_id)?.children.push(item) + } + return byId.get(root.id) +} + +const getEquipmentBusinessHistoryCount = async (equipmentId: number, client: any = prisma) => { + const counts = await Promise.all([ + client.borrowRecord.count({ where: { equipment_id: equipmentId } }), + client.maintenanceRecord.count({ where: { equipment_id: equipmentId } }), + client.disposalRecord.count({ where: { equipment_id: equipmentId } }), + client.disposalItem.count({ where: { equipment_id: equipmentId } }), + client.inventoryTaskItem.count({ where: { equipment_id: equipmentId } }), + client.assetTransferRecord.count({ where: { root_equipment_id: equipmentId } }), + client.assetTransferItem.count({ where: { equipment_id: equipmentId } }), + client.operationLog.count({ where: { equipment_id: equipmentId } }), + client.mountHistory.count({ + where: { + OR: [ + { child_id: equipmentId }, + { parent_id: equipmentId }, + { old_parent_id: equipmentId }, + { new_parent_id: equipmentId } + ] + } + }) + ]) + return counts.reduce((sum, count) => sum + count, 0) +} + const toOptionalNumber = (value: unknown) => { if (value === undefined || value === null || value === '') return null const numericValue = Number(value) @@ -206,6 +294,64 @@ const normalizeEntryMode = (value: unknown): EquipmentEntryMode => { return 'single' } +const ensureAdministrativeCreateAllowed = (req: Request, entryMode: EquipmentEntryMode) => { + if (req.res?.locals.user?.role !== 'administrative') { + return + } + + if (Array.isArray(req.body.children) && req.body.children.length > 0) { + throw new Error('行政角色不能在登记时建立父子资产关系,请由 IT 登记后单独挂载') + } + if (req.body.department_id !== undefined && req.body.department_id !== null && req.body.department_id !== '') { + throw new Error('行政角色登记资产时不能直接分配部门') + } + if (req.body.status && req.body.status !== '可用') { + throw new Error('行政角色登记资产时只能登记为可用状态') + } +} + +const ensureAdministrativeCorrectionAllowed = async ( + req: Request, + equipment: any, + client: any = prisma, + role?: string +) => { + if ((role || req.res?.locals.user?.role) !== 'administrative') { + return + } + + const forbiddenFields = ['status', 'department_id', 'parent_id', 'mount_status', 'remaining_quantity'] + const changedForbiddenField = forbiddenFields.find((field) => + Object.prototype.hasOwnProperty.call(req.body, field) + ) + if (changedForbiddenField) { + throw new Error('行政角色只能纠错基础资料,不能直接修改状态、部门或父子关系') + } + if (equipment.status !== '可用') { + throw new Error('行政角色只能纠错处于可用状态的资产') + } + + const [borrowCount, maintenanceCount, disposalCount] = await Promise.all([ + client.borrowRecord.count({ where: { equipment_id: equipment.id, status: '已借出' } }), + client.maintenanceRecord.count({ where: { equipment_id: equipment.id, status: 'in_progress' } }), + client.disposalItem.count({ + where: { + equipment_id: equipment.id, + disposal: { status: { in: ['pending', 'approved'] } } + } + }) + ]) + if (borrowCount > 0 || maintenanceCount > 0 || disposalCount > 0) { + throw new Error('资产存在进行中的借用、维修或报废业务,行政角色不能纠错') + } +} + +const ensureEquipmentCodeHasNoBusinessHistory = async (equipmentId: number, client: any = prisma) => { + if (await getEquipmentBusinessHistoryCount(equipmentId, client) > 0) { + throw new Error('资产已有业务历史,不能修改资产编码') + } +} + const EDIT_LOCKED_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏']) const resolveEditableEquipmentStatus = ({ @@ -274,6 +420,36 @@ const ensureAssetTypeExists = async (client: any, equipmentType?: string, assetT return assetType } +const ensureUpdateAssetTypeExists = async ( + client: any, + equipmentType?: string, + assetTypeId?: number | null +) => { + try { + return await ensureAssetTypeExists(client, equipmentType, assetTypeId) + } catch (error: any) { + error.statusCode = 422 + throw error + } +} + +const ensureAssetTypeSupportsCurrentRelation = ( + assetType: { can_be_parent: boolean; can_be_child: boolean }, + parentId: number | null, + childCount: number +) => { + if (parentId && !assetType.can_be_child) { + const error: any = new Error('当前资产已挂载在父资产下,新类型必须允许作为子资产') + error.statusCode = 422 + throw error + } + if (childCount > 0 && !assetType.can_be_parent) { + const error: any = new Error('当前资产仍挂载子资产,新类型必须允许作为父资产') + error.statusCode = 422 + throw error + } +} + const ensureBusinessDepartmentExists = async (client: any, departmentId?: number | null) => { if (!departmentId) { return null @@ -686,25 +862,49 @@ export const getEquipmentDetail = async (req: Request, res: Response) => { try { const { id } = req.params - const equipment = await prisma.equipment.findUnique({ - where: { id: Number(id) }, - include: includeEquipmentRelations - }) + const equipment = await loadRecursiveEquipmentDetail(Number(id)) if (!equipment) { return errorResponse(res, '资产不存在', 404) } - successResponse(res, equipment, '获取成功') + const businessHistoryCount = await getEquipmentBusinessHistoryCount(equipment.id) + successResponse(res, { + ...equipment, + can_correct_code: businessHistoryCount === 0 + }, '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取资产详情失败') } } +export const getEquipmentTransferHistory = async (req: Request, res: Response) => { + try { + const equipmentId = Number(req.params.id) + const history = await prisma.assetTransferRecord.findMany({ + where: { + OR: [ + { root_equipment_id: equipmentId }, + { items: { some: { equipment_id: equipmentId } } } + ] + }, + orderBy: { operated_at: 'desc' }, + include: { + operator: { select: { id: true, name: true, username: true } }, + items: { orderBy: { id: 'asc' } } + } + }) + successResponse(res, history, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '获取资产流转历史失败') + } +} + // 新增资产 export const createEquipment = async (req: Request, res: Response) => { try { const entryMode = normalizeEntryMode(req.body.entry_mode) + ensureAdministrativeCreateAllowed(req, entryMode) const userId = Number(res.locals.user?.id) const locationId = toOptionalNumber(req.body.storage_location_id) const departmentId = toOptionalNumber(req.body.department_id) @@ -869,6 +1069,7 @@ export const updateEquipment = async (req: Request, res: Response) => { try { const { id } = req.params const { + equipment_code, equipment_name, equipment_model, equipment_type, @@ -891,6 +1092,37 @@ export const updateEquipment = async (req: Request, res: Response) => { if (!currentEquipment) { return errorResponse(res, '资产不存在', 404) } + + const controlledFields = ['status', 'department_id', 'parent_id', 'mount_status', 'remaining_quantity'] + const submittedControlledField = controlledFields.find((field) => + Object.prototype.hasOwnProperty.call(req.body, field) + ) + if (submittedControlledField) { + return errorResponse( + res, + '状态、部门、剩余数量和父子关系必须通过正式业务流程变更', + 422 + ) + } + + const currentRole = res.locals.user?.role as string | undefined + await ensureAdministrativeCorrectionAllowed(req, currentEquipment, prisma, currentRole) + + let normalizedEquipmentCode = currentEquipment.equipment_code + if (equipment_code !== undefined && equipment_code !== currentEquipment.equipment_code) { + normalizedEquipmentCode = String(equipment_code).trim().toUpperCase() + if (!validateEquipmentCode(normalizedEquipmentCode)) { + return errorResponse(res, '资产编码格式不正确', 422) + } + await ensureEquipmentCodeHasNoBusinessHistory(currentEquipment.id) + const duplicate = await prisma.equipment.findUnique({ + where: { equipment_code: normalizedEquipmentCode }, + select: { id: true } + }) + if (duplicate && duplicate.id !== currentEquipment.id) { + return errorResponse(res, '资产编码已存在', 409) + } + } let targetLocationId = currentEquipment.storage_location_id if (storage_location_id !== undefined) { @@ -910,14 +1142,51 @@ export const updateEquipment = async (req: Request, res: Response) => { } await ensureBusinessDepartmentExists(prisma, targetDeptId) - // 处理资产类型ID + // 资产类型名称与外键必须成对更新,并且不能破坏已有父子关系的类型能力。 + const assetTypeChangeRequested = + Object.prototype.hasOwnProperty.call(req.body, 'equipment_type') || + Object.prototype.hasOwnProperty.call(req.body, 'asset_type_id') let targetTypeId = currentEquipment.asset_type_id - if (asset_type_id !== undefined) { - targetTypeId = asset_type_id ? Number(asset_type_id) : null + let targetEquipmentType = currentEquipment.equipment_type + if (assetTypeChangeRequested) { + let requestedTypeId: number | null = null + if (asset_type_id !== undefined && asset_type_id !== null && asset_type_id !== '') { + requestedTypeId = Number(asset_type_id) + if (!Number.isInteger(requestedTypeId) || requestedTypeId <= 0) { + return errorResponse(res, '资产类型参数不合法', 422) + } + } else if (equipment_type === undefined) { + requestedTypeId = currentEquipment.asset_type_id + } + + const requestedTypeName = equipment_type === undefined + ? currentEquipment.equipment_type + : String(equipment_type).trim() + const resolvedAssetType = await ensureUpdateAssetTypeExists( + prisma, + requestedTypeName, + requestedTypeId + ) + const currentChildCount = await prisma.equipment.count({ + where: { parent_id: currentEquipment.id } + }) + ensureAssetTypeSupportsCurrentRelation( + resolvedAssetType, + currentEquipment.parent_id, + currentChildCount + ) + targetTypeId = resolvedAssetType.id + targetEquipmentType = resolvedAssetType.name } // 计算价值与剩余库存同步逻辑 const finalTotalQuantity = total_quantity !== undefined ? Number(total_quantity) : currentEquipment.total_quantity + if ( + total_quantity !== undefined && + (!Number.isInteger(finalTotalQuantity) || finalTotalQuantity < 1) + ) { + return errorResponse(res, '资产总数量必须是大于或等于 1 的整数', 422) + } const finalUnitPrice = unit_price !== undefined ? Number(unit_price) : Number(currentEquipment.unit_price) // 已借数量 = 原总数 - 原剩余数(保证不为负) const borrowedQuantity = Math.max(currentEquipment.total_quantity - currentEquipment.remaining_quantity, 0) @@ -931,6 +1200,13 @@ export const updateEquipment = async (req: Request, res: Response) => { requestedStatus: status, remainingQuantity: newRemainingQuantity }) + if (currentRole === 'administrative' && resolvedStatus !== '可用') { + return errorResponse( + res, + '该数量纠错会改变资产可用状态,请先核对数量或由管理员处理', + 422 + ) + } const values = calculateEquipmentValues({ unitPrice: finalUnitPrice, totalQuantity: finalTotalQuantity, @@ -938,91 +1214,137 @@ export const updateEquipment = async (req: Request, res: Response) => { status: resolvedStatus }) - // 调试日志:观察请求与计算过程 - console.log('[更新资产] 输入参数:', { - id, - body: { - equipment_name, - equipment_model, - equipment_type, - unit_price, - total_quantity, - status, - purchase_date, - supplier, - description, - storage_location_id, - department_id, - asset_type_id + // 基础资料纠错只写入本次明确提交的字段,避免把读取时的部门、状态等快照再次全量覆盖。 + const correctionUpdateData: Record = {} + const assignIfSubmitted = (field: string, value: unknown) => { + if (Object.prototype.hasOwnProperty.call(req.body, field)) { + correctionUpdateData[field] = value } - }) - console.log('[更新资产] 当前资产:', { - current_total: currentEquipment.total_quantity, - current_remaining: currentEquipment.remaining_quantity, - current_status: currentEquipment.status - }) - console.log('[更新资产] 计算结果:', { - borrowedQuantity, - finalTotalQuantity, - newRemainingQuantity, - finalUnitPrice, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value - }) - - await prisma.equipment.update({ - where: { id: Number(id) }, - data: { - equipment_name, - equipment_model, - equipment_type, - unit_price: finalUnitPrice, - total_quantity: finalTotalQuantity, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value, - // 若未显式传入状态,则根据剩余库存自动设定 - status: resolvedStatus, - purchase_date: purchase_date ? new Date(purchase_date) : currentEquipment.purchase_date, - supplier, - description, - storage_location_id: targetLocationId, - department_id: targetDeptId, - asset_type_id: targetTypeId, - remaining_quantity: newRemainingQuantity + } + assignIfSubmitted('equipment_name', equipment_name) + assignIfSubmitted('equipment_model', equipment_model) + assignIfSubmitted('supplier', supplier) + assignIfSubmitted('description', description) + if (assetTypeChangeRequested) { + correctionUpdateData.equipment_type = targetEquipmentType + correctionUpdateData.asset_type_id = targetTypeId + } + if (Object.prototype.hasOwnProperty.call(req.body, 'equipment_code')) { + correctionUpdateData.equipment_code = normalizedEquipmentCode + if (normalizedEquipmentCode !== currentEquipment.equipment_code) { + correctionUpdateData.code_generation_mode = 'manual' } - }) + } + if (Object.prototype.hasOwnProperty.call(req.body, 'purchase_date') && purchase_date) { + correctionUpdateData.purchase_date = new Date(purchase_date) + } + if (Object.prototype.hasOwnProperty.call(req.body, 'storage_location_id')) { + correctionUpdateData.storage_location_id = targetLocationId + } + if (Object.prototype.hasOwnProperty.call(req.body, 'unit_price')) { + correctionUpdateData.unit_price = finalUnitPrice + correctionUpdateData.total_value = values.total_value + correctionUpdateData.borrowed_value = values.borrowed_value + correctionUpdateData.available_value = values.available_value + } + if (Object.prototype.hasOwnProperty.call(req.body, 'total_quantity')) { + correctionUpdateData.total_quantity = finalTotalQuantity + correctionUpdateData.remaining_quantity = newRemainingQuantity + correctionUpdateData.status = resolvedStatus + correctionUpdateData.total_value = values.total_value + correctionUpdateData.borrowed_value = values.borrowed_value + correctionUpdateData.available_value = values.available_value + } - const childCount = await prisma.equipment.count({ - where: { parent_id: Number(id) } - }) + const updateData = correctionUpdateData + if (Object.keys(updateData).length === 0) { + const unchangedEquipment = await prisma.equipment.findUnique({ + where: { id: Number(id) }, + include: includeStorageLocation + }) + return successResponse(res, unchangedEquipment, '无需更新') + } - if ( - childCount > 0 && - ['可用', '已借出', '维护中', '待报废', '已报废'].includes(resolvedStatus) - ) { - await syncMountedChildrenState( - prisma, - Number(id), - resolvedStatus as '可用' | '已借出' | '维护中' | '待报废' | '已报废' + const updated = await prisma.$transaction(async (tx) => { + await tx.$queryRawUnsafe( + `SELECT id FROM equipment WHERE id = ${currentEquipment.id} FOR UPDATE` ) - } - if (childCount > 0) { - await refreshParentTotalValueWithChildren(prisma, Number(id)) - } + await ensureAdministrativeCorrectionAllowed(req, currentEquipment, tx, currentRole) + if (assetTypeChangeRequested) { + const resolvedAssetType = await ensureUpdateAssetTypeExists( + tx, + targetEquipmentType, + targetTypeId + ) + const currentChildCount = await tx.equipment.count({ + where: { parent_id: currentEquipment.id } + }) + ensureAssetTypeSupportsCurrentRelation( + resolvedAssetType, + currentEquipment.parent_id, + currentChildCount + ) + } + if (normalizedEquipmentCode !== currentEquipment.equipment_code) { + await ensureEquipmentCodeHasNoBusinessHistory(currentEquipment.id, tx) + const duplicate = await tx.equipment.findUnique({ + where: { equipment_code: normalizedEquipmentCode }, + select: { id: true } + }) + if (duplicate && duplicate.id !== currentEquipment.id) { + const duplicateError: any = new Error('资产编码已存在') + duplicateError.statusCode = 409 + throw duplicateError + } + } - if (currentEquipment.parent_id) { - await refreshParentTotalValueWithChildren(prisma, currentEquipment.parent_id) - } + const claimed = await tx.equipment.updateMany({ + where: { + id: Number(id), + status: currentEquipment.status, + department_id: currentEquipment.department_id, + parent_id: currentEquipment.parent_id, + remaining_quantity: currentEquipment.remaining_quantity, + updated_at: currentEquipment.updated_at + }, + data: updateData + }) + if (claimed.count !== 1) { + return false + } - console.log('[更新资产] 已写入:', { - id, - total_quantity: finalTotalQuantity, - remaining_quantity: newRemainingQuantity + const childCount = await tx.equipment.count({ + where: { parent_id: Number(id) } + }) + + if ( + childCount > 0 && + ['可用', '已借出', '维护中', '待报废', '已报废'].includes(resolvedStatus) && + Object.prototype.hasOwnProperty.call(updateData, 'status') + ) { + await syncMountedChildrenState( + tx, + Number(id), + resolvedStatus as '可用' | '已借出' | '维护中' | '待报废' | '已报废' + ) + } + + if (childCount > 0) { + await refreshParentTotalValueWithChildren(tx, Number(id)) + } + + if (currentEquipment.parent_id) { + await refreshParentTotalValueWithChildren(tx, currentEquipment.parent_id) + } + + return true }) + if (!updated) { + return errorResponse(res, '资产状态、部门或挂载关系已变化,请刷新后重试', 409) + } + const equipment = await prisma.equipment.findUnique({ where: { id: Number(id) }, include: includeStorageLocation @@ -1030,7 +1352,8 @@ export const updateEquipment = async (req: Request, res: Response) => { successResponse(res, equipment, '更新成功') } catch (error: any) { - errorResponse(res, error.message || '更新资产失败') + const statusCode = error.statusCode || (error.code === 'P2002' ? 409 : 400) + errorResponse(res, error.message || '更新资产失败', statusCode) } } @@ -1050,8 +1373,11 @@ export const deleteEquipment = async (req: Request, res: Response) => { children: true, mountHistories: true, disposalRecords: true, + disposalItems: true, maintenanceRecords: true, - inventoryTaskItems: true + inventoryTaskItems: true, + transferRootRecords: true, + transferItems: true } } } @@ -1061,14 +1387,32 @@ export const deleteEquipment = async (req: Request, res: Response) => { return errorResponse(res, '资产不存在', 404) } + const [relatedMountHistoryCount, operationLogCount] = await Promise.all([ + prisma.mountHistory.count({ + where: { + OR: [ + { child_id: equipmentId }, + { parent_id: equipmentId }, + { old_parent_id: equipmentId }, + { new_parent_id: equipmentId } + ] + } + }), + prisma.operationLog.count({ where: { equipment_id: equipmentId } }) + ]) + const blockedReason = getEquipmentDeleteBlockedReason({ borrowRecordCount: equipment._count.borrow_records, isMountedChild: Boolean(equipment.parent_id), childCount: equipment._count.children, - mountHistoryCount: equipment._count.mountHistories, + mountHistoryCount: relatedMountHistoryCount, disposalRecordCount: equipment._count.disposalRecords, + disposalItemCount: equipment._count.disposalItems, maintenanceRecordCount: equipment._count.maintenanceRecords, - inventoryTaskItemCount: equipment._count.inventoryTaskItems + inventoryTaskItemCount: equipment._count.inventoryTaskItems, + transferHistoryCount: + equipment._count.transferRootRecords + equipment._count.transferItems, + operationLogCount }) if (blockedReason) { diff --git a/backend/src/controllers/maintenanceController.ts b/backend/src/controllers/maintenanceController.ts index 0f36f18..6c346c0 100644 --- a/backend/src/controllers/maintenanceController.ts +++ b/backend/src/controllers/maintenanceController.ts @@ -2,51 +2,97 @@ import { Request, Response } from 'express' 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' +import { loadAssetTree } from '../services/assetMovementService' + +class MaintenanceConflictError extends Error { + status = 409 +} + +const blockedMaintenanceStatuses = new Set(['已借出', '维护中', '待报废', '已报废']) // 创建维修记录 export const createMaintenance = async (req: Request, res: Response) => { try { - const { equipment_id, issue_description, repair_vendor, estimated_cost, expected_end_date, notes } = req.body - const userId = res.locals.user?.id + const { equipment_id, issue_description, repair_vendor, estimated_cost, expected_end_date } = req.body + const userId = Number(res.locals.user?.id) + const equipmentId = Number(equipment_id) if (!userId) { return errorResponse(res, '用户未登录', 401) } - - const equipment = await prisma.equipment.findUnique({ - where: { id: Number(equipment_id) } - }) - - if (!equipment) { - return errorResponse(res, '资产不存在', 404) + if (!Number.isInteger(equipmentId) || equipmentId <= 0) { + return errorResponse(res, '资产ID无效', 422) } - if (equipment.parent_id) { - return errorResponse(res, '该子资产当前已挂载,请先卸载后再送修', 422) - } + const result = await prisma.$transaction(async (tx) => { + const tree = await loadAssetTree(tx, equipmentId) + const equipment = tree[0] + if (equipment.parent_id) { + throw Object.assign(new Error('该子资产当前已挂载,请先卸载后再送修'), { status: 422 }) + } - if (equipment.status === '维护中') { - return errorResponse(res, '资产已在维修中') - } - if (equipment.status === '待报废' || equipment.status === '已报废') { - return errorResponse(res, '资产已报废,无法送修') - } + const blocked = tree.find((item) => blockedMaintenanceStatuses.has(item.status)) + if (blocked) { + const message = blocked.status === '维护中' + ? `资产 ${blocked.equipment_code} 已在维修中` + : blocked.status === '已借出' + ? `资产 ${blocked.equipment_code} 尚未归还,无法送修` + : `资产 ${blocked.equipment_code} 已在报废流程中或已报废,无法送修` + throw Object.assign(new Error(message), { status: 422 }) + } - try { - await ensureNoActiveBorrowRecords(equipment.id, '资产存在未归还记录,无法送修') - } catch (error: any) { - return errorResponse(res, error.message || '资产借用状态校验失败', 422) - } + const equipmentIds = tree.map((item) => item.id) + const [activeBorrowCount, activeDisposalCount, activeMaintenanceCount] = await Promise.all([ + tx.borrowRecord.count({ + where: { equipment_id: { in: equipmentIds }, status: '已借出' } + }), + tx.disposalItem.count({ + where: { + equipment_id: { in: equipmentIds }, + disposal: { status: { in: ['pending', 'approved'] } } + } + }), + tx.maintenanceRecord.count({ + where: { equipment_id: { in: equipmentIds }, status: 'in_progress' } + }) + ]) + if (activeBorrowCount > 0) { + throw Object.assign(new Error('资产树存在未归还记录,无法送修'), { status: 422 }) + } + if (activeDisposalCount > 0) { + throw Object.assign(new Error('资产树存在进行中的报废申请,无法送修'), { status: 422 }) + } + if (activeMaintenanceCount > 0) { + throw new MaintenanceConflictError('资产树已有进行中的维修记录,请刷新后查看') + } - 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 acquired = await tx.equipment.updateMany({ + where: { + id: equipment.id, + status: equipment.status, + parent_id: null, + remaining_quantity: equipment.remaining_quantity, + department_id: equipment.department_id + }, + data: { + status: '维护中', + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + } + }) + if (acquired.count !== 1) { + throw new MaintenanceConflictError('资产状态、库存或归属已变化,请刷新后重试') + } + const created = await tx.maintenanceRecord.create({ data: { equipment_id: equipment.id, @@ -59,19 +105,9 @@ export const createMaintenance = async (req: Request, res: Response) => { } }) - await tx.equipment.update({ - where: { id: equipment.id }, - data: { - status: '维护中', - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value - } - }) - await syncMountedChildrenState(tx, equipment.id, '维护中') - return created + return { maintenance: created, equipment } }) // 记录操作日志 @@ -80,16 +116,16 @@ export const createMaintenance = async (req: Request, res: Response) => { userId, username, '送修', - `资产 ${equipment.equipment_name}(${equipment.equipment_code}) 送修,故障描述:${issue_description}${repair_vendor ? `,维修商:${repair_vendor}` : ''}`, - equipment.id, - equipment.equipment_code, + `资产 ${result.equipment.equipment_name}(${result.equipment.equipment_code}) 送修,故障描述:${issue_description}${repair_vendor ? `,维修商:${repair_vendor}` : ''}`, + result.equipment.id, + result.equipment.equipment_code, req.ip ) - successResponse(res, maintenance, '维修记录创建成功') + successResponse(res, result.maintenance, '维修记录创建成功') } catch (error: any) { console.error('[创建维修记录失败]', error) - errorResponse(res, error.message || '创建维修记录失败') + errorResponse(res, error.message || '创建维修记录失败', error.status || 500) } } @@ -190,46 +226,49 @@ export const completeMaintenance = async (req: Request, res: Response) => { try { const { id } = req.params const { actual_cost, repair_result } = req.body + const maintenanceId = Number(id) - const maintenance = await prisma.maintenanceRecord.findUnique({ - where: { id: Number(id) } - }) + const maintenance = await prisma.$transaction(async (tx) => { + const currentMaintenance = await tx.maintenanceRecord.findUnique({ + where: { id: maintenanceId } + }) + if (!currentMaintenance) { + throw Object.assign(new Error('维修记录不存在'), { status: 404 }) + } - if (!maintenance) { - return errorResponse(res, '维修记录不存在', 404) - } + const equipment = await tx.equipment.findUnique({ + where: { id: currentMaintenance.equipment_id } + }) + if (!equipment) { + throw Object.assign(new Error('资产不存在'), { status: 404 }) + } - if (maintenance.status !== 'in_progress') { - 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) }, + const updatedMaintenance = await tx.maintenanceRecord.updateMany({ + where: { id: maintenanceId, status: 'in_progress' }, data: { status: 'completed', - actual_cost: actual_cost !== undefined ? Number(actual_cost) : maintenance.estimated_cost, + actual_cost: + actual_cost !== undefined ? Number(actual_cost) : currentMaintenance.estimated_cost, actual_end_date: new Date(), repair_result } }) + if (updatedMaintenance.count !== 1) { + throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看') + } - await tx.equipment.update({ - where: { id: maintenance.equipment_id }, + const updatedEquipment = await tx.equipment.updateMany({ + where: { + id: currentMaintenance.equipment_id, + status: '维护中', + parent_id: null + }, data: { status: '可用', total_value: values.total_value, @@ -237,8 +276,12 @@ export const completeMaintenance = async (req: Request, res: Response) => { available_value: values.available_value } }) + if (updatedEquipment.count !== 1) { + throw new MaintenanceConflictError('资产已不处于维修状态,不能覆盖当前状态') + } await syncMountedChildrenState(tx, equipment.id, '可用') + return currentMaintenance }) // 记录操作日志 @@ -256,7 +299,7 @@ export const completeMaintenance = async (req: Request, res: Response) => { successResponse(res, null, '维修完成') } catch (error: any) { - errorResponse(res, error.message || '完成维修失败') + errorResponse(res, error.message || '完成维修失败', error.status || 500) } } @@ -265,41 +308,43 @@ export const cancelMaintenance = async (req: Request, res: Response) => { try { const { id } = req.params const { reason } = req.body + const maintenanceId = Number(id) - const maintenance = await prisma.maintenanceRecord.findUnique({ - where: { id: Number(id) } - }) + const maintenance = await prisma.$transaction(async (tx) => { + const currentMaintenance = await tx.maintenanceRecord.findUnique({ + where: { id: maintenanceId } + }) + if (!currentMaintenance) { + throw Object.assign(new Error('维修记录不存在'), { status: 404 }) + } - if (!maintenance) { - return errorResponse(res, '维修记录不存在', 404) - } + const equipment = await tx.equipment.findUnique({ + where: { id: currentMaintenance.equipment_id } + }) + if (!equipment) { + throw Object.assign(new Error('资产不存在'), { status: 404 }) + } - if (maintenance.status !== 'in_progress') { - 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) }, + const updatedMaintenance = await tx.maintenanceRecord.updateMany({ + where: { id: maintenanceId, status: 'in_progress' }, data: { status: 'cancelled', repair_result: reason || '已取消' } }) + if (updatedMaintenance.count !== 1) { + throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看') + } - await tx.equipment.update({ - where: { id: maintenance.equipment_id }, + const updatedEquipment = await tx.equipment.updateMany({ + where: { + id: currentMaintenance.equipment_id, + status: '维护中', + parent_id: null + }, data: { status: '可用', total_value: values.total_value, @@ -307,8 +352,12 @@ export const cancelMaintenance = async (req: Request, res: Response) => { available_value: values.available_value } }) + if (updatedEquipment.count !== 1) { + throw new MaintenanceConflictError('资产已不处于维修状态,不能覆盖当前状态') + } await syncMountedChildrenState(tx, equipment.id, '可用') + return currentMaintenance }) // 记录操作日志 @@ -326,7 +375,7 @@ export const cancelMaintenance = async (req: Request, res: Response) => { successResponse(res, null, '维修已取消') } catch (error: any) { - errorResponse(res, error.message || '取消维修失败') + errorResponse(res, error.message || '取消维修失败', error.status || 500) } } diff --git a/backend/src/controllers/mountController.ts b/backend/src/controllers/mountController.ts index d67e136..a5764ab 100644 --- a/backend/src/controllers/mountController.ts +++ b/backend/src/controllers/mountController.ts @@ -1,33 +1,37 @@ import { Request, Response } from 'express' +import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' -import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' import { calculateEquipmentValues, calculateChildrenTotalValueFromRecords, refreshParentTotalValueWithChildren } from '../utils/assetValue' +import { + loadAndLockAssetTree, + lockEquipmentRows, + type AssetTreeItem +} from '../services/assetMovementService' +type TransactionClient = Prisma.TransactionClient type MountLifecycleStatus = '可用' | '已借出' | '维护中' | '待报废' | '已报废' const PARENT_MOUNTABLE_STATUSES = new Set(['可用', '已借出', '维护中']) -const DETACHED_CHILD_ALLOWED_STATUSES = new Set(['可用', '维护中', '待报废', '已报废']) -const ensureParentMountOperable = (equipment: { status: string }, label: string) => { - if (!PARENT_MOUNTABLE_STATUSES.has(equipment.status as MountLifecycleStatus)) { - throw new Error(`${label}当前状态不可挂载或更换配件`) +class MountOperationError extends Error { + statusCode: number + + constructor(message: string, statusCode: number = 422) { + super(message) + this.name = 'MountOperationError' + this.statusCode = statusCode } } -const ensureChildAttachable = async ( - equipment: { id: number; status: string; remaining_quantity: number }, - label: string -) => { - if (equipment.status !== '可用' || equipment.remaining_quantity <= 0) { - throw new Error(`${label}当前状态不可挂载`) +const ensureParentMountOperable = (equipment: { status: string }, label: string) => { + if (!PARENT_MOUNTABLE_STATUSES.has(equipment.status as MountLifecycleStatus)) { + throw new MountOperationError(`${label}当前状态不可挂载或更换配件`) } - - await ensureNoActiveBorrowRecords(equipment.id, `${label}存在未归还记录,当前不可挂载`) } const resolveDetachedChildStatus = (status?: string): MountLifecycleStatus => { @@ -36,11 +40,11 @@ const resolveDetachedChildStatus = (status?: string): MountLifecycleStatus => { return '可用' } - if (!DETACHED_CHILD_ALLOWED_STATUSES.has(normalized as MountLifecycleStatus)) { - throw new Error('拆下配件状态不合法') + if (normalized !== '可用') { + throw new MountOperationError('拆下的配件只能恢复为可用;维修或报废请使用正式业务流程') } - return normalized as MountLifecycleStatus + return '可用' } const buildEquipmentStateUpdate = ( @@ -65,138 +69,194 @@ const buildEquipmentStateUpdate = ( } } -const buildMountedChildStateUpdate = ( - parent: { status: string }, - child: { unit_price: unknown; total_quantity: number; status: string } -) => { - const inheritedStatus = parent.status === '已借出' || parent.status === '维护中' +const resolveMountedChildStatus = (parent: { status: string }): MountLifecycleStatus => { + return parent.status === '已借出' || parent.status === '维护中' ? (parent.status as MountLifecycleStatus) : '可用' - return buildEquipmentStateUpdate(child, inheritedStatus) } -const ensureNoMountCycle = async (parentId: number, childId: number) => { +const ensureAssetTreeAttachable = async ( + client: TransactionClient, + items: AssetTreeItem[], + label: string +) => { + const blocked = items.find((item) => item.status !== '可用' || item.remaining_quantity <= 0) + if (blocked) { + throw new MountOperationError(`${label}中的资产 ${blocked.equipment_code} 当前状态不可挂载`) + } + const activeBorrowCount = await client.borrowRecord.count({ + where: { + equipment_id: { in: items.map((item) => item.id) }, + status: '已借出' + } + }) + if (activeBorrowCount > 0) { + throw new MountOperationError(`${label}存在未归还记录,当前不可挂载`) + } +} + +const updateAssetTreeState = async ( + client: TransactionClient, + items: AssetTreeItem[], + input: { + rootParentId: number | null + rootMountStatus: 'mounted' | 'unmounted' + targetStatus: MountLifecycleStatus + departmentId: number | null + } +) => { + const rootId = items[0]?.id + for (const item of items) { + const isRoot = item.id === rootId + const claimed = await client.equipment.updateMany({ + where: { + id: item.id, + parent_id: item.parent_id, + department_id: item.department_id, + status: item.status, + mount_status: item.mount_status, + remaining_quantity: item.remaining_quantity, + updated_at: item.updated_at + }, + data: { + ...(isRoot + ? { + parent_id: input.rootParentId, + mount_status: input.rootMountStatus + } + : {}), + department_id: input.departmentId, + ...buildEquipmentStateUpdate(item, input.targetStatus) + } + }) + if (claimed.count !== 1) { + throw new MountOperationError(`资产 ${item.equipment_code} 状态或挂载关系已变化,请刷新后重试`, 409) + } + } +} + +const ensureNoMountCycle = async ( + client: TransactionClient, + parentId: number, + childId: number +) => { if (parentId === childId) { - throw new Error('父资产和子资产不能是同一条记录') + throw new MountOperationError('父资产和子资产不能是同一条记录') } - let current = await prisma.equipment.findUnique({ - where: { id: parentId }, - select: { parent_id: true } - }) - - while (current?.parent_id) { - if (current.parent_id === childId) { - throw new Error('不能把资产挂载到自己的下级节点上') + const visited = new Set() + let currentId: number | null = parentId + for (let depth = 0; currentId !== null; depth += 1) { + if (depth >= 100) { + throw new MountOperationError('资产父子关系层级过深,请先检查资产关系', 409) } + if (currentId === childId) { + throw new MountOperationError('不能把资产挂载到自己的下级节点上') + } + if (visited.has(currentId)) { + throw new MountOperationError('现有资产关系已存在循环,请先修复关系', 409) + } + visited.add(currentId) - current = await prisma.equipment.findUnique({ - where: { id: current.parent_id }, + await lockEquipmentRows(client, [currentId]) + const current: { parent_id: number | null } | null = await client.equipment.findUnique({ + where: { id: currentId }, select: { parent_id: true } }) + currentId = current?.parent_id ?? null } } +const getMountErrorStatus = (error: any) => { + if (error instanceof MountOperationError) return error.statusCode + if (Number.isInteger(error?.statusCode)) return error.statusCode + if (Number.isInteger(error?.status)) return error.status + if (error?.code === 'P2034' || /deadlock|lock wait timeout/i.test(String(error?.message || ''))) { + return 409 + } + return 500 +} + export const mountChild = async (req: Request, res: Response) => { try { const numericParentId = Number(req.params.parentId) const { child_id, notes } = req.body as { child_id: number; notes?: string } + const numericChildId = Number(child_id) const userId = Number(res.locals.user?.id) if (!userId) { return errorResponse(res, '用户未登录', 401) } - - try { - await ensureNoMountCycle(numericParentId, child_id) - } catch (error: any) { - return errorResponse(res, error.message || '挂载关系校验失败', 422) + if (!Number.isInteger(numericParentId) || !Number.isInteger(numericChildId) || numericParentId <= 0 || numericChildId <= 0) { + return errorResponse(res, '资产参数不合法', 422) } - const parent = await prisma.equipment.findUnique({ - where: { id: numericParentId }, - include: { assetType: true } - }) - if (!parent) { - return errorResponse(res, '父资产不存在', 404) - } - if (!parent.assetType?.can_be_parent) { - return errorResponse(res, '该资产类型不支持挂载配件', 400) - } - - try { + const updatedParent = await prisma.$transaction(async (tx) => { + await lockEquipmentRows(tx, [numericParentId, numericChildId]) + const [parent, child] = await Promise.all([ + tx.equipment.findUnique({ + where: { id: numericParentId }, + include: { assetType: true } + }), + tx.equipment.findUnique({ + where: { id: numericChildId }, + include: { assetType: true } + }) + ]) + if (!parent) throw new MountOperationError('父资产不存在', 404) + if (!child) throw new MountOperationError('子资产不存在', 404) + if (!parent.assetType?.can_be_parent) { + throw new MountOperationError('该资产类型不支持挂载配件', 400) + } + if (!child.assetType?.can_be_child) { + throw new MountOperationError('该资产类型不能作为配件挂载', 400) + } ensureParentMountOperable(parent, '父资产') - } catch (error: any) { - return errorResponse(res, error.message || '父资产状态校验失败', 422) - } - - const child = await prisma.equipment.findUnique({ - where: { id: child_id }, - include: { assetType: true } - }) - if (!child) { - return errorResponse(res, '子资产不存在', 404) - } - if (!child.assetType?.can_be_child) { - return errorResponse(res, '该资产类型不能作为配件挂载', 400) - } - - try { - await ensureChildAttachable(child, '子资产') - } catch (error: any) { - return errorResponse(res, error.message || '子资产状态校验失败', 422) - } - - if (child.parent_id) { - const currentParent = await prisma.equipment.findUnique({ - where: { id: child.parent_id } - }) - return errorResponse(res, `该配件已挂载到 ${currentParent?.equipment_name || '其他资产'}`, 400) - } - - await prisma.$transaction(async (tx) => { - await tx.equipment.update({ - where: { id: child_id }, - data: { - parent_id: numericParentId, - mount_status: 'mounted', - ...buildMountedChildStateUpdate(parent, child) - } + if (child.parent_id !== null || !['none', 'unmounted'].includes(child.mount_status)) { + throw new MountOperationError('该配件已被挂载或关系已变化,请刷新后重试', 409) + } + await ensureNoMountCycle(tx, numericParentId, numericChildId) + const childTree = await loadAndLockAssetTree(tx, numericChildId) + await ensureAssetTreeAttachable(tx, childTree, '子资产树') + await updateAssetTreeState(tx, childTree, { + rootParentId: numericParentId, + rootMountStatus: 'mounted', + targetStatus: resolveMountedChildStatus(parent), + departmentId: parent.department_id }) await tx.mountHistory.create({ data: { - child_id, + child_id: numericChildId, parent_id: numericParentId, operation_type: 'mount', operated_by: userId, notes } }) - }) + await refreshParentTotalValueWithChildren(tx, numericParentId) - await refreshParentTotalValueWithChildren(prisma, numericParentId) - - const updatedParent = await prisma.equipment.findUnique({ - where: { id: numericParentId }, - include: { - children: { - select: { - id: true, - equipment_code: true, - equipment_name: true, - equipment_model: true, - equipment_type: true, - unit_price: true, - status: true + return tx.equipment.findUnique({ + where: { id: numericParentId }, + include: { + children: { + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + unit_price: true, + status: true + } } } - } - }) + }) + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) successResponse(res, updatedParent, '挂载成功') } catch (error: any) { - errorResponse(res, error.message || '挂载失败') + errorResponse(res, error.message || '挂载失败', getMountErrorStatus(error)) } } @@ -210,60 +270,50 @@ export const unmountChild = async (req: Request, res: Response) => { } const userId = Number(res.locals.user?.id) const detachedChildStatus = resolveDetachedChildStatus(released_child_status) + const numericChildId = Number(child_id) if (!userId) { return errorResponse(res, '用户未登录', 401) } - - const parent = await prisma.equipment.findUnique({ - where: { id: numericParentId } - }) - if (!parent) { - return errorResponse(res, '父资产不存在', 404) - } - - const child = await prisma.equipment.findUnique({ - where: { id: child_id } - }) - if (!child) { - return errorResponse(res, '子资产不存在', 404) - } - if (child.parent_id !== numericParentId) { - return errorResponse(res, '该配件未挂载到此资产', 400) - } - - try { - ensureParentMountOperable(parent, '父资产') - } catch (error: any) { - return errorResponse(res, error.message || '卸载状态校验失败', 422) + if (!Number.isInteger(numericParentId) || !Number.isInteger(numericChildId) || numericParentId <= 0 || numericChildId <= 0) { + return errorResponse(res, '资产参数不合法', 422) } await prisma.$transaction(async (tx) => { - await tx.equipment.update({ - where: { id: child_id }, - data: { - parent_id: null, - mount_status: 'unmounted', - ...buildEquipmentStateUpdate(child, detachedChildStatus) - } + await lockEquipmentRows(tx, [numericParentId, numericChildId]) + const [parent, child] = await Promise.all([ + tx.equipment.findUnique({ where: { id: numericParentId } }), + tx.equipment.findUnique({ where: { id: numericChildId } }) + ]) + if (!parent) throw new MountOperationError('父资产不存在', 404) + if (!child) throw new MountOperationError('子资产不存在', 404) + if (child.parent_id !== numericParentId) { + throw new MountOperationError('该配件未挂载到此资产或关系已变化', 409) + } + ensureParentMountOperable(parent, '父资产') + const childTree = await loadAndLockAssetTree(tx, numericChildId) + await updateAssetTreeState(tx, childTree, { + rootParentId: null, + rootMountStatus: 'unmounted', + targetStatus: detachedChildStatus, + departmentId: null }) await tx.mountHistory.create({ data: { - child_id, + child_id: numericChildId, parent_id: numericParentId, operation_type: 'unmount', operated_by: userId, notes } }) - }) - - await refreshParentTotalValueWithChildren(prisma, numericParentId) + await refreshParentTotalValueWithChildren(tx, numericParentId) + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) successResponse(res, null, '卸载成功') } catch (error: any) { - errorResponse(res, error.message || '卸载失败') + errorResponse(res, error.message || '卸载失败', getMountErrorStatus(error)) } } @@ -278,86 +328,71 @@ export const replaceChild = async (req: Request, res: Response) => { } const userId = Number(res.locals.user?.id) const detachedChildStatus = resolveDetachedChildStatus(released_child_status) + const numericOldChildId = Number(old_child_id) + const numericNewChildId = Number(new_child_id) if (!userId) { return errorResponse(res, '用户未登录', 401) } - if (old_child_id === new_child_id) { + if ( + !Number.isInteger(numericParentId) || !Number.isInteger(numericOldChildId) || + !Number.isInteger(numericNewChildId) || numericParentId <= 0 || + numericOldChildId <= 0 || numericNewChildId <= 0 + ) { + return errorResponse(res, '资产参数不合法', 422) + } + if (numericOldChildId === numericNewChildId) { return errorResponse(res, '旧配件和新配件不能相同', 422) } - try { - await ensureNoMountCycle(numericParentId, new_child_id) - } catch (error: any) { - return errorResponse(res, error.message || '更换关系校验失败', 422) - } - - const oldChild = await prisma.equipment.findUnique({ - where: { id: old_child_id } - }) - if (!oldChild || oldChild.parent_id !== numericParentId) { - return errorResponse(res, '旧配件未挂载到此资产', 400) - } - - const parent = await prisma.equipment.findUnique({ - where: { id: numericParentId }, - include: { assetType: true } - }) - if (!parent) { - return errorResponse(res, '父资产不存在', 404) - } - if (!parent.assetType?.can_be_parent) { - return errorResponse(res, '该资产类型不支持挂载配件', 400) - } - - try { - ensureParentMountOperable(parent, '父资产') - } catch (error: any) { - return errorResponse(res, error.message || '挂载状态校验失败', 422) - } - - const newChild = await prisma.equipment.findUnique({ - where: { id: new_child_id }, - include: { assetType: true } - }) - if (!newChild) { - return errorResponse(res, '新配件不存在', 404) - } - if (!newChild.assetType?.can_be_child) { - return errorResponse(res, '新配件类型不能作为配件挂载', 400) - } - if (newChild.parent_id) { - return errorResponse(res, '新配件已挂载到其他资产', 400) - } - - try { - await ensureChildAttachable(newChild, '新配件') - } catch (error: any) { - return errorResponse(res, error.message || '新配件状态校验失败', 422) - } - await prisma.$transaction(async (tx) => { - await tx.equipment.update({ - where: { id: old_child_id }, - data: { - parent_id: null, - mount_status: 'unmounted', - ...buildEquipmentStateUpdate(oldChild, detachedChildStatus) - } + await lockEquipmentRows(tx, [numericParentId, numericOldChildId, numericNewChildId]) + const [parent, oldChild, newChild] = await Promise.all([ + tx.equipment.findUnique({ + where: { id: numericParentId }, + include: { assetType: true } + }), + tx.equipment.findUnique({ where: { id: numericOldChildId } }), + tx.equipment.findUnique({ + where: { id: numericNewChildId }, + include: { assetType: true } + }) + ]) + if (!parent) throw new MountOperationError('父资产不存在', 404) + if (!oldChild || oldChild.parent_id !== numericParentId) { + throw new MountOperationError('旧配件未挂载到此资产或关系已变化', 409) + } + if (!newChild) throw new MountOperationError('新配件不存在', 404) + if (!parent.assetType?.can_be_parent) { + throw new MountOperationError('该资产类型不支持挂载配件', 400) + } + if (!newChild.assetType?.can_be_child) { + throw new MountOperationError('新配件类型不能作为配件挂载', 400) + } + if (newChild.parent_id !== null || !['none', 'unmounted'].includes(newChild.mount_status)) { + throw new MountOperationError('新配件已被挂载或关系已变化,请刷新后重试', 409) + } + ensureParentMountOperable(parent, '父资产') + await ensureNoMountCycle(tx, numericParentId, numericNewChildId) + const oldChildTree = await loadAndLockAssetTree(tx, numericOldChildId) + const newChildTree = await loadAndLockAssetTree(tx, numericNewChildId) + await ensureAssetTreeAttachable(tx, newChildTree, '新配件树') + await updateAssetTreeState(tx, oldChildTree, { + rootParentId: null, + rootMountStatus: 'unmounted', + targetStatus: detachedChildStatus, + departmentId: null }) - - await tx.equipment.update({ - where: { id: new_child_id }, - data: { - parent_id: numericParentId, - mount_status: 'mounted', - ...buildMountedChildStateUpdate(parent, newChild) - } + await updateAssetTreeState(tx, newChildTree, { + rootParentId: numericParentId, + rootMountStatus: 'mounted', + targetStatus: resolveMountedChildStatus(parent), + departmentId: parent.department_id }) await tx.mountHistory.create({ data: { - child_id: old_child_id, + child_id: numericOldChildId, parent_id: numericParentId, operation_type: 'replace', operated_by: userId, @@ -369,7 +404,7 @@ export const replaceChild = async (req: Request, res: Response) => { await tx.mountHistory.create({ data: { - child_id: new_child_id, + child_id: numericNewChildId, parent_id: numericParentId, operation_type: 'replace', operated_by: userId, @@ -378,13 +413,12 @@ export const replaceChild = async (req: Request, res: Response) => { new_parent_id: numericParentId } }) - }) - - await refreshParentTotalValueWithChildren(prisma, numericParentId) + await refreshParentTotalValueWithChildren(tx, numericParentId) + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) successResponse(res, null, '更换成功') } catch (error: any) { - errorResponse(res, error.message || '更换失败') + errorResponse(res, error.message || '更换失败', getMountErrorStatus(error)) } } diff --git a/backend/src/controllers/myAssetController.ts b/backend/src/controllers/myAssetController.ts new file mode 100644 index 0000000..70db286 --- /dev/null +++ b/backend/src/controllers/myAssetController.ts @@ -0,0 +1,82 @@ +import type { Request, Response } from 'express' +import prisma from '../utils/prisma' +import { errorResponse, successResponse } from '../utils/response' + +const parseChildrenSnapshot = (value: string | null) => { + if (!value) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} +export const getMyAssets = async (_req: Request, res: Response) => { + try { + const personId = Number(res.locals.user?.person_id) + if (!Number.isInteger(personId) || personId <= 0) { + return errorResponse(res, '当前账号尚未绑定员工档案', 403) + } + + const person = await prisma.person.findUnique({ + where: { id: personId }, + select: { + id: true, + real_name: true, + nickname: true, + job_number: true, + status: true, + source_employee_status: true, + mainDepartment: { select: { id: true, name: true } } + } + }) + if (!person || person.status !== 'active') { + return errorResponse(res, '绑定的员工档案不存在或已离职', 403) + } + + const records = await prisma.borrowRecord.findMany({ + where: { + person_id: personId, + status: '已借出' + }, + orderBy: [{ borrow_time: 'desc' }, { id: 'desc' }], + include: { + equipment: { + include: { + storageLocation: { select: { id: true, name: true, code: true } }, + department: { select: { id: true, name: true } }, + children: { + orderBy: { id: 'asc' }, + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + status: true, + parent_id: true + } + } + } + } + } + }) + + successResponse(res, { + person, + assets: records.map((record) => ({ + borrow_record_id: record.id, + borrow_time: record.borrow_time, + expected_return_time: record.expected_return_time, + is_long_term: record.is_long_term, + notes: record.notes, + equipment: record.equipment, + children: record.equipment.children.length > 0 + ? record.equipment.children + : parseChildrenSnapshot(record.children_snapshot) + })) + }, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '获取本人资产失败') + } +} diff --git a/backend/src/controllers/notificationController.ts b/backend/src/controllers/notificationController.ts index 81b2931..772651e 100644 --- a/backend/src/controllers/notificationController.ts +++ b/backend/src/controllers/notificationController.ts @@ -16,23 +16,6 @@ const getCurrentUserId = (res: Response) => { return Number.isNaN(userId) ? null : userId } -const getCurrentUserRole = (res: Response) => { - const role = res.locals.user?.role - return typeof role === 'string' ? role : null -} - -const ensureAdminNotificationInbox = async (userId: number, role: string | null) => { - if (role !== 'admin') { - return - } - - await prisma.$executeRaw` - INSERT IGNORE INTO user_notification (user_id, notification_id) - SELECT ${userId}, n.id - FROM notification n - ` -} - const sanitizeNotification = (item: any) => ({ id: item.id, is_read: item.is_read, @@ -55,8 +38,6 @@ export const getNotificationList = async (req: Request, res: Response) => { if (!userId) { return errorResponse(res, '未获取到当前用户信息', 401) } - await ensureAdminNotificationInbox(userId, getCurrentUserRole(res)) - const { page = 1, pageSize = 10, status = 'all' } = req.query const where: any = { user_id: userId } @@ -107,8 +88,6 @@ export const getUnreadNotificationCount = async (_req: Request, res: Response) = if (!userId) { return errorResponse(res, '未获取到当前用户信息', 401) } - await ensureAdminNotificationInbox(userId, getCurrentUserRole(res)) - const count = await prisma.userNotification.count({ where: { user_id: userId, @@ -128,8 +107,6 @@ export const markNotificationAsRead = async (req: Request, res: Response) => { if (!userId) { return errorResponse(res, '未获取到当前用户信息', 401) } - await ensureAdminNotificationInbox(userId, getCurrentUserRole(res)) - const id = Number(req.params.id) const existing = await prisma.userNotification.findFirst({ where: { @@ -168,8 +145,6 @@ export const markAllNotificationsAsRead = async (_req: Request, res: Response) = if (!userId) { return errorResponse(res, '未获取到当前用户信息', 401) } - await ensureAdminNotificationInbox(userId, getCurrentUserRole(res)) - const result = await prisma.userNotification.updateMany({ where: { user_id: userId, diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index c7fa1eb..54e3638 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' import bcrypt from 'bcryptjs' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' +import { getPermissionsForRole, isUserRole } from '../utils/permissions' const SALT_ROUNDS = 10 @@ -13,12 +14,52 @@ const sanitizeUser = (user: any) => ({ phone: user.phone, role: user.role, roles: [user.role], + permissions: getPermissionsForRole(user.role), status: user.status, + person_id: user.person_id, + person_bound: Boolean(user.person_id), + person: user.person || null, last_login_at: user.last_login_at, created_at: user.created_at, updated_at: user.updated_at }) +const personSelect = { + id: true, + real_name: true, + nickname: true, + status: true, + source_employee_status: true +} as const + +const normalizePersonId = (value: unknown) => { + if (value === undefined) return undefined + if (value === null || value === '') return null + const personId = Number(value) + if (!Number.isInteger(personId) || personId <= 0) { + throw new Error('员工绑定无效') + } + return personId +} + +const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => { + const person = await prisma.person.findUnique({ where: { id: personId } }) + if (!person) { + throw new Error('绑定的员工档案不存在') + } + + const occupied = await prisma.user.findFirst({ + where: { + person_id: personId, + ...(excludeUserId ? { id: { not: excludeUserId } } : {}) + }, + select: { id: true } + }) + if (occupied) { + throw new Error('该员工档案已绑定其他用户') + } +} + export const getUserList = async (req: Request, res: Response) => { try { const { page = 1, pageSize = 10, keyword, role, status } = req.query @@ -48,7 +89,8 @@ export const getUserList = async (req: Request, res: Response) => { where, skip, take: Number(pageSize), - orderBy: { created_at: 'desc' } + orderBy: { created_at: 'desc' }, + include: { person: { select: personSelect } } }), prisma.user.count({ where }) ]) @@ -70,7 +112,7 @@ export const getUserList = async (req: Request, res: Response) => { export const createUser = async (req: Request, res: Response) => { try { - const { username, password, name, email, phone, roles, role, status } = req.body as Record< + const { username, password, name, email, phone, roles, role, status, person_id } = req.body as Record< string, any > @@ -86,6 +128,14 @@ export const createUser = async (req: Request, res: Response) => { const primaryRole = (Array.isArray(roles) ? roles[0] : roles) || role || (username === 'admin' ? 'admin' : 'user') + if (!isUserRole(primaryRole)) { + return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422) + } + + const personId = normalizePersonId(person_id) + if (typeof personId === 'number') { + await ensurePersonCanBind(personId) + } const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS) @@ -97,8 +147,10 @@ export const createUser = async (req: Request, res: Response) => { email, phone, role: primaryRole, - status: status || 'active' - } + status: status || 'active', + person_id: personId ?? null + }, + include: { person: { select: personSelect } } }) successResponse(res, sanitizeUser(created), '创建成功') @@ -110,7 +162,7 @@ export const createUser = async (req: Request, res: Response) => { export const updateUser = async (req: Request, res: Response) => { try { const { id } = req.params - const { name, email, phone, roles, role, status } = req.body as Record + const { name, email, phone, roles, role, status, person_id } = req.body as Record const user = await prisma.user.findUnique({ where: { id: Number(id) } }) if (!user) { @@ -125,6 +177,9 @@ export const updateUser = async (req: Request, res: Response) => { const newRole = (Array.isArray(roles) ? roles[0] : roles) || role if (newRole) { + if (!isUserRole(newRole)) { + return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422) + } updateData.role = newRole } @@ -132,9 +187,18 @@ export const updateUser = async (req: Request, res: Response) => { updateData.status = status } + const personId = normalizePersonId(person_id) + if (typeof personId === 'number') { + await ensurePersonCanBind(personId, user.id) + updateData.person_id = personId + } else if (personId === null) { + updateData.person_id = null + } + const updated = await prisma.user.update({ where: { id: Number(id) }, - data: updateData + data: updateData, + include: { person: { select: personSelect } } }) successResponse(res, sanitizeUser(updated), '更新成功') @@ -186,7 +250,8 @@ export const updateUserStatus = async (req: Request, res: Response) => { const updated = await prisma.user.update({ where: { id: Number(id) }, - data: { status } + data: { status }, + include: { person: { select: personSelect } } }) successResponse(res, sanitizeUser(updated), '状态更新成功') @@ -210,7 +275,11 @@ export const deleteUser = async (req: Request, res: Response) => { mount_histories: true, disposal_applied: true, disposal_approved: true, - maintenance_records: true + disposal_completed: true, + disposal_actions: true, + maintenance_records: true, + asset_transfers_operated: true, + dingtalk_deliveries: true } } } @@ -229,7 +298,11 @@ export const deleteUser = async (req: Request, res: Response) => { user._count.mount_histories > 0 || user._count.disposal_applied > 0 || user._count.disposal_approved > 0 || + user._count.disposal_completed > 0 || + user._count.disposal_actions > 0 || user._count.maintenance_records > 0 + || user._count.asset_transfers_operated > 0 + || user._count.dingtalk_deliveries > 0 ) { return errorResponse(res, '该用户已存在业务历史记录,无法删除') } diff --git a/backend/src/index.ts b/backend/src/index.ts index 581d659..2791d63 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,7 +4,6 @@ import cors from 'cors' import dotenv from 'dotenv' import session from 'express-session' import equipmentRoutes from './routes/equipment' -import { scanEquipment } from './controllers/equipmentController' import borrowRoutes, { returnRouter } from './routes/borrow' import statisticsRoutes from './routes/statistics' import authRoutes from './routes/auth' @@ -31,10 +30,14 @@ import printTerminalRoutes from './routes/printTerminals' import printJobRoutes from './routes/printJobs' import printAgentRoutes from './routes/printAgent' import printDownloadRoutes from './routes/printDownloads' +import myAssetRoutes from './routes/myAssets' +import dingtalkRoutes from './routes/dingtalk' import { keycloak, memoryStore } from './utils/keycloak' -import { protect, requireAdmin } from './middleware/auth' +import { protect, requirePermission } from './middleware/auth' +import { PERMISSIONS } from './utils/permissions' import { startHrSyncScheduler } from './services/hrSyncService' import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService' +import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService' dotenv.config() @@ -65,34 +68,35 @@ app.use(keycloak.middleware({ logout: '/api/auth/logout', admin: '/keycloak' })) app.use('/uploads', express.static(path.resolve(__dirname, '..', 'uploads'))) // Routes -app.get('/api/equipment/scan/:equipment_code', protect(), scanEquipment) -app.use('/api/equipment', protect(), requireAdmin(), equipmentRoutes) +app.use('/api/equipment', protect(), equipmentRoutes) app.use('/api/borrow', protect(), borrowRoutes) app.use('/api/return', protect(), returnRouter) app.use('/api/statistics', protect(), statisticsRoutes) app.use('/api/barcode', protect(), barcodeRoutes) app.use('/api/auth', authRoutes) app.use('/api/settings', settingsRoutes) -app.use('/api/users', protect(), requireAdmin(), usersRoutes) +app.use('/api/users', protect(), usersRoutes) app.use('/api/persons', personRoutes) app.use('/api/storage-locations', storageLocationRoutes) app.use('/api/asset-types', assetTypeRoutes) app.use('/api/departments', departmentRoutes) app.use('/api/operation-logs', operationLogRoutes) app.use('/api/batch-import', batchImportRoutes) -app.use('/api/mount', protect(), mountRoutes) -app.use('/api/disposal', protect(), disposalRoutes) -app.use('/api/maintenance', protect(), maintenanceRoutes) -app.use('/api/batch', protect(), requireAdmin(), batchRoutes) +app.use('/api/mount', mountRoutes) +app.use('/api/disposal', disposalRoutes) +app.use('/api/maintenance', maintenanceRoutes) +app.use('/api/batch', protect(), batchRoutes) app.use('/api/export', protect(), exportRoutes) app.use('/api/search', protect(), searchRoutes) -app.use('/api/sync', protect(), requireAdmin(), syncRoutes) +app.use('/api/sync', syncRoutes) app.use('/api/notifications', notificationRoutes) app.use('/api/inventory', inventoryRoutes) app.use('/api/public/inventory', publicInventoryRoutes) -app.use('/api/print-terminals', protect(), requireAdmin(), printTerminalRoutes) -app.use('/api/print-jobs', protect(), requireAdmin(), printJobRoutes) +app.use('/api/print-terminals', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), printTerminalRoutes) +app.use('/api/print-jobs', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), printJobRoutes) app.use('/api/print-agent', printAgentRoutes) +app.use('/api/my-assets', myAssetRoutes) +app.use('/api/dingtalk', dingtalkRoutes) app.use('/downloads', printDownloadRoutes) // Health check @@ -119,3 +123,4 @@ ensurePublicAssetPersonExists().catch((error) => { }) startHrSyncScheduler() +startDingTalkDeliveryWorker() diff --git a/backend/src/middleware/__tests__/auth.priority.test.ts b/backend/src/middleware/__tests__/auth.priority.test.ts new file mode 100644 index 0000000..56e2914 --- /dev/null +++ b/backend/src/middleware/__tests__/auth.priority.test.ts @@ -0,0 +1,150 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { keycloak } from '../../utils/keycloak' +import { protect } from '../auth' + +const makeUser = (id: number, username: string) => ({ + id, + username, + password: 'hidden', + name: username, + email: null, + phone: null, + role: 'admin', + status: 'active', + person_id: null, + person: null +}) + +const createResponse = () => { + const state = { statusCode: 200, body: null as any, nextCount: 0 } + const res = { + locals: {} as any, + status(code: number) { + state.statusCode = code + return this + }, + json(payload: any) { + state.body = payload + return this + } + } + const next = () => { + state.nextCount += 1 + } + return { res, next, state } +} + +test('Keycloak 模式下真实 Bearer 令牌优先于旧的钉钉 H5 session', async () => { + const originalMode = process.env.AUTH_MODE + const originalProtect = keycloak.protect + const originalFindUnique = prisma.user.findUnique + const lookups: any[] = [] + + process.env.AUTH_MODE = 'keycloak' + ;(keycloak.protect as any) = () => async (req: any, _res: any, next: any) => { + req.kauth = { + grant: { + access_token: { + content: { + preferred_username: 'keycloak-user', + name: 'Keycloak User', + realm_access: { roles: ['ignored-external-role'] } + } + } + } + } + return next() + } + ;(prisma.user.findUnique as any) = async (args: any) => { + lookups.push(args.where) + if (args.where.username === 'keycloak-user') return makeUser(22, 'keycloak-user') + if (args.where.id === 11) return makeUser(11, 'old-dingtalk-session') + return null + } + + try { + const middleware = protect() + const req = { + headers: { authorization: 'Bearer real-keycloak-token' }, + session: { user: { id: 11 } } + } + const { res, next, state } = createResponse() + await middleware(req as any, res as any, next as any) + + assert.equal(state.nextCount, 1) + assert.equal(res.locals.user.id, 22) + assert.deepEqual(lookups, [{ username: 'keycloak-user' }]) + } finally { + if (originalMode === undefined) delete process.env.AUTH_MODE + else process.env.AUTH_MODE = originalMode + ;(keycloak.protect as any) = originalProtect + ;(prisma.user.findUnique as any) = originalFindUnique + } +}) + +test('Keycloak 模式下 Bearer session 占位值仍使用 H5 session', async () => { + const originalMode = process.env.AUTH_MODE + const originalProtect = keycloak.protect + const originalFindUnique = prisma.user.findUnique + let keycloakCalled = 0 + + process.env.AUTH_MODE = 'keycloak' + ;(keycloak.protect as any) = () => async (_req: any, _res: any, next: any) => { + keycloakCalled += 1 + return next() + } + ;(prisma.user.findUnique as any) = async (args: any) => { + if (args.where.id === 11) return makeUser(11, 'dingtalk-session') + return null + } + + try { + const middleware = protect() + const req = { + headers: { authorization: 'Bearer session' }, + session: { user: { id: 11 } } + } + const { res, next, state } = createResponse() + await middleware(req as any, res as any, next as any) + + assert.equal(state.nextCount, 1) + assert.equal(keycloakCalled, 0) + assert.equal(res.locals.user.id, 11) + } finally { + if (originalMode === undefined) delete process.env.AUTH_MODE + else process.env.AUTH_MODE = originalMode + ;(keycloak.protect as any) = originalProtect + ;(prisma.user.findUnique as any) = originalFindUnique + } +}) + +test('local 模式即使带真实 Bearer 也只使用本地 session', async () => { + const originalMode = process.env.AUTH_MODE + const originalFindUnique = prisma.user.findUnique + + process.env.AUTH_MODE = 'local' + ;(prisma.user.findUnique as any) = async (args: any) => { + if (args.where.id === 31) return makeUser(31, 'local-session') + return null + } + + try { + const middleware = protect() + const req = { + headers: { authorization: 'Bearer should-not-win' }, + session: { user: { id: 31 } } + } + const { res, next, state } = createResponse() + await middleware(req as any, res as any, next as any) + + assert.equal(state.nextCount, 1) + assert.equal(res.locals.user.id, 31) + } finally { + if (originalMode === undefined) delete process.env.AUTH_MODE + else process.env.AUTH_MODE = originalMode + ;(prisma.user.findUnique as any) = originalFindUnique + } +}) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index c17dd0c..af430af 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,26 +1,49 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express' import { keycloak } from '../utils/keycloak' -import { resolveOrProvisionAuthUser } from '../utils/authIdentity' +import { + AuthIdentityError, + resolveAuthUserById, + resolveOrProvisionAuthUser +} from '../utils/authIdentity' +import { PERMISSIONS, type Permission } from '../utils/permissions' const isLocalAuth = () => process.env.AUTH_MODE === 'local' +const hasRealBearerToken = (req: Request) => { + const authorization = req.headers.authorization + const match = authorization?.match(/^Bearer\s+(.+)$/i) + const token = match?.[1]?.trim() + return Boolean(token && token.toLowerCase() !== 'session') +} + // 选择保护策略:local 使用会话校验;默认使用 Keycloak 保护 export function protect(): RequestHandler { - if (isLocalAuth()) { - return function localSessionProtect(req: Request, res: Response, next: NextFunction) { - const sessionUser = (req as any).session?.user - if (sessionUser) { - res.locals.user = sessionUser - return next() + const keycloakMiddleware = isLocalAuth() ? null : keycloak.protect() + + return async function authWrapper(req: Request, res: Response, next: NextFunction) { + const authenticateSession = async () => { + const sessionUserId = Number((req as any).session?.user?.id) + if (!Number.isInteger(sessionUserId) || sessionUserId <= 0) { + return false + } + + try { + const currentUser = await resolveAuthUserById(sessionUserId) + ;(req as any).session.user = { id: currentUser.id } + res.locals.user = currentUser + next() + return true + } catch (error: any) { + if ((req as any).session) { + ;(req as any).session.user = undefined + } + const status = error instanceof AuthIdentityError ? error.status : 401 + res.status(status).json({ code: status, message: error.message || '认证失败', data: null }) + return true } - return res.status(401).json({ code: 401, message: '未登录', data: null }) } - } - const keycloakMiddleware = keycloak.protect() - - return function keycloakWrapper(req: Request, res: Response, next: NextFunction) { - keycloakMiddleware(req, res, async (err?: any) => { + const authenticateKeycloak = () => keycloakMiddleware?.(req, res, async (err?: any) => { if (err) { return next(err) } @@ -39,25 +62,44 @@ export function protect(): RequestHandler { return next(resolveError) } } - next() + return next() }) + + // 本地模式只认服务端会话,不接受 Bearer 令牌改变当前身份。 + if (!keycloakMiddleware) { + if (await authenticateSession()) return + return res.status(401).json({ code: 401, message: '未登录', data: null }) + } + + // Keycloak 模式下,真实 Bearer 令牌优先于浏览器里可能残留的钉钉 H5 会话。 + // 前端本地登录使用的 `Bearer session` 只是占位值,仍按会话处理。 + if (hasRealBearerToken(req)) { + return authenticateKeycloak() + } + + if (await authenticateSession()) return + return authenticateKeycloak() } } export function requireAdmin(): RequestHandler { - return function adminGuard(req: Request, res: Response, next: NextFunction) { - if (isLocalAuth()) { - const sessionUser = (req as any).session?.user - if (sessionUser && (sessionUser.role === 'admin' || sessionUser.roles?.includes('admin'))) { - res.locals.user = sessionUser - return next() - } - return res.status(403).json({ code: 403, message: '权限不足', data: null }) - } + return requirePermission(PERMISSIONS.SYSTEM_SETTINGS) +} - const roles: string[] = - ((req as any).kauth?.grant?.access_token?.content?.realm_access?.roles as string[]) || [] - if (roles.includes('admin')) { +export function requirePermission(permission: Permission): RequestHandler { + return function permissionGuard(_req: Request, res: Response, next: NextFunction) { + const permissions = res.locals.user?.permissions + if (Array.isArray(permissions) && permissions.includes(permission)) { + return next() + } + return res.status(403).json({ code: 403, message: '权限不足', data: null }) + } +} + +export function requireAnyPermission(...required: Permission[]): RequestHandler { + return function permissionGuard(_req: Request, res: Response, next: NextFunction) { + const permissions = res.locals.user?.permissions + if (Array.isArray(permissions) && required.some((permission) => permissions.includes(permission))) { return next() } return res.status(403).json({ code: 403, message: '权限不足', data: null }) diff --git a/backend/src/routes/assetTypes.ts b/backend/src/routes/assetTypes.ts index 3144e6b..d27e2c7 100644 --- a/backend/src/routes/assetTypes.ts +++ b/backend/src/routes/assetTypes.ts @@ -6,14 +6,15 @@ import { updateAssetType, deleteAssetType } from '../controllers/assetTypeController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.get('/options', protect(), getAssetTypeOptions) -router.get('/list', protect(), requireAdmin(), getAssetTypeList) -router.post('/', protect(), requireAdmin(), createAssetType) -router.put('/:id', protect(), requireAdmin(), updateAssetType) -router.delete('/:id', protect(), requireAdmin(), deleteAssetType) +router.get('/options', protect(), requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getAssetTypeOptions) +router.get('/list', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getAssetTypeList) +router.post('/', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), createAssetType) +router.put('/:id', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), updateAssetType) +router.delete('/:id', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), deleteAssetType) export default router diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 8c4a0c4..4a80fa6 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -4,35 +4,13 @@ import { keycloak } from '../utils/keycloak' import type { Request, Response } from 'express' import bcrypt from 'bcryptjs' import prisma from '../utils/prisma' +import { protect } from '../middleware/auth' +import { resolveAuthUserById } from '../utils/authIdentity' const router = Router() // 获取当前用户信息 -router.get('/user', (req, res) => { - if (process.env.AUTH_MODE === 'local') { - const user = (req as any).session?.user - if (!user) { - return errorResponse(res, '未登录', 401) - } - return successResponse(res, user, '获取成功') - } - const grant = (req as any).kauth?.grant - const token = grant?.access_token - - if (!token) { - return errorResponse(res, '无法获取用户信息', 401) - } - - const tokenContent = token.content as Record - - successResponse(res, { - id: tokenContent.sub, - username: tokenContent.preferred_username, - name: tokenContent.name || tokenContent.preferred_username, - email: tokenContent.email, - roles: tokenContent.realm_access?.roles || [] - }, '获取成功') -}) +router.get('/user', protect(), (_req, res) => successResponse(res, res.locals.user, '获取成功')) // Keycloak 配置信息(前端使用) router.get('/config', (req, res) => { @@ -48,9 +26,9 @@ router.get('/config', (req, res) => { // 退出登录 router.post('/logout', async (req, res) => { try { - if (process.env.AUTH_MODE === 'local') { + if (req.session) { await new Promise((resolve, reject) => { - req.session?.destroy((err) => { + req.session.destroy((err) => { if (err) { reject(err) return @@ -58,9 +36,8 @@ router.post('/logout', async (req, res) => { resolve() }) }) - return successResponse(res, null, '退出成功') } - // Keycloak 模式下仅返回成功,由前端触发 keycloak.logout() + // Keycloak 模式下前端仍需触发 keycloak.logout(),这里同时销毁钉钉/本地会话。 return successResponse(res, null, '退出成功') } catch (error: any) { errorResponse(res, error?.message || '退出失败') @@ -69,50 +46,50 @@ router.post('/logout', async (req, res) => { // 本地登录模式(不跳转 Keycloak) router.post('/local/login', async (req: Request, res: Response) => { - if (process.env.AUTH_MODE !== 'local') { - return errorResponse(res, '本地登录未启用', 400) + try { + if (process.env.AUTH_MODE !== 'local') { + return errorResponse(res, '本地登录未启用', 400) + } + const { username, password } = req.body as { username?: string; password?: string } + if (!username || !password) { + return errorResponse(res, '用户名与密码必填', 422) + } + + const user = await prisma.user.findUnique({ where: { username } }) + if (!user || user.status !== 'active') { + return errorResponse(res, '用户名或密码错误', 401) + } + + const validPassword = await bcrypt.compare(password, user.password) + if (!validPassword) { + return errorResponse(res, '用户名或密码错误', 401) + } + + await prisma.user.update({ + where: { id: user.id }, + data: { last_login_at: new Date() } + }) + + const userPayload = await resolveAuthUserById(user.id) + await new Promise((resolve, reject) => { + ;(req.session as any).regenerate((error?: Error) => { + if (error) return reject(error) + resolve() + }) + }) + ;(req.session as any).user = { id: user.id } + + return successResponse( + res, + { + token: 'session', + user: userPayload + }, + '登录成功' + ) + } catch (error: any) { + return errorResponse(res, error.message || '登录失败', error.status || 500) } - const { username, password } = req.body as { username?: string; password?: string } - if (!username || !password) { - return errorResponse(res, '用户名与密码必填', 422) - } - - const user = await prisma.user.findUnique({ where: { username } }) - if (!user || user.status !== 'active') { - return errorResponse(res, '用户名或密码错误', 401) - } - - const validPassword = await bcrypt.compare(password, user.password) - if (!validPassword) { - return errorResponse(res, '用户名或密码错误', 401) - } - - await prisma.user.update({ - where: { id: user.id }, - data: { last_login_at: new Date() } - }) - - const userPayload = { - id: user.id, - username: user.username, - name: user.name, - email: user.email, - phone: user.phone, - role: user.role, - roles: [user.role], - status: user.status - } - - ;(req as any).session.user = userPayload - - return successResponse( - res, - { - token: 'session', - user: userPayload - }, - '登录成功' - ) }) export default router diff --git a/backend/src/routes/barcode.ts b/backend/src/routes/barcode.ts index fd3e060..d061584 100644 --- a/backend/src/routes/barcode.ts +++ b/backend/src/routes/barcode.ts @@ -3,11 +3,14 @@ import { getEquipmentForBarcode, getEquipmentBatchForBarcode } from '../controllers/barcodeController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() +router.use(requirePermission(PERMISSIONS.ASSETS_VIEW_ALL)) + router.get('/:equipment_code', getEquipmentForBarcode) router.post('/batch', getEquipmentBatchForBarcode) export default router - diff --git a/backend/src/routes/batch.ts b/backend/src/routes/batch.ts index 5c2e9cb..7d2ea2c 100644 --- a/backend/src/routes/batch.ts +++ b/backend/src/routes/batch.ts @@ -1,10 +1,12 @@ import { Router } from 'express' import { batchBorrow, batchReturn, batchTransfer } from '../controllers/batchController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.post('/borrow', batchBorrow) -router.post('/return', batchReturn) -router.post('/transfer', batchTransfer) +router.post('/borrow', requirePermission(PERMISSIONS.ASSETS_BORROW), batchBorrow) +router.post('/return', requirePermission(PERMISSIONS.ASSETS_BORROW), batchReturn) +router.post('/transfer', requirePermission(PERMISSIONS.ASSETS_TRANSFER), batchTransfer) export default router diff --git a/backend/src/routes/batchImport.ts b/backend/src/routes/batchImport.ts index d2ba70f..898c483 100644 --- a/backend/src/routes/batchImport.ts +++ b/backend/src/routes/batchImport.ts @@ -1,13 +1,12 @@ import { Router } from 'express' import { batchImportPersons, batchImportEquipments } from '../controllers/batchImportController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() router.use(protect()) -router.use(requireAdmin()) - -router.post('/persons', batchImportPersons) -router.post('/equipments', batchImportEquipments) +router.post('/persons', requirePermission(PERMISSIONS.USERS_MANAGE), batchImportPersons) +router.post('/equipments', requirePermission(PERMISSIONS.ASSETS_IMPORT), batchImportEquipments) export default router diff --git a/backend/src/routes/borrow.ts b/backend/src/routes/borrow.ts index e6a7373..880f568 100644 --- a/backend/src/routes/borrow.ts +++ b/backend/src/routes/borrow.ts @@ -8,17 +8,19 @@ import { getBorrowRecordByCode, getBorrowRecordList } from '../controllers/borrowController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() // 借用相关 -router.post('/', borrowEquipment) -router.post('/scan', scanBorrow) -router.post('/scan-batch', scanBorrowBatch) -router.post('/anomalies/:equipment_id/mark-returned', resolveBorrowAnomalyAsReturned) -router.post('/anomalies/:equipment_id/register-borrow', registerBorrowAnomaly) -router.get('/scan/:equipment_code', getBorrowRecordByCode) -router.get('/records', getBorrowRecordList) +router.post('/', requirePermission(PERMISSIONS.ASSETS_BORROW), borrowEquipment) +router.post('/scan', requirePermission(PERMISSIONS.ASSETS_BORROW), scanBorrow) +router.post('/scan-batch', requirePermission(PERMISSIONS.ASSETS_BORROW), scanBorrowBatch) +router.post('/anomalies/:equipment_id/mark-returned', requirePermission(PERMISSIONS.ASSETS_BORROW), resolveBorrowAnomalyAsReturned) +router.post('/anomalies/:equipment_id/register-borrow', requirePermission(PERMISSIONS.ASSETS_BORROW), registerBorrowAnomaly) +router.get('/scan/:equipment_code', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getBorrowRecordByCode) +router.get('/records', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getBorrowRecordList) export default router @@ -32,7 +34,7 @@ import { searchReturnAssets } from '../controllers/borrowController' -returnRouter.post('/', returnEquipment) -returnRouter.post('/scan', scanReturn) -returnRouter.post('/scan-batch', scanReturnBatch) -returnRouter.get('/search-assets', searchReturnAssets) +returnRouter.post('/', requirePermission(PERMISSIONS.ASSETS_BORROW), returnEquipment) +returnRouter.post('/scan', requirePermission(PERMISSIONS.ASSETS_BORROW), scanReturn) +returnRouter.post('/scan-batch', requirePermission(PERMISSIONS.ASSETS_BORROW), scanReturnBatch) +returnRouter.get('/search-assets', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), searchReturnAssets) diff --git a/backend/src/routes/departments.ts b/backend/src/routes/departments.ts index 80214a7..c57978e 100644 --- a/backend/src/routes/departments.ts +++ b/backend/src/routes/departments.ts @@ -8,16 +8,17 @@ import { updateDepartment, deleteDepartment } from '../controllers/departmentController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.get('/options', protect(), getDepartmentOptions) -router.get('/tree', protect(), getDepartmentTree) -router.get('/organization-tree', protect(), requireAdmin(), getOrganizationTree) -router.get('/list', protect(), requireAdmin(), getDepartmentList) -router.post('/', protect(), requireAdmin(), createDepartment) -router.put('/:id', protect(), requireAdmin(), updateDepartment) -router.delete('/:id', protect(), requireAdmin(), deleteDepartment) +router.get('/options', protect(), requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getDepartmentOptions) +router.get('/tree', protect(), requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getDepartmentTree) +router.get('/organization-tree', protect(), requirePermission(PERMISSIONS.USERS_MANAGE), getOrganizationTree) +router.get('/list', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getDepartmentList) +router.post('/', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), createDepartment) +router.put('/:id', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), updateDepartment) +router.delete('/:id', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), deleteDepartment) export default router diff --git a/backend/src/routes/dingtalk.ts b/backend/src/routes/dingtalk.ts new file mode 100644 index 0000000..e8aefd0 --- /dev/null +++ b/backend/src/routes/dingtalk.ts @@ -0,0 +1,22 @@ +import { Router } from 'express' +import { + getDingTalkDeliveries, + getDingTalkDeliveryStatus, + getPublicDingTalkConfig, + loginWithDingTalkCode, + retryDingTalkDelivery +} from '../controllers/dingtalkController' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' + +const router = Router() + +router.get('/public-config', getPublicDingTalkConfig) +router.post('/h5-login', loginWithDingTalkCode) + +router.use('/deliveries', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS)) +router.get('/deliveries/status', getDingTalkDeliveryStatus) +router.get('/deliveries', getDingTalkDeliveries) +router.post('/deliveries/:id/retry', retryDingTalkDelivery) + +export default router diff --git a/backend/src/routes/disposal.ts b/backend/src/routes/disposal.ts index 21dbce0..7310214 100644 --- a/backend/src/routes/disposal.ts +++ b/backend/src/routes/disposal.ts @@ -7,18 +7,17 @@ import { completeDisposal, getEquipmentDisposalHistory } from '../controllers/disposalController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() router.use(protect()) -router.use(requireAdmin()) - -router.post('/', createDisposal) -router.get('/list', getDisposalList) -router.get('/:id', getDisposalDetail) -router.put('/:id/approve', approveDisposal) -router.put('/:id/complete', completeDisposal) -router.get('/equipment/:equipment_id/history', getEquipmentDisposalHistory) +router.post('/', requirePermission(PERMISSIONS.DISPOSALS_APPLY), createDisposal) +router.get('/list', requirePermission(PERMISSIONS.NOTIFICATIONS_VIEW), getDisposalList) +router.get('/equipment/:equipment_id/history', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getEquipmentDisposalHistory) +router.get('/:id', requirePermission(PERMISSIONS.NOTIFICATIONS_VIEW), getDisposalDetail) +router.put('/:id/approve', requirePermission(PERMISSIONS.DISPOSALS_APPROVE), approveDisposal) +router.put('/:id/complete', requirePermission(PERMISSIONS.DISPOSALS_COMPLETE), completeDisposal) export default router diff --git a/backend/src/routes/equipment.ts b/backend/src/routes/equipment.ts index 0314a6d..de4a25c 100644 --- a/backend/src/routes/equipment.ts +++ b/backend/src/routes/equipment.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { getEquipmentList, getEquipmentDetail, + getEquipmentTransferHistory, createEquipment, updateEquipment, deleteEquipment, @@ -9,21 +10,19 @@ import { verifyCode, scanEquipment } from '../controllers/equipmentController' -import { requireAdmin } from '../middleware/auth' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.get('/scan/:equipment_code', scanEquipment) - -router.use(requireAdmin()) - -router.get('/list', getEquipmentList) -router.get('/:id', getEquipmentDetail) -router.post('/', createEquipment) -router.put('/:id', updateEquipment) -router.delete('/:id', deleteEquipment) -router.post('/generate-code', generateCode) -router.post('/verify-code', verifyCode) +router.get('/scan/:equipment_code', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), scanEquipment) +router.get('/list', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getEquipmentList) +router.get('/:id/transfer-history', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getEquipmentTransferHistory) +router.get('/:id', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getEquipmentDetail) +router.post('/', requirePermission(PERMISSIONS.ASSETS_CREATE), createEquipment) +router.put('/:id', requirePermission(PERMISSIONS.ASSETS_CORRECT), updateEquipment) +router.delete('/:id', requirePermission(PERMISSIONS.ASSETS_DELETE), deleteEquipment) +router.post('/generate-code', requirePermission(PERMISSIONS.ASSETS_CREATE), generateCode) +router.post('/verify-code', requirePermission(PERMISSIONS.ASSETS_CREATE), verifyCode) export default router - diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index a9dbc30..58b83df 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -1,8 +1,12 @@ import { Router } from 'express' import { exportEquipment, exportBorrowRecords, generateImportTemplate, generatePersonTemplate } from '../controllers/exportController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() +router.use(requirePermission(PERMISSIONS.REPORTS_VIEW)) + router.get('/equipment', exportEquipment) router.get('/borrow-records', exportBorrowRecords) router.get('/template', generateImportTemplate) diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts index 723d746..5fdcd10 100644 --- a/backend/src/routes/inventory.ts +++ b/backend/src/routes/inventory.ts @@ -10,11 +10,12 @@ import { previewInventoryTaskController, submitAdminInventoryScanController } from '../controllers/inventoryController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.use(protect(), requireAdmin()) +router.use(protect(), requirePermission(PERMISSIONS.INVENTORY_MANAGE)) router.post('/tasks/preview', previewInventoryTaskController) router.post('/tasks', createInventoryTaskController) diff --git a/backend/src/routes/maintenance.ts b/backend/src/routes/maintenance.ts index c13e31c..d34aca4 100644 --- a/backend/src/routes/maintenance.ts +++ b/backend/src/routes/maintenance.ts @@ -8,19 +8,18 @@ import { cancelMaintenance, getEquipmentMaintenanceHistory } from '../controllers/maintenanceController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() router.use(protect()) -router.use(requireAdmin()) - -router.post('/', createMaintenance) -router.get('/list', getMaintenanceList) -router.get('/:id', getMaintenanceDetail) -router.put('/:id', updateMaintenance) -router.put('/:id/complete', completeMaintenance) -router.put('/:id/cancel', cancelMaintenance) -router.get('/equipment/:equipment_id/history', getEquipmentMaintenanceHistory) +router.post('/', requirePermission(PERMISSIONS.ASSETS_MAINTENANCE), createMaintenance) +router.get('/list', requirePermission(PERMISSIONS.ASSETS_MAINTENANCE), getMaintenanceList) +router.get('/equipment/:equipment_id/history', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getEquipmentMaintenanceHistory) +router.get('/:id', requirePermission(PERMISSIONS.ASSETS_MAINTENANCE), getMaintenanceDetail) +router.put('/:id', requirePermission(PERMISSIONS.ASSETS_MAINTENANCE), updateMaintenance) +router.put('/:id/complete', requirePermission(PERMISSIONS.ASSETS_MAINTENANCE), completeMaintenance) +router.put('/:id/cancel', requirePermission(PERMISSIONS.ASSETS_MAINTENANCE), cancelMaintenance) export default router diff --git a/backend/src/routes/mount.ts b/backend/src/routes/mount.ts index 64a3383..0af0dc4 100644 --- a/backend/src/routes/mount.ts +++ b/backend/src/routes/mount.ts @@ -7,28 +7,29 @@ import { getMountHistory, getAvailableChildren } from '../controllers/mountController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() router.use(protect()) // 获取可挂载的子资产列表 -router.get('/available-children', requireAdmin(), getAvailableChildren) +router.get('/available-children', requirePermission(PERMISSIONS.ASSETS_MOUNT), getAvailableChildren) // 获取资产的挂载历史 -router.get('/:id/mount-history', getMountHistory) +router.get('/:id/mount-history', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getMountHistory) // 获取父资产的子资产列表 -router.get('/:parentId/children', getChildren) +router.get('/:parentId/children', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getChildren) // 挂载子资产到父资产 -router.post('/:parentId/mount', requireAdmin(), mountChild) +router.post('/:parentId/mount', requirePermission(PERMISSIONS.ASSETS_MOUNT), mountChild) // 从父资产卸载子资产 -router.post('/:parentId/unmount', requireAdmin(), unmountChild) +router.post('/:parentId/unmount', requirePermission(PERMISSIONS.ASSETS_MOUNT), unmountChild) // 更换配件 -router.post('/:parentId/replace', requireAdmin(), replaceChild) +router.post('/:parentId/replace', requirePermission(PERMISSIONS.ASSETS_MOUNT), replaceChild) export default router diff --git a/backend/src/routes/myAssets.ts b/backend/src/routes/myAssets.ts new file mode 100644 index 0000000..5d1d39d --- /dev/null +++ b/backend/src/routes/myAssets.ts @@ -0,0 +1,10 @@ +import { Router } from 'express' +import { getMyAssets } from '../controllers/myAssetController' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' + +const router = Router() + +router.get('/', protect(), requirePermission(PERMISSIONS.ASSETS_VIEW_OWN), getMyAssets) + +export default router diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 5e923db..8abc641 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -5,11 +5,13 @@ import { markAllNotificationsAsRead, markNotificationAsRead } from '../controllers/notificationController' -import { protect } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() router.use(protect()) +router.use(requirePermission(PERMISSIONS.NOTIFICATIONS_VIEW)) router.get('/', getNotificationList) router.get('/unread-count', getUnreadNotificationCount) diff --git a/backend/src/routes/operationLog.ts b/backend/src/routes/operationLog.ts index 0429401..767a138 100644 --- a/backend/src/routes/operationLog.ts +++ b/backend/src/routes/operationLog.ts @@ -1,11 +1,12 @@ import { Router } from 'express' import { getOperationLogList, getOperationTypes } from '../controllers/operationLogController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() router.use(protect()) -router.use(requireAdmin()) +router.use(requirePermission(PERMISSIONS.SYSTEM_SETTINGS)) router.get('/list', getOperationLogList) router.get('/types', getOperationTypes) diff --git a/backend/src/routes/persons.ts b/backend/src/routes/persons.ts index b2c724e..e49edcc 100644 --- a/backend/src/routes/persons.ts +++ b/backend/src/routes/persons.ts @@ -8,20 +8,20 @@ import { getPersonDetail, deletePerson } from '../controllers/personController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() router.use(protect()) -router.get('/', getPersonList) -router.get('/options', getPersonOptions) -router.get('/:id', getPersonDetail) +router.get('/', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonList) +router.get('/options', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonOptions) +router.get('/:id', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonDetail) -router.post('/', requireAdmin(), createPerson) -router.put('/:id', requireAdmin(), updatePerson) -router.patch('/:id/status', requireAdmin(), updatePersonStatus) -router.delete('/:id', requireAdmin(), deletePerson) +router.post('/', requirePermission(PERMISSIONS.USERS_MANAGE), createPerson) +router.put('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), updatePerson) +router.patch('/:id/status', requirePermission(PERMISSIONS.USERS_MANAGE), updatePersonStatus) +router.delete('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), deletePerson) export default router - diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts index 404bfd9..0d65884 100644 --- a/backend/src/routes/search.ts +++ b/backend/src/routes/search.ts @@ -1,8 +1,10 @@ import { Router } from 'express' import { globalSearch } from '../controllers/searchController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.get('/', globalSearch) +router.get('/', requirePermission(PERMISSIONS.REPORTS_VIEW), globalSearch) export default router diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 07e3e8d..d67d1ec 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { getSiteConfig, updateSiteConfig } from '../controllers/settingsController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() @@ -9,9 +10,8 @@ router.get('/site', getSiteConfig) router.put( '/site', protect(), - requireAdmin(), + requirePermission(PERMISSIONS.SYSTEM_SETTINGS), updateSiteConfig ) export default router - diff --git a/backend/src/routes/statistics.ts b/backend/src/routes/statistics.ts index 6c0d80e..e92e073 100644 --- a/backend/src/routes/statistics.ts +++ b/backend/src/routes/statistics.ts @@ -3,11 +3,14 @@ import { getDashboardStats, getValueStatistics } from '../controllers/statisticsController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() +router.use(requirePermission(PERMISSIONS.REPORTS_VIEW)) + router.get('/dashboard', getDashboardStats) router.get('/value', getValueStatistics) export default router - diff --git a/backend/src/routes/storageLocations.ts b/backend/src/routes/storageLocations.ts index 683b384..c82d766 100644 --- a/backend/src/routes/storageLocations.ts +++ b/backend/src/routes/storageLocations.ts @@ -6,15 +6,15 @@ import { updateStorageLocation, updateStorageLocationStatus } from '../controllers/storageLocationController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.get('/options', protect(), getStorageLocationOptions) -router.get('/list', protect(), requireAdmin(), getStorageLocationList) -router.post('/', protect(), requireAdmin(), createStorageLocation) -router.put('/:id', protect(), requireAdmin(), updateStorageLocation) -router.patch('/:id/status', protect(), requireAdmin(), updateStorageLocationStatus) +router.get('/options', protect(), requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getStorageLocationOptions) +router.get('/list', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getStorageLocationList) +router.post('/', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), createStorageLocation) +router.put('/:id', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), updateStorageLocation) +router.patch('/:id/status', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), updateStorageLocationStatus) export default router - diff --git a/backend/src/routes/sync.ts b/backend/src/routes/sync.ts index 8433a66..968158e 100644 --- a/backend/src/routes/sync.ts +++ b/backend/src/routes/sync.ts @@ -7,11 +7,12 @@ import { runHrSyncController, updateHrSyncChangeProcessingController } from '../controllers/syncController' -import { protect, requireAdmin } from '../middleware/auth' +import { protect, requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.use(protect(), requireAdmin()) +router.use(protect(), requirePermission(PERMISSIONS.SYNC_MANAGE)) router.get('/hr/jobs', getHrSyncJobListController) router.get('/hr/jobs/:id', getHrSyncJobDetailController) diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 9c7403e..9c1f834 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -7,9 +7,13 @@ import { updateUserStatus, deleteUser } from '../controllers/userController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() +router.use(requirePermission(PERMISSIONS.USERS_MANAGE)) + router.get('/', getUserList) router.post('/', createUser) router.put('/:id', updateUser) @@ -18,4 +22,3 @@ router.patch('/:id/status', updateUserStatus) router.delete('/:id', deleteUser) export default router - diff --git a/backend/src/services/__tests__/assetMovementService.concurrency.test.ts b/backend/src/services/__tests__/assetMovementService.concurrency.test.ts new file mode 100644 index 0000000..ed019a5 --- /dev/null +++ b/backend/src/services/__tests__/assetMovementService.concurrency.test.ts @@ -0,0 +1,155 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + finalizeBorrowAnomalyReturnState, + transferAssetTree +} from '../assetMovementService' + +const now = new Date('2026-07-14T04:00:00.000Z') +const rootEquipment = { + id: 20, + equipment_code: 'JS-PC-0020', + equipment_name: '主机', + parent_id: null, + department_id: 3, + storage_location_id: 1, + status: '可用', + mount_status: 'none', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 1000, + updated_at: now +} +const childEquipment = { + id: 21, + equipment_code: 'JS-RAM-0021', + equipment_name: '内存', + parent_id: rootEquipment.id, + department_id: 3, + storage_location_id: 1, + status: '可用', + mount_status: 'mounted', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 200, + updated_at: now +} + +test('初读资产树后并发挂载新配件时调拨中止且不拆分部门', async () => { + let mountedAfterInitialRead = false + let movementWriteCount = 0 + let equipmentWriteCount = 0 + const lockQueries: any[] = [] + const client = { + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) { + lockQueries.push(query) + mountedAfterInitialRead = true + return [] + } + if (query.sql.includes('WHERE equipment.id = ?')) { + return [rootEquipment] + } + if (query.sql.includes('WHERE equipment.parent_id IN')) { + return mountedAfterInitialRead && query.values.includes(rootEquipment.id) + ? [childEquipment] + : [] + } + return [] + }, + equipment: { + findUnique: async () => rootEquipment, + // 故意模拟 MySQL RR 已建立的旧快照:并发挂载后 ORM 普通查询仍看不到新配件。 + findMany: async () => [], + updateMany: async () => { + equipmentWriteCount += 1 + return { count: 1 } + } + }, + borrowRecord: { count: async () => 0 }, + department: { findUnique: async () => ({ name: '原部门' }) }, + assetTransferRecord: { + create: async () => { + movementWriteCount += 1 + return { id: 1, items: [] } + } + } + } + + await assert.rejects( + transferAssetTree(client as any, { + rootEquipmentId: rootEquipment.id, + actionType: 'transfer', + toDepartmentId: 8, + toDepartmentName: '目标部门', + operatedBy: 6, + reason: '工位调整' + }), + /资产树状态或挂载关系已变化/ + ) + + assert.match(lockQueries[0].sql, /IN \(\?\) ORDER BY id FOR UPDATE/) + assert.deepEqual(lockQueries[0].values, [20]) + assert.equal(movementWriteCount, 0) + assert.equal(equipmentWriteCount, 0) + assert.equal(rootEquipment.department_id, childEquipment.department_id) +}) + +test('异常归还发现活动借用记录时中止,不写资产状态', async () => { + let equipmentWriteCount = 0 + const anomalousRoot = { + ...rootEquipment, + status: '已借出', + total_quantity: 1, + remaining_quantity: 0 + } + const client = { + $queryRaw: async (query: any) => query.sql.includes('FROM borrow_record') + ? [{ id: 99 }] + : [], + equipment: { + updateMany: async () => { + equipmentWriteCount += 1 + return { count: 1 } + } + } + } + + await assert.rejects( + finalizeBorrowAnomalyReturnState(client as any, anomalousRoot as any), + /已存在未归还借用记录/ + ) + assert.equal(equipmentWriteCount, 0) +}) + +test('异常归还在无活动借用时按锁后快照 CAS 恢复资产', async () => { + const anomalousRoot = { + ...rootEquipment, + status: '已借出', + total_quantity: 1, + remaining_quantity: 0 + } + const updates: any[] = [] + const client = { + $queryRaw: async () => [], + equipment: { + updateMany: async (args: any) => { + updates.push(args) + return { count: 1 } + }, + findMany: async () => [] + } + } + + const result = await finalizeBorrowAnomalyReturnState(client as any, anomalousRoot as any) + + assert.equal(result.remainingQuantity, 1) + assert.equal(result.status, '可用') + assert.equal(updates.length, 1) + assert.equal(updates[0].where.remaining_quantity, 0) + assert.equal(updates[0].where.status, '已借出') + assert.equal(updates[0].where.updated_at, anomalousRoot.updated_at) + assert.equal(updates[0].data.remaining_quantity, 1) + assert.equal(updates[0].data.department_id, null) +}) diff --git a/backend/src/services/__tests__/dingtalkClient.config.test.ts b/backend/src/services/__tests__/dingtalkClient.config.test.ts new file mode 100644 index 0000000..a1a8e74 --- /dev/null +++ b/backend/src/services/__tests__/dingtalkClient.config.test.ts @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getDingTalkPublicConfig } from '../dingtalkClient' + +test('钉钉 H5 公开配置返回 clientId 但不泄露 AppSecret', () => { + const keys = [ + 'DINGTALK_APP_KEY', + 'DINGTALK_APP_SECRET', + 'DINGTALK_AGENT_ID', + 'DINGTALK_CORP_ID', + 'DINGTALK_PUBLIC_BASE_URL' + ] as const + const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])) + + process.env.DINGTALK_APP_KEY = 'ding-app-key' + process.env.DINGTALK_APP_SECRET = 'must-not-leak' + process.env.DINGTALK_AGENT_ID = '10001' + process.env.DINGTALK_CORP_ID = 'ding-corp' + process.env.DINGTALK_PUBLIC_BASE_URL = 'https://assets.example.com' + + try { + const config = getDingTalkPublicConfig() + assert.deepEqual(config, { + configured: true, + corpId: 'ding-corp', + clientId: 'ding-app-key' + }) + assert.equal(JSON.stringify(config).includes('must-not-leak'), false) + } finally { + for (const key of keys) { + if (original[key] === undefined) delete process.env[key] + else process.env[key] = original[key] + } + } +}) diff --git a/backend/src/services/__tests__/dingtalkDeliveryService.lease.test.ts b/backend/src/services/__tests__/dingtalkDeliveryService.lease.test.ts new file mode 100644 index 0000000..464f165 --- /dev/null +++ b/backend/src/services/__tests__/dingtalkDeliveryService.lease.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { updateSendingDeliveryIfLeaseOwned } from '../dingtalkDeliveryService' + +test('租约已被新 Worker 接管时,旧 Worker 不能覆盖成功、失败或重试状态', async () => { + const originalUpdateMany = prisma.dingTalkDelivery.updateMany + const originalUpdate = prisma.dingTalkDelivery.update + const persisted = { + id: 81, + status: 'sending', + lease_owner: 'new-worker:81' + } + const attempts: any[] = [] + let unconditionalUpdateCount = 0 + + ;(prisma.dingTalkDelivery.updateMany as any) = async (args: any) => { + attempts.push(args) + const ownsCurrentLease = args.where.id === persisted.id && + args.where.status === persisted.status && + args.where.lease_owner === persisted.lease_owner + if (!ownsCurrentLease) return { count: 0 } + Object.assign(persisted, args.data) + return { count: 1 } + } + ;(prisma.dingTalkDelivery.update as any) = async () => { + unconditionalUpdateCount += 1 + return persisted + } + + try { + const results = await Promise.all([ + updateSendingDeliveryIfLeaseOwned(persisted.id, { status: 'sent' }), + updateSendingDeliveryIfLeaseOwned(persisted.id, { status: 'failed' }), + updateSendingDeliveryIfLeaseOwned(persisted.id, { status: 'retrying' }) + ]) + + assert.deepEqual(results, [false, false, false]) + assert.equal(attempts.length, 3) + for (const attempt of attempts) { + assert.deepEqual(Object.keys(attempt.where).sort(), ['id', 'lease_owner', 'status']) + assert.equal(attempt.where.id, persisted.id) + assert.equal(attempt.where.status, 'sending') + assert.notEqual(attempt.where.lease_owner, persisted.lease_owner) + assert.ok(String(attempt.where.lease_owner).length > 0) + } + assert.equal(persisted.status, 'sending') + assert.equal(persisted.lease_owner, 'new-worker:81') + assert.equal(unconditionalUpdateCount, 0) + } finally { + ;(prisma.dingTalkDelivery.updateMany as any) = originalUpdateMany + ;(prisma.dingTalkDelivery.update as any) = originalUpdate + } +}) diff --git a/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts b/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts new file mode 100644 index 0000000..e0c15fb --- /dev/null +++ b/backend/src/services/__tests__/dingtalkDeliveryService.reminder.test.ts @@ -0,0 +1,96 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { createOverdueDisposalReminders } from '../dingtalkDeliveryService' + +const candidate = { + id: 71, + equipment_code: 'JS-PC-0071', + status: 'pending', + applied_at: new Date('2026-07-12T00:00:00.000Z'), + reminded_at: null +} + +test('24小时催办在无行政时回退通知启用管理员', async () => { + const originalFindMany = prisma.disposalRecord.findMany + const originalTransaction = prisma.$transaction + const events: string[] = [] + + ;(prisma.disposalRecord.findMany as any) = async () => [candidate] + ;(prisma.$transaction as any) = async (callback: any) => callback({ + user: { + findMany: async (args: any) => { + const role = args.where.role.in[0] + events.push(`recipients:${role}`) + return role === 'admin' + ? [{ id: 9, person: { source_userid: 'ding-admin-9' } }] + : [] + } + }, + disposalRecord: { + updateMany: async () => { + events.push('claim') + return { count: 1 } + } + }, + disposalAction: { + create: async () => { + events.push('action') + return { id: 1 } + } + }, + notification: { + create: async () => { + events.push('notification') + return { id: 2 } + } + }, + dingTalkDelivery: { + createMany: async () => { + events.push('delivery') + return { count: 1 } + } + } + }) + + try { + await createOverdueDisposalReminders() + assert.deepEqual(events, [ + 'recipients:administrative', + 'recipients:admin', + 'claim', + 'action', + 'notification', + 'delivery' + ]) + } finally { + ;(prisma.disposalRecord.findMany as any) = originalFindMany + ;(prisma.$transaction as any) = originalTransaction + } +}) + +test('24小时催办没有任何收件人时不消费 reminded_at', async () => { + const originalFindMany = prisma.disposalRecord.findMany + const originalTransaction = prisma.$transaction + let claimCount = 0 + + ;(prisma.disposalRecord.findMany as any) = async () => [candidate] + ;(prisma.$transaction as any) = async (callback: any) => callback({ + user: { findMany: async () => [] }, + disposalRecord: { + updateMany: async () => { + claimCount += 1 + return { count: 1 } + } + } + }) + + try { + await createOverdueDisposalReminders() + assert.equal(claimCount, 0) + } finally { + ;(prisma.disposalRecord.findMany as any) = originalFindMany + ;(prisma.$transaction as any) = originalTransaction + } +}) diff --git a/backend/src/services/assetMovementService.ts b/backend/src/services/assetMovementService.ts new file mode 100644 index 0000000..ab475a6 --- /dev/null +++ b/backend/src/services/assetMovementService.ts @@ -0,0 +1,493 @@ +import { Prisma } from '@prisma/client' +import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' + +type TransactionClient = Prisma.TransactionClient + +export type AssetTreeItem = { + id: number + equipment_code: string + equipment_name: string + parent_id: number | null + department_id: number | null + storage_location_id: number | null + status: string + mount_status: string + total_quantity: number + remaining_quantity: number + unit_price: Prisma.Decimal + updated_at: Date +} + +const treeSelect = { + id: true, + equipment_code: true, + equipment_name: true, + parent_id: true, + department_id: true, + storage_location_id: true, + status: true, + mount_status: true, + total_quantity: true, + remaining_quantity: true, + unit_price: true, + updated_at: true +} as const + +export class AssetTreeConflictError extends Error { + status = 409 + + constructor(message = '资产树状态或挂载关系已变化,请刷新后重试') { + super(message) + this.name = 'AssetTreeConflictError' + } +} + +export const lockEquipmentRows = async ( + client: TransactionClient, + equipmentIds: number[] +) => { + const ids = [...new Set(equipmentIds)] + .filter((id) => Number.isInteger(id) && id > 0) + .sort((left, right) => left - right) + if (ids.length === 0) return + + await client.$queryRaw( + Prisma.sql`SELECT id FROM equipment WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE` + ) +} + +const getAssetTreeFingerprint = (items: AssetTreeItem[]) => JSON.stringify( + items + .map((item) => ({ + id: item.id, + parent_id: item.parent_id, + department_id: item.department_id, + status: item.status, + mount_status: item.mount_status, + remaining_quantity: item.remaining_quantity, + updated_at: item.updated_at.getTime() + })) + .sort((left, right) => left.id - right.id) +) + +const normalizeRawAssetTreeItem = (item: Record): AssetTreeItem => { + const updatedAt = item.updated_at instanceof Date + ? item.updated_at + : new Date(String(item.updated_at || '')) + if (Number.isNaN(updatedAt.getTime())) { + throw new AssetTreeConflictError('资产更新时间无效,请先检查数据') + } + + return { + id: Number(item.id), + equipment_code: String(item.equipment_code || ''), + equipment_name: String(item.equipment_name || ''), + parent_id: item.parent_id === null ? null : Number(item.parent_id), + department_id: item.department_id === null ? null : Number(item.department_id), + storage_location_id: item.storage_location_id === null + ? null + : Number(item.storage_location_id), + status: String(item.status || ''), + mount_status: String(item.mount_status || ''), + total_quantity: Number(item.total_quantity), + remaining_quantity: Number(item.remaining_quantity), + unit_price: new Prisma.Decimal(String(item.unit_price ?? 0)), + updated_at: updatedAt + } +} + +export const loadAssetTree = async ( + client: TransactionClient, + rootEquipmentId: number +): Promise => { + const root = await client.equipment.findUnique({ + where: { id: rootEquipmentId }, + select: treeSelect + }) + if (!root) { + throw new Error('资产不存在') + } + + const result: AssetTreeItem[] = [root] + const visited = new Set([root.id]) + let parentIds = [root.id] + let depth = 0 + while (parentIds.length > 0) { + if (depth >= 100) { + throw new Error('资产父子关系层级过深,请先检查资产关系') + } + const children = await client.equipment.findMany({ + where: { parent_id: { in: parentIds } }, + select: treeSelect, + orderBy: { id: 'asc' } + }) + if (children.length === 0) break + + const repeated = children.find((item) => visited.has(item.id)) + if (repeated) { + throw new Error(`资产 ${repeated.equipment_code} 存在循环父子关系,请先修复关系`) + } + + children.forEach((item) => visited.add(item.id)) + result.push(...children) + parentIds = children.map((item) => item.id) + depth += 1 + } + + return result +} + +const loadAssetTreeForUpdate = async ( + client: TransactionClient, + rootEquipmentId: number +): Promise => { + if (!Number.isInteger(rootEquipmentId) || rootEquipmentId <= 0) { + throw new Error('资产参数不合法') + } + + const rootRows = await client.$queryRaw>>(Prisma.sql` + SELECT + equipment.id, + equipment.equipment_code, + equipment.equipment_name, + equipment.parent_id, + equipment.department_id, + equipment.storage_location_id, + equipment.status, + equipment.mount_status, + equipment.total_quantity, + equipment.remaining_quantity, + equipment.unit_price, + equipment.updated_at + FROM equipment + WHERE equipment.id = ${rootEquipmentId} + FOR UPDATE + `) + const root = rootRows[0] ? normalizeRawAssetTreeItem(rootRows[0]) : null + if (!root) { + throw new Error('资产不存在') + } + + const result: AssetTreeItem[] = [root] + const visited = new Set([root.id]) + let parentIds = [root.id] + let depth = 0 + while (parentIds.length > 0) { + if (depth >= 100) { + throw new Error('资产父子关系层级过深,请先检查资产关系') + } + const childRows = await client.$queryRaw>>(Prisma.sql` + SELECT + equipment.id, + equipment.equipment_code, + equipment.equipment_name, + equipment.parent_id, + equipment.department_id, + equipment.storage_location_id, + equipment.status, + equipment.mount_status, + equipment.total_quantity, + equipment.remaining_quantity, + equipment.unit_price, + equipment.updated_at + FROM equipment + WHERE equipment.parent_id IN (${Prisma.join(parentIds)}) + ORDER BY equipment.id + FOR UPDATE + `) + const children = childRows.map(normalizeRawAssetTreeItem) + if (children.length === 0) break + + const repeated = children.find((item) => visited.has(item.id)) + if (repeated) { + throw new Error(`资产 ${repeated.equipment_code} 存在循环父子关系,请先修复关系`) + } + + children.forEach((item) => visited.add(item.id)) + result.push(...children) + parentIds = children.map((item) => item.id) + depth += 1 + } + + return result +} + +export const loadAndLockAssetTree = async ( + client: TransactionClient, + rootEquipmentId: number +) => { + const beforeLock = await loadAssetTree(client, rootEquipmentId) + await lockEquipmentRows(client, beforeLock.map((item) => item.id)) + const afterLock = await loadAssetTreeForUpdate(client, rootEquipmentId) + if (getAssetTreeFingerprint(beforeLock) !== getAssetTreeFingerprint(afterLock)) { + throw new AssetTreeConflictError() + } + return afterLock +} + +const getDepartmentName = async (client: TransactionClient, departmentId: number | null) => { + if (!departmentId) return null + const department = await client.department.findUnique({ + where: { id: departmentId }, + select: { name: true } + }) + return department?.name || null +} + +export const createAssetMovementRecord = async ( + client: TransactionClient, + input: { + items: AssetTreeItem[] + actionType: 'transfer' | 'idle' | 'return' + toDepartmentId: number | null + toDepartmentName?: string | null + operatedBy: number + reason?: string | null + } +) => { + const root = input.items[0] + const fromDepartmentName = await getDepartmentName(client, root.department_id) + + return client.assetTransferRecord.create({ + data: { + root_equipment_id: root.id, + action_type: input.actionType, + from_department_id: root.department_id, + from_department_name: fromDepartmentName, + to_department_id: input.toDepartmentId, + to_department_name: input.toDepartmentName || null, + reason: input.reason?.trim() || null, + operated_by: input.operatedBy, + items: { + create: input.items.map((item) => ({ + equipment_id: item.id, + equipment_code: item.equipment_code, + equipment_name: item.equipment_name, + parent_equipment_id: item.parent_id, + from_department_id: item.department_id, + to_department_id: input.toDepartmentId, + storage_location_id: item.storage_location_id, + snapshot_status: item.status + })) + } + }, + include: { items: true } + }) +} + +export const transferAssetTree = async ( + client: TransactionClient, + input: { + rootEquipmentId: number + actionType: 'transfer' | 'idle' + toDepartmentId: number | null + toDepartmentName?: string | null + operatedBy: number + reason: string + } +) => { + const items = await loadAndLockAssetTree(client, input.rootEquipmentId) + const root = items[0] + if (root.parent_id) { + throw new Error('挂载中的子资产不能单独调拨') + } + + const blocked = items.find((item) => + ['已借出', '维护中', '待报废', '已报废'].includes(item.status) + ) + if (blocked) { + throw new Error(`资产 ${blocked.equipment_code} 当前为${blocked.status},不能调拨`) + } + if (input.actionType === 'idle') { + const nonAvailable = items.find((item) => item.status !== '可用') + if (nonAvailable) { + throw new Error(`资产 ${nonAvailable.equipment_code} 当前不是可用状态,不能转为闲置`) + } + } + + const activeBorrowCount = await client.borrowRecord.count({ + where: { + equipment_id: { in: items.map((item) => item.id) }, + status: '已借出' + } + }) + if (activeBorrowCount > 0) { + throw new Error('资产树存在未归还记录,请先走归还流程') + } + + const movement = await createAssetMovementRecord(client, { + items, + actionType: input.actionType, + toDepartmentId: input.toDepartmentId, + toDepartmentName: input.toDepartmentName, + operatedBy: input.operatedBy, + reason: input.reason + }) + + for (const item of items) { + const idleData = input.actionType === 'idle' + ? { + status: '可用', + remaining_quantity: item.total_quantity, + ...calculateEquipmentValues({ + unitPrice: Number(item.unit_price), + totalQuantity: item.total_quantity, + remainingQuantity: item.total_quantity, + status: '可用' + }) + } + : {} + + const updated = await client.equipment.updateMany({ + where: { + id: item.id, + status: item.status, + department_id: item.department_id, + parent_id: item.parent_id, + mount_status: item.mount_status, + remaining_quantity: item.remaining_quantity, + updated_at: item.updated_at + }, + data: { + department_id: input.toDepartmentId, + ...idleData + } + }) + if (updated.count !== 1) { + throw new Error('资产关系或状态已变化,请刷新后重试') + } + } + + return movement +} + +export const recordReturnToStorage = async ( + client: TransactionClient, + input: { + rootEquipmentId: number + operatedBy: number + reason?: string | null + } +) => { + const items = await loadAndLockAssetTree(client, input.rootEquipmentId) + const movement = await createAssetMovementRecord(client, { + items, + actionType: 'return', + toDepartmentId: null, + operatedBy: input.operatedBy, + reason: input.reason || '正常归还入仓' + }) + return { items, movement } +} + +const updateReturnedRootWithSnapshot = async ( + client: TransactionClient, + root: AssetTreeItem, + input: { + remainingQuantity: number + status: '可用' | '已借出' + departmentId: number | null + } +) => { + const values = calculateEquipmentValues({ + unitPrice: Number(root.unit_price), + totalQuantity: root.total_quantity, + remainingQuantity: input.remainingQuantity, + status: input.status + }) + const updated = await client.equipment.updateMany({ + where: { + id: root.id, + parent_id: root.parent_id, + department_id: root.department_id, + status: root.status, + mount_status: root.mount_status, + total_quantity: root.total_quantity, + remaining_quantity: root.remaining_quantity, + unit_price: root.unit_price, + updated_at: root.updated_at + }, + data: { + remaining_quantity: input.remainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value, + status: input.status, + department_id: input.departmentId + } + }) + if (updated.count !== 1) { + throw new AssetTreeConflictError('资产库存或状态已变化,请刷新后重试') + } +} + +export const finalizeNormalReturnState = async ( + client: TransactionClient, + root: AssetTreeItem +) => { + if (root.parent_id !== null) { + throw new AssetTreeConflictError('挂载中的子资产不能单独归还') + } + if (root.total_quantity <= 0 || root.remaining_quantity < 0 || + root.remaining_quantity >= root.total_quantity) { + throw new AssetTreeConflictError('资产库存与借出记录不一致,请先核查') + } + const expectedCurrentStatus = root.remaining_quantity > 0 ? '可用' : '已借出' + if (root.status !== expectedCurrentStatus) { + throw new AssetTreeConflictError('资产状态与当前库存不一致,请先核查') + } + + const remainingQuantity = root.remaining_quantity + 1 + const borrowedQuantity = root.total_quantity - remainingQuantity + const status = remainingQuantity > 0 ? '可用' : '已借出' + const departmentId = borrowedQuantity > 0 ? root.department_id : null + await updateReturnedRootWithSnapshot(client, root, { + remainingQuantity, + status, + departmentId + }) + + const childStatus = borrowedQuantity > 0 ? '已借出' : '可用' + await syncMountedChildrenState( + client, + root.id, + childStatus, + borrowedQuantity > 0 ? {} : { departmentId: null } + ) + + return { remainingQuantity, borrowedQuantity, status, departmentId } +} + +export const finalizeBorrowAnomalyReturnState = async ( + client: TransactionClient, + root: AssetTreeItem +) => { + const activeBorrowRows = await client.$queryRaw>(Prisma.sql` + SELECT id + FROM borrow_record + WHERE equipment_id = ${root.id} AND status = ${'已借出'} + ORDER BY id + FOR UPDATE + `) + if (activeBorrowRows.length > 0) { + throw new AssetTreeConflictError('该资产已存在未归还借用记录,不能按异常归还处理') + } + if (root.parent_id !== null || root.status !== '已借出' || + root.total_quantity <= 0 || root.remaining_quantity >= root.total_quantity) { + throw new AssetTreeConflictError('资产已不符合借用单缺失异常处理条件') + } + + await updateReturnedRootWithSnapshot(client, root, { + remainingQuantity: root.total_quantity, + status: '可用', + departmentId: null + }) + await syncMountedChildrenState(client, root.id, '可用', { departmentId: null }) + + return { + remainingQuantity: root.total_quantity, + borrowedQuantity: 0, + status: '可用' as const, + departmentId: null + } +} diff --git a/backend/src/services/businessNotificationService.ts b/backend/src/services/businessNotificationService.ts new file mode 100644 index 0000000..f3cd909 --- /dev/null +++ b/backend/src/services/businessNotificationService.ts @@ -0,0 +1,101 @@ +import type { Prisma } from '@prisma/client' + +type TransactionClient = Prisma.TransactionClient + +export type NotificationRecipient = { + id: number + person: { source_userid: string | null } | null +} +export const findActiveUsersByRoles = async ( + client: TransactionClient, + roles: string[] +): Promise => client.user.findMany({ + where: { + status: 'active', + role: { in: roles } + }, + select: { + id: true, + person: { select: { source_userid: true } } + } +}) + +export const findActiveUsersByIds = async ( + client: TransactionClient, + userIds: number[] +): Promise => { + if (userIds.length === 0) return [] + return client.user.findMany({ + where: { + id: { in: userIds }, + status: 'active' + }, + select: { + id: true, + person: { select: { source_userid: true } } + } + }) +} + +export const mergeRecipients = (...groups: NotificationRecipient[][]) => { + const recipients = new Map() + groups.flat().forEach((recipient) => recipients.set(recipient.id, recipient)) + return [...recipients.values()] +} + +export const createBusinessNotification = async ( + client: TransactionClient, + input: { + eventType: string + businessType: string + businessId: number + title: string + content: string + level?: string + actionUrl: string + metadata?: Record + recipients: NotificationRecipient[] + } +) => { + if (input.recipients.length === 0) { + return null + } + + const notification = await client.notification.create({ + data: { + title: input.title, + content: input.content, + type: input.businessType, + level: input.level || 'info', + action_url: input.actionUrl, + metadata: JSON.stringify({ + event_type: input.eventType, + business_type: input.businessType, + business_id: input.businessId, + ...(input.metadata || {}) + }), + recipients: { + create: input.recipients.map((recipient) => ({ user_id: recipient.id })) + } + } + }) + + await client.dingTalkDelivery.createMany({ + data: input.recipients.map((recipient) => ({ + idempotency_key: `${input.eventType}:${input.businessType}:${input.businessId}:${recipient.id}`, + event_type: input.eventType, + business_type: input.businessType, + business_id: input.businessId, + recipient_user_id: recipient.id, + recipient_dingtalk_userid: recipient.person?.source_userid || null, + payload_json: JSON.stringify({ + title: input.title, + content: input.content, + action_url: input.actionUrl + }) + })), + skipDuplicates: true + }) + + return notification +} diff --git a/backend/src/services/dingtalkClient.ts b/backend/src/services/dingtalkClient.ts new file mode 100644 index 0000000..0558c43 --- /dev/null +++ b/backend/src/services/dingtalkClient.ts @@ -0,0 +1,214 @@ +type DingTalkResponse = { + errcode?: number + errmsg?: string + [key: string]: any +} + +export class DingTalkApiError extends Error { + code: string + deterministic: boolean + + constructor(message: string, code: string, deterministic = false) { + super(message) + this.name = 'DingTalkApiError' + this.code = code + this.deterministic = deterministic + } +} + +const API_BASE_URL = 'https://oapi.dingtalk.com' +const REQUEST_TIMEOUT_MS = 8_000 + +const requiredConfig = () => ({ + appKey: process.env.DINGTALK_APP_KEY || '', + appSecret: process.env.DINGTALK_APP_SECRET || '', + agentId: process.env.DINGTALK_AGENT_ID || '', + corpId: process.env.DINGTALK_CORP_ID || '', + publicBaseUrl: (process.env.DINGTALK_PUBLIC_BASE_URL || '').replace(/\/$/, '') +}) + +export const getDingTalkConfigStatus = () => { + const config = requiredConfig() + const missing: string[] = [] + if (!config.appKey) missing.push('AppKey') + if (!config.appSecret) missing.push('AppSecret') + if (!config.agentId) missing.push('AgentId') + if (!config.corpId) missing.push('CorpId') + if (!config.publicBaseUrl) missing.push('PublicBaseUrl') + return { + configured: Boolean( + config.appKey && config.appSecret && config.agentId && config.corpId && config.publicBaseUrl + ), + send_enabled: process.env.DINGTALK_SEND_ENABLED === 'true', + missing + } +} + +export const getDingTalkPublicConfig = () => { + const config = requiredConfig() + return { + configured: Boolean( + config.appKey && config.appSecret && config.agentId && config.corpId && config.publicBaseUrl + ), + corpId: config.corpId, + // requestAuthCode JSAPI 要求传入企业内部应用 AppKey;AppKey 是客户端标识,不包含 AppSecret。 + clientId: config.appKey + } +} + +const isDeterministicError = (code: number | string, message: string) => { + const deterministicCodes = new Set([ + '40014', '40056', '40057', '40078', '41001', '41009', '60111', '60121', '70020' + ]) + const normalized = String(code) + return deterministicCodes.has(normalized) || + /不存在|不在可见范围|无权限|invalid user|forbidden user|userid/i.test(message) +} + +const requestJson = async ( + url: string, + init?: RequestInit +): Promise => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers || {}) + } + }) + const payload = await response.json() as T + if (!response.ok) { + throw new DingTalkApiError( + `钉钉接口返回 HTTP ${response.status}`, + `HTTP_${response.status}`, + response.status >= 400 && response.status < 500 && response.status !== 429 + ) + } + if (payload.errcode !== undefined && payload.errcode !== 0) { + throw new DingTalkApiError( + payload.errmsg || '钉钉接口调用失败', + String(payload.errcode), + isDeterministicError(payload.errcode, payload.errmsg || '') + ) + } + return payload + } catch (error: any) { + if (error instanceof DingTalkApiError) throw error + if (error?.name === 'AbortError') { + throw new DingTalkApiError('钉钉请求超时', 'TIMEOUT', false) + } + throw new DingTalkApiError(error?.message || '钉钉网络请求失败', 'NETWORK', false) + } finally { + clearTimeout(timeout) + } +} + +let cachedToken: { value: string; expiresAt: number } | null = null +let tokenPromise: Promise | null = null + +export const getDingTalkAccessToken = async () => { + if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) { + return cachedToken.value + } + if (tokenPromise) return tokenPromise + + tokenPromise = (async () => { + const { appKey, appSecret } = requiredConfig() + if (!appKey || !appSecret) { + throw new DingTalkApiError('钉钉应用配置不完整', 'CONFIG_INCOMPLETE', true) + } + const payload = await requestJson( + `${API_BASE_URL}/gettoken?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}` + ) + if (!payload.access_token) { + throw new DingTalkApiError('钉钉未返回 access_token', 'TOKEN_MISSING', false) + } + const expiresIn = Math.max(300, Number(payload.expires_in) || 7200) + cachedToken = { + value: String(payload.access_token), + expiresAt: Date.now() + expiresIn * 1000 + } + return cachedToken.value + })() + + try { + return await tokenPromise + } finally { + tokenPromise = null + } +} + +export const exchangeDingTalkLoginCode = async (code: string) => { + const token = await getDingTalkAccessToken() + const payload = await requestJson( + `${API_BASE_URL}/topapi/v2/user/getuserinfo?access_token=${encodeURIComponent(token)}`, + { method: 'POST', body: JSON.stringify({ code }) } + ) + const userId = payload.result?.userid + if (!userId) { + throw new DingTalkApiError('未能识别钉钉用户', 'USERID_MISSING', true) + } + return String(userId) +} + +const buildCardUrl = (actionUrl: string) => { + const { corpId, agentId, publicBaseUrl } = requiredConfig() + const entryUrl = `${publicBaseUrl}/dingtalk-entry?redirect=${encodeURIComponent(actionUrl)}` + return `dingtalk://dingtalkclient/action/openapp?corpid=${encodeURIComponent(corpId)}&container_type=work_platform&app_id=0_${encodeURIComponent(agentId)}&redirect_type=jump&redirect_url=${encodeURIComponent(entryUrl)}` +} + +export const sendDingTalkWorkNotification = async (input: { + userId: string + title: string + content: string + actionUrl: string +}) => { + const config = requiredConfig() + if (!config.agentId) { + throw new DingTalkApiError('钉钉 AgentId 未配置', 'CONFIG_INCOMPLETE', true) + } + const token = await getDingTalkAccessToken() + const payload = await requestJson( + `${API_BASE_URL}/topapi/message/corpconversation/asyncsend_v2?access_token=${encodeURIComponent(token)}`, + { + method: 'POST', + body: JSON.stringify({ + agent_id: Number(config.agentId), + userid_list: input.userId, + msg: { + msgtype: 'action_card', + action_card: { + title: input.title, + markdown: `### ${input.title}\n\n${input.content}`, + single_title: '查看详情', + single_url: buildCardUrl(input.actionUrl) + } + } + }) + } + ) + if (payload.task_id === undefined || payload.task_id === null) { + throw new DingTalkApiError('钉钉未返回任务编号', 'TASK_ID_MISSING', false) + } + return String(payload.task_id) +} + +export const getDingTalkSendResult = async (taskId: string) => { + const config = requiredConfig() + const token = await getDingTalkAccessToken() + const payload = await requestJson( + `${API_BASE_URL}/topapi/message/corpconversation/getsendresult?access_token=${encodeURIComponent(token)}`, + { + method: 'POST', + body: JSON.stringify({ + agent_id: Number(config.agentId), + task_id: taskId + }) + } + ) + return payload.send_result || payload.result || {} +} diff --git a/backend/src/services/dingtalkDeliveryService.ts b/backend/src/services/dingtalkDeliveryService.ts new file mode 100644 index 0000000..4ff677e --- /dev/null +++ b/backend/src/services/dingtalkDeliveryService.ts @@ -0,0 +1,269 @@ +import os from 'os' +import type { Prisma } from '@prisma/client' +import prisma from '../utils/prisma' +import { + DingTalkApiError, + getDingTalkSendResult, + sendDingTalkWorkNotification +} from './dingtalkClient' +import { + createBusinessNotification, + findActiveUsersByRoles +} from './businessNotificationService' + +const workerId = `${os.hostname()}:${process.pid}:${Math.random().toString(36).slice(2, 10)}` +const LEASE_MS = 2 * 60 * 1000 +const BACKOFF_MINUTES = [1, 5, 15, 30] + +export const updateSendingDeliveryIfLeaseOwned = async ( + deliveryId: number, + data: Prisma.DingTalkDeliveryUpdateManyMutationInput +) => { + const updated = await prisma.dingTalkDelivery.updateMany({ + where: { + id: deliveryId, + status: 'sending', + lease_owner: workerId + }, + data + }) + return updated.count === 1 +} + +const claimNextDelivery = async () => { + const now = new Date() + const claimable = { + OR: [ + { + status: { in: ['pending', 'retrying'] }, + next_attempt_at: { lte: now } + }, + { + status: 'sending', + lease_expires_at: { lte: now } + } + ] + } + const candidate = await prisma.dingTalkDelivery.findFirst({ + where: claimable, + orderBy: [{ next_attempt_at: 'asc' }, { id: 'asc' }] + }) + if (!candidate) return null + + const claimed = await prisma.dingTalkDelivery.updateMany({ + where: { id: candidate.id, ...claimable }, + data: { + status: 'sending', + lease_owner: workerId, + lease_expires_at: new Date(Date.now() + LEASE_MS), + attempt_count: { increment: 1 } + } + }) + if (claimed.count !== 1) return null + return prisma.dingTalkDelivery.findUnique({ where: { id: candidate.id } }) +} + +const scheduleDeliveryFailure = async ( + delivery: NonNullable>>, + error: any +) => { + const deterministic = error instanceof DingTalkApiError && error.deterministic + const exhausted = delivery.attempt_count >= delivery.max_attempts + const failed = deterministic || exhausted + const delayMinutes = BACKOFF_MINUTES[Math.min(delivery.attempt_count - 1, BACKOFF_MINUTES.length - 1)] + + await updateSendingDeliveryIfLeaseOwned(delivery.id, { + status: failed ? 'failed' : 'retrying', + next_attempt_at: failed + ? delivery.next_attempt_at + : new Date(Date.now() + delayMinutes * 60 * 1000), + lease_owner: null, + lease_expires_at: null, + last_error_code: error instanceof DingTalkApiError ? error.code : 'UNKNOWN', + last_error_message: String(error?.message || '钉钉投递失败').slice(0, 2000) + }) +} + +const processDelivery = async ( + delivery: NonNullable>> +) => { + try { + if (!delivery.recipient_dingtalk_userid) { + throw new DingTalkApiError('收件人未绑定有效的钉钉员工档案', 'RECIPIENT_UNBOUND', true) + } + const payload = JSON.parse(delivery.payload_json) as { + title?: string + content?: string + action_url?: string + } + const taskId = await sendDingTalkWorkNotification({ + userId: delivery.recipient_dingtalk_userid, + title: payload.title || '固定资产通知', + content: payload.content || '', + actionUrl: payload.action_url || '/' + }) + await updateSendingDeliveryIfLeaseOwned(delivery.id, { + status: 'sent', + dingtalk_task_id: taskId, + sent_at: new Date(), + next_attempt_at: new Date(Date.now() + 60_000), + lease_owner: null, + lease_expires_at: null, + last_error_code: null, + last_error_message: null + }) + } catch (error: any) { + await scheduleDeliveryFailure(delivery, error) + } +} + +const refreshOneDeliveryResult = async () => { + const delivery = await prisma.dingTalkDelivery.findFirst({ + where: { + status: { in: ['sent', 'unread'] }, + dingtalk_task_id: { not: null }, + next_attempt_at: { lte: new Date() } + }, + orderBy: { updated_at: 'asc' } + }) + if (!delivery?.dingtalk_task_id || !delivery.recipient_dingtalk_userid) return + + try { + const result = await getDingTalkSendResult(delivery.dingtalk_task_id) + const includesUser = (value: unknown) => + Array.isArray(value) && value.map(String).includes(delivery.recipient_dingtalk_userid as string) + + if (includesUser(result.read_user_id_list)) { + await prisma.dingTalkDelivery.update({ + where: { id: delivery.id }, + data: { status: 'read', read_at: new Date() } + }) + return + } + if ( + includesUser(result.invalid_user_id_list) || includesUser(result.forbidden_user_id_list) || + includesUser(result.failed_user_id_list) + ) { + await prisma.dingTalkDelivery.update({ + where: { id: delivery.id }, + data: { + status: 'failed', + last_error_code: 'SEND_RESULT_FAILED', + last_error_message: '钉钉返回收件人无效、应用不可见或发送失败' + } + }) + return + } + if (includesUser(result.unread_user_id_list)) { + await prisma.dingTalkDelivery.update({ + where: { id: delivery.id }, + data: { status: 'unread', next_attempt_at: new Date(Date.now() + 5 * 60_000) } + }) + return + } + await prisma.dingTalkDelivery.update({ + where: { id: delivery.id }, + data: { next_attempt_at: new Date(Date.now() + 60_000) } + }) + } catch (error: any) { + await prisma.dingTalkDelivery.update({ + where: { id: delivery.id }, + data: { + last_error_code: error instanceof DingTalkApiError ? error.code : 'RESULT_QUERY_FAILED', + last_error_message: String(error?.message || '查询钉钉发送结果失败').slice(0, 2000), + next_attempt_at: new Date(Date.now() + 5 * 60_000) + } + }) + } +} + +export const createOverdueDisposalReminders = async () => { + const overdueAt = new Date(Date.now() - 24 * 60 * 60 * 1000) + const candidates = await prisma.disposalRecord.findMany({ + where: { + status: 'pending', + applied_at: { lte: overdueAt }, + reminded_at: null + }, + orderBy: { applied_at: 'asc' }, + take: 50 + }) + + for (const candidate of candidates) { + await prisma.$transaction(async (tx) => { + let recipients = await findActiveUsersByRoles(tx, ['administrative']) + if (recipients.length === 0) { + recipients = await findActiveUsersByRoles(tx, ['admin']) + } + if (recipients.length === 0) { + return + } + + const claimed = await tx.disposalRecord.updateMany({ + where: { + id: candidate.id, + status: 'pending', + reminded_at: null + }, + data: { reminded_at: new Date() } + }) + if (claimed.count !== 1) return + + await tx.disposalAction.create({ + data: { + disposal_id: candidate.id, + action_type: 'reminded', + from_status: 'pending', + to_status: 'pending', + detail: '提交满24小时仍未审批,系统自动催办一次' + } + }) + await createBusinessNotification(tx, { + eventType: 'disposal_overdue_reminder', + businessType: 'disposal', + businessId: candidate.id, + title: `报废审批催办:${candidate.equipment_code}`, + content: '该报废申请已等待审批超过24小时,请尽快处理', + level: 'warning', + actionUrl: `/workflows/disposals/${candidate.id}`, + recipients + }) + }) + } +} + +let deliveryBusy = false +let reminderBusy = false + +export const startDingTalkDeliveryWorker = () => { + const processTick = async () => { + if (deliveryBusy || process.env.DINGTALK_SEND_ENABLED !== 'true') return + deliveryBusy = true + try { + const delivery = await claimNextDelivery() + if (delivery) await processDelivery(delivery) + await refreshOneDeliveryResult() + } catch (error) { + console.error('[DingTalkWorker] 投递循环失败:', error) + } finally { + deliveryBusy = false + } + } + + const reminderTick = async () => { + if (reminderBusy) return + reminderBusy = true + try { + await createOverdueDisposalReminders() + } catch (error) { + console.error('[DingTalkWorker] 报废催办生成失败:', error) + } finally { + reminderBusy = false + } + } + + setInterval(processTick, 10_000).unref() + setInterval(reminderTick, 5 * 60_000).unref() + setTimeout(processTick, 2_000).unref() + setTimeout(reminderTick, 5_000).unref() +} diff --git a/backend/src/utils/__tests__/assetValue.recursion.test.ts b/backend/src/utils/__tests__/assetValue.recursion.test.ts new file mode 100644 index 0000000..8fa100a --- /dev/null +++ b/backend/src/utils/__tests__/assetValue.recursion.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { refreshParentTotalValueWithChildren, syncMountedChildrenState } from '../assetValue' + +test('syncMountedChildrenState 发现父子环时在写入前中止', async () => { + let updateCount = 0 + const client = { + equipment: { + findMany: async (args: any) => { + const parentId = args.where.parent_id.in[0] + if (parentId === 1) { + return [{ id: 2, unit_price: 100, total_quantity: 1 }] + } + return [{ id: 1, unit_price: 1000, total_quantity: 1 }] + }, + update: async () => { + updateCount += 1 + } + } + } + + await assert.rejects( + syncMountedChildrenState(client as any, 1, '待报废'), + /循环父子关系/ + ) + assert.equal(updateCount, 0) +}) + +test('syncMountedChildrenState 超过 100 层时在写入前中止', async () => { + let findCount = 0 + let updateCount = 0 + const client = { + equipment: { + findMany: async (args: any) => { + findCount += 1 + const parentId = args.where.parent_id.in[0] + return [{ id: parentId + 1, unit_price: 10, total_quantity: 1 }] + }, + update: async () => { + updateCount += 1 + } + } + } + + await assert.rejects( + syncMountedChildrenState(client as any, 1, '维护中'), + /超过 100 层/ + ) + assert.equal(findCount, 101) + assert.equal(updateCount, 0) +}) + +test('挂载零价配件时仍显式更新父节点以刷新 updated_at', async () => { + const updates: any[] = [] + const client = { + equipment: { + findUnique: async () => ({ + id: 1, + unit_price: 1000, + total_value_with_children: 1000, + children: [{ unit_price: 0 }] + }), + update: async (args: any) => { + updates.push(args) + return { id: 1 } + } + } + } + + await refreshParentTotalValueWithChildren(client as any, 1) + + assert.equal(updates.length, 1) + assert.equal(updates[0].where.id, 1) + assert.equal(updates[0].data.total_value_with_children, 1000) +}) diff --git a/backend/src/utils/__tests__/borrowDepartment.test.ts b/backend/src/utils/__tests__/borrowDepartment.test.ts index 73cb44d..f72484d 100644 --- a/backend/src/utils/__tests__/borrowDepartment.test.ts +++ b/backend/src/utils/__tests__/borrowDepartment.test.ts @@ -1,62 +1,32 @@ import assert from 'node:assert/strict' import test from 'node:test' import { - normalizeReturnDepartmentInput, + assertReturnStorageDepartmentContract, resolveBorrowDepartmentUpdate } from '../borrowDepartment' -test('归还时保留当前归属不生成部门更新', () => { - assert.deepEqual( - normalizeReturnDepartmentInput({ - department_action: 'keep' - }), - { - departmentAction: 'keep', - departmentId: null, - equipmentUpdateData: null - } - ) +test('归还入仓允许省略 department_action', () => { + assert.doesNotThrow(() => assertReturnStorageDepartmentContract({})) }) -test('归还时清空归属会生成 department_id = null', () => { - assert.deepEqual( - normalizeReturnDepartmentInput({ - department_action: 'clear' - }), - { - departmentAction: 'clear', - departmentId: null, - equipmentUpdateData: { - department_id: null - } - } - ) +test('归还入仓允许显式 clear', () => { + assert.doesNotThrow(() => assertReturnStorageDepartmentContract({ + department_action: 'clear' + })) }) -test('归还时指定部门会生成 department_id 更新', () => { - assert.deepEqual( - normalizeReturnDepartmentInput({ - department_action: 'set', - department_id: 18 - }), - { - departmentAction: 'set', - departmentId: 18, - equipmentUpdateData: { - department_id: 18 - } - } - ) -}) - -test('归还时指定部门但未传 department_id 会抛错', () => { - assert.throws( - () => - normalizeReturnDepartmentInput({ - department_action: 'set' - }), - /请选择要改成的业务部门/ - ) +test('归还入仓拒绝 keep、set 和任何 department_id', () => { + for (const input of [ + { department_action: 'keep' }, + { department_action: 'set', department_id: 18 }, + { department_id: 18 }, + { department_action: 'clear', department_id: null } + ]) { + assert.throws( + () => assertReturnStorageDepartmentContract(input), + /归还入仓统一清空部门/ + ) + } }) test('出库时使用者有主部门就同步资产归属', () => { diff --git a/backend/src/utils/__tests__/permissions.test.ts b/backend/src/utils/__tests__/permissions.test.ts new file mode 100644 index 0000000..40d7f3f --- /dev/null +++ b/backend/src/utils/__tests__/permissions.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { getPermissionsForRole, PERMISSIONS } from '../permissions' + +test('管理员拥有完整权限集合', () => { + const permissions = getPermissionsForRole('admin') + assert.ok(permissions.includes(PERMISSIONS.USERS_MANAGE)) + assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY)) + assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) + assert.ok(permissions.includes(PERMISSIONS.ASSETS_VIEW_OWN)) +}) +test('行政可以登记和审批但不能借还、调拨或挂载', () => { + const permissions = getPermissionsForRole('administrative') + assert.ok(permissions.includes(PERMISSIONS.ASSETS_CREATE)) + assert.ok(permissions.includes(PERMISSIONS.ASSETS_CORRECT)) + assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) + assert.ok(!permissions.includes(PERMISSIONS.ASSETS_BORROW)) + assert.ok(!permissions.includes(PERMISSIONS.ASSETS_TRANSFER)) + assert.ok(!permissions.includes(PERMISSIONS.ASSETS_MOUNT)) +}) + +test('IT可以流转和发起报废但不能审批或管理用户', () => { + const permissions = getPermissionsForRole('it') + assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW)) + assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER)) + assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY)) + assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) + assert.ok(!permissions.includes(PERMISSIONS.USERS_MANAGE)) +}) + +test('普通用户只有本人资产读取权限,未知角色没有权限', () => { + assert.deepEqual(getPermissionsForRole('user'), [PERMISSIONS.ASSETS_VIEW_OWN]) + assert.deepEqual(getPermissionsForRole('unknown'), []) +}) diff --git a/backend/src/utils/assetValue.ts b/backend/src/utils/assetValue.ts index 5b18595..29f3a0a 100644 --- a/backend/src/utils/assetValue.ts +++ b/backend/src/utils/assetValue.ts @@ -114,18 +114,43 @@ export const refreshParentTotalValueWithChildren = async (client: EquipmentClien export const syncMountedChildrenState = async ( client: EquipmentClient, parentId: number, - targetStatus: '可用' | '已借出' | '维护中' | '待报废' | '已报废' + targetStatus: '可用' | '已借出' | '维护中' | '待报废' | '已报废', + options: { departmentId?: number | null } = {} ) => { - const children = await client.equipment.findMany({ - where: { parent_id: parentId }, - select: { - id: true, - unit_price: true, - total_quantity: true - } - }) + const descendants: Array<{ + id: number + unit_price: unknown + total_quantity: number + }> = [] + const visited = new Set([parentId]) + let parentIds = [parentId] + let depth = 0 + while (parentIds.length > 0) { + const children = await client.equipment.findMany({ + where: { parent_id: { in: parentIds } }, + select: { + id: true, + unit_price: true, + total_quantity: true + } + }) + if (children.length === 0) break - for (const child of children) { + if (depth >= 100) { + throw new Error('资产父子关系超过 100 层,已停止状态同步,请先修复关系') + } + const repeated = children.find((child) => visited.has(child.id)) + if (repeated) { + throw new Error(`资产 ID ${repeated.id} 存在循环父子关系,已停止状态同步`) + } + + children.forEach((child) => visited.add(child.id)) + descendants.push(...children) + parentIds = children.map((child) => child.id) + depth += 1 + } + + for (const child of descendants) { const remainingQuantity = targetStatus === '已借出' ? 0 : Number(child.total_quantity) const values = calculateEquipmentValues({ unitPrice: Number(child.unit_price), @@ -141,7 +166,10 @@ export const syncMountedChildrenState = async ( remaining_quantity: remainingQuantity, total_value: values.total_value, borrowed_value: values.borrowed_value, - available_value: values.available_value + available_value: values.available_value, + ...(Object.prototype.hasOwnProperty.call(options, 'departmentId') + ? { department_id: options.departmentId ?? null } + : {}) } }) } diff --git a/backend/src/utils/authIdentity.ts b/backend/src/utils/authIdentity.ts index 2977985..a86efe3 100644 --- a/backend/src/utils/authIdentity.ts +++ b/backend/src/utils/authIdentity.ts @@ -1,5 +1,5 @@ -import bcrypt from 'bcryptjs' import prisma from './prisma' +import { getPermissionsForRole, isUserRole, type Permission } from './permissions' type AuthIdentityInput = { username?: string @@ -8,90 +8,108 @@ type AuthIdentityInput = { roles?: string[] } -type AuthIdentityUser = { +export type AuthIdentityUser = { id: number username: string name: string email?: string | null + phone?: string | null role: string roles: string[] status: string + permissions: Permission[] + person_id: number | null + person_bound: boolean + person: { + id: number + real_name: string + nickname: string + status: string + source_employee_status: string | null + } | null } -const SHADOW_PASSWORD_ROUNDS = 10 +export class AuthIdentityError extends Error { + status: number -const buildShadowPassword = async (username: string) => { - const seed = `keycloak-shadow:${username}:${Date.now()}:${Math.random()}` - return bcrypt.hash(seed, SHADOW_PASSWORD_ROUNDS) + constructor(message: string, status: number) { + super(message) + this.name = 'AuthIdentityError' + this.status = status + } } +const userInclude = { + person: { + select: { + id: true, + real_name: true, + nickname: true, + status: true, + source_employee_status: true + } + } +} as const + +export const toAuthIdentityUser = (user: any): AuthIdentityUser => ({ + id: user.id, + username: user.username, + name: user.name, + email: user.email, + phone: user.phone, + role: user.role, + roles: [user.role], + status: user.status, + permissions: getPermissionsForRole(user.role), + person_id: user.person_id, + person_bound: Boolean(user.person_id), + person: user.person || null +}) + export const resolveOrProvisionAuthUser = async ( identity: AuthIdentityInput ): Promise => { const username = identity.username?.trim() if (!username) { - throw new Error('认证信息缺少用户名') + throw new AuthIdentityError('认证信息缺少用户名', 401) } const existing = await prisma.user.findUnique({ - where: { username } + where: { username }, + include: userInclude }) - if (existing) { - return { - id: existing.id, - username: existing.username, - name: existing.name, - email: existing.email, - role: existing.role, - roles: identity.roles || [existing.role], - status: existing.status - } + if (!existing) { + throw new AuthIdentityError('该身份尚未绑定系统用户,请联系管理员', 403) } - try { - const created = await prisma.user.create({ - data: { - username, - password: await buildShadowPassword(username), - name: identity.name?.trim() || username, - email: identity.email?.trim() || null, - role: identity.roles?.includes('admin') ? 'admin' : 'user', - status: 'active' - } - }) - - return { - id: created.id, - username: created.username, - name: created.name, - email: created.email, - role: created.role, - roles: identity.roles || [created.role], - status: created.status - } - } catch (error: any) { - if (error?.code !== 'P2002') { - throw error - } - - const conflicted = await prisma.user.findUnique({ - where: { username } - }) - - if (!conflicted) { - throw error - } - - return { - id: conflicted.id, - username: conflicted.username, - name: conflicted.name, - email: conflicted.email, - role: conflicted.role, - roles: identity.roles || [conflicted.role], - status: conflicted.status - } + if (existing.status !== 'active') { + throw new AuthIdentityError('账号已停用', 403) } + + if (!isUserRole(existing.role)) { + throw new AuthIdentityError('账号角色无效,请联系管理员', 403) + } + + return toAuthIdentityUser(existing) +} + +export const resolveAuthUserById = async (userId: number): Promise => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: userInclude + }) + + if (!user) { + throw new AuthIdentityError('用户不存在', 401) + } + if (user.status !== 'active') { + throw new AuthIdentityError('账号已停用', 401) + } + if (!isUserRole(user.role)) { + throw new AuthIdentityError('账号角色无效,请联系管理员', 403) + } + + return toAuthIdentityUser(user) } diff --git a/backend/src/utils/borrowDepartment.ts b/backend/src/utils/borrowDepartment.ts index 52dfeb9..60914e1 100644 --- a/backend/src/utils/borrowDepartment.ts +++ b/backend/src/utils/borrowDepartment.ts @@ -1,18 +1,8 @@ -export type ReturnDepartmentAction = 'keep' | 'clear' | 'set' - -export interface NormalizeReturnDepartmentInputParams { +export interface ReturnStorageDepartmentInput { department_action?: unknown department_id?: unknown } -export interface NormalizedReturnDepartmentInput { - departmentAction: ReturnDepartmentAction - departmentId: number | null - equipmentUpdateData: { - department_id: number | null - } | null -} - const toOptionalPositiveInteger = (value: unknown) => { if (value === undefined || value === null || value === '') { return null @@ -26,48 +16,16 @@ const toOptionalPositiveInteger = (value: unknown) => { return numericValue } -export const normalizeReturnDepartmentInput = ( - input: NormalizeReturnDepartmentInputParams -): NormalizedReturnDepartmentInput => { - const rawAction = typeof input.department_action === 'string' - ? input.department_action.trim() - : '' +export const assertReturnStorageDepartmentContract = ( + input: ReturnStorageDepartmentInput +) => { + const hasDepartmentAction = Object.prototype.hasOwnProperty.call(input, 'department_action') + const hasDepartmentId = Object.prototype.hasOwnProperty.call(input, 'department_id') + if (!hasDepartmentAction && !hasDepartmentId) return - const departmentAction: ReturnDepartmentAction = - rawAction === 'clear' || rawAction === 'set' - ? rawAction - : 'keep' + if (input.department_action === 'clear' && !hasDepartmentId) return - if (departmentAction === 'keep') { - return { - departmentAction, - departmentId: null, - equipmentUpdateData: null - } - } - - if (departmentAction === 'clear') { - return { - departmentAction, - departmentId: null, - equipmentUpdateData: { - department_id: null - } - } - } - - const departmentId = toOptionalPositiveInteger(input.department_id) - if (!departmentId) { - throw new Error('请选择要改成的业务部门') - } - - return { - departmentAction, - departmentId, - equipmentUpdateData: { - department_id: departmentId - } - } + throw new Error('归还入仓统一清空部门,不支持保留或指定部门') } export const resolveBorrowDepartmentUpdate = (mainDepartmentId?: number | null) => { diff --git a/backend/src/utils/dingtalkRedirect.ts b/backend/src/utils/dingtalkRedirect.ts new file mode 100644 index 0000000..efdd971 --- /dev/null +++ b/backend/src/utils/dingtalkRedirect.ts @@ -0,0 +1,28 @@ +const allowedRedirectPrefixes = [ + '/workflows/disposals', + '/maintenance', + '/my-assets', + '/equipment', + '/assets', + '/notifications', + '/dashboard' +] + +export const normalizeInternalRedirect = (value: unknown) => { + const raw = typeof value === 'string' && value.trim() ? value.trim() : '/' + if (raw === '/') return raw + if (!raw.startsWith('/') || raw.startsWith('//') || /[\\\r\n]/.test(raw)) { + throw new Error('跳转地址不在允许范围内') + } + const url = new URL(raw, 'https://equipment.internal') + if (url.origin !== 'https://equipment.internal') { + throw new Error('跳转地址不在允许范围内') + } + const allowed = allowedRedirectPrefixes.some((prefix) => + url.pathname === prefix || url.pathname.startsWith(`${prefix}/`) + ) + if (!allowed) { + throw new Error('跳转地址不在允许范围内') + } + return `${url.pathname}${url.search}${url.hash}` +} diff --git a/backend/src/utils/equipmentDeletion.ts b/backend/src/utils/equipmentDeletion.ts index 1364bfb..fe9ccc0 100644 --- a/backend/src/utils/equipmentDeletion.ts +++ b/backend/src/utils/equipmentDeletion.ts @@ -6,6 +6,9 @@ export interface EquipmentDeleteCheckInput { disposalRecordCount: number maintenanceRecordCount: number inventoryTaskItemCount: number + disposalItemCount?: number + transferHistoryCount?: number + operationLogCount?: number } export const getEquipmentDeleteBlockedReason = ({ @@ -15,7 +18,10 @@ export const getEquipmentDeleteBlockedReason = ({ mountHistoryCount, disposalRecordCount, maintenanceRecordCount, - inventoryTaskItemCount + inventoryTaskItemCount, + disposalItemCount = 0, + transferHistoryCount = 0, + operationLogCount = 0 }: EquipmentDeleteCheckInput) => { if (borrowRecordCount > 0) { return '该资产已存在借用记录,无法删除' @@ -29,7 +35,10 @@ export const getEquipmentDeleteBlockedReason = ({ return '该资产存在已挂载的子资产,无法删除' } - if (mountHistoryCount > 0 || disposalRecordCount > 0 || maintenanceRecordCount > 0) { + if ( + mountHistoryCount > 0 || disposalRecordCount > 0 || disposalItemCount > 0 || + maintenanceRecordCount > 0 || transferHistoryCount > 0 || operationLogCount > 0 + ) { return '该资产已存在维修、报废或挂载历史,无法删除' } diff --git a/backend/src/utils/permissions.ts b/backend/src/utils/permissions.ts new file mode 100644 index 0000000..7c72705 --- /dev/null +++ b/backend/src/utils/permissions.ts @@ -0,0 +1,68 @@ +export const USER_ROLES = ['admin', 'administrative', 'it', 'user'] as const + +export type UserRole = (typeof USER_ROLES)[number] + +export const PERMISSIONS = { + ASSETS_VIEW_ALL: 'assets.view_all', + ASSETS_VIEW_OWN: 'assets.view_own', + ASSETS_CREATE: 'assets.create', + ASSETS_IMPORT: 'assets.import', + ASSETS_CORRECT: 'assets.correct', + ASSETS_DELETE: 'assets.delete', + ASSETS_BORROW: 'assets.borrow', + ASSETS_TRANSFER: 'assets.transfer', + ASSETS_MAINTENANCE: 'assets.maintenance', + ASSETS_MOUNT: 'assets.mount', + DISPOSALS_APPLY: 'disposals.apply', + DISPOSALS_APPROVE: 'disposals.approve', + DISPOSALS_COMPLETE: 'disposals.complete', + INVENTORY_MANAGE: 'inventory.manage', + NOTIFICATIONS_VIEW: 'notifications.view', + USERS_MANAGE: 'users.manage', + SYNC_MANAGE: 'sync.manage', + SYSTEM_SETTINGS: 'system.settings', + REPORTS_VIEW: 'reports.view' +} as const + +export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS] + +const ALL_PERMISSIONS = Object.values(PERMISSIONS) as Permission[] + +const ROLE_PERMISSIONS: Record = { + admin: ALL_PERMISSIONS, + administrative: [ + PERMISSIONS.ASSETS_VIEW_ALL, + PERMISSIONS.ASSETS_CREATE, + PERMISSIONS.ASSETS_IMPORT, + PERMISSIONS.ASSETS_CORRECT, + PERMISSIONS.DISPOSALS_APPROVE, + PERMISSIONS.DISPOSALS_COMPLETE, + PERMISSIONS.INVENTORY_MANAGE, + PERMISSIONS.NOTIFICATIONS_VIEW, + PERMISSIONS.REPORTS_VIEW + ], + it: [ + PERMISSIONS.ASSETS_VIEW_ALL, + PERMISSIONS.ASSETS_BORROW, + PERMISSIONS.ASSETS_TRANSFER, + PERMISSIONS.ASSETS_MAINTENANCE, + PERMISSIONS.ASSETS_MOUNT, + PERMISSIONS.DISPOSALS_APPLY, + PERMISSIONS.NOTIFICATIONS_VIEW, + PERMISSIONS.REPORTS_VIEW + ], + user: [PERMISSIONS.ASSETS_VIEW_OWN] +} + +export const isUserRole = (value: unknown): value is UserRole => + typeof value === 'string' && USER_ROLES.includes(value as UserRole) + +export const getPermissionsForRole = (role: string): Permission[] => { + if (!isUserRole(role)) { + return [] + } + return [...ROLE_PERMISSIONS[role]] +} + +export const roleHasPermission = (role: string, permission: Permission) => + getPermissionsForRole(role).includes(permission) diff --git a/deploy.sh b/deploy.sh index 8988d0c..a3d3d62 100644 --- a/deploy.sh +++ b/deploy.sh @@ -7,51 +7,20 @@ PROJECT_DIR="/opt/equipment-management" COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml" PROJECT_NAME="equipment-management" BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh" +DISPOSAL_PRECHECK_SQL="$PROJECT_DIR/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql" +MYSQL_CONTAINER="equipment-mysql" SKIP_BACKUP="${SKIP_BACKUP:-0}" +CONFIRM_ACTIVE_DISPOSALS_REVIEWED="${CONFIRM_ACTIVE_DISPOSALS_REVIEWED:-}" HEALTHCHECK_ATTEMPTS="${HEALTHCHECK_ATTEMPTS:-12}" HEALTHCHECK_INTERVAL_SECONDS="${HEALTHCHECK_INTERVAL_SECONDS:-5}" HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}" EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql") -LEGACY_CONTAINERS=("shebeiguanli-frontend" "shebeiguanli-backend" "shebeiguanli-mysql") -LEGACY_STATUS_BEFORE=() get_container_status() { local container="$1" docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || printf 'missing\n' } -record_legacy_container_statuses() { - local index container status - - echo ">>> 记录摄影设备借还系统容器状态(仅检查,不操作)..." - for index in "${!LEGACY_CONTAINERS[@]}"; do - container="${LEGACY_CONTAINERS[$index]}" - status="$(get_container_status "$container")" - LEGACY_STATUS_BEFORE[$index]="$status" - echo " $container => $status" - done -} - -verify_legacy_container_statuses() { - local index container before_status after_status - local failed=0 - - echo ">>> 确认摄影设备借还系统未受影响(仅检查,不操作)..." - for index in "${!LEGACY_CONTAINERS[@]}"; do - container="${LEGACY_CONTAINERS[$index]}" - before_status="${LEGACY_STATUS_BEFORE[$index]}" - after_status="$(get_container_status "$container")" - echo " $container => 部署前: $before_status,部署后: $after_status" - - if [ "$before_status" != "missing" ] && [ "$after_status" != "$before_status" ]; then - echo " 错误:$container 状态发生变化。部署脚本未对该容器执行任何操作,请立即排查。" >&2 - failed=1 - fi - done - - return "$failed" -} - verify_equipment_containers() { local container status local failed=0 @@ -89,6 +58,124 @@ wait_for_backend_health() { return 1 } +run_equipment_mysql() { + docker exec -i "$MYSQL_CONTAINER" sh -eu -c ' + if [ -z "${MYSQL_ROOT_PASSWORD:-}" ]; then + echo "equipment-mysql 缺少 MYSQL_ROOT_PASSWORD,无法安全执行迁移前核查。" >&2 + exit 1 + fi + if [ -z "${MYSQL_DATABASE:-}" ]; then + echo "equipment-mysql 缺少 MYSQL_DATABASE,无法确认固定资产数据库。" >&2 + exit 1 + fi + + defaults_file="$(mktemp /tmp/equipment-mysql-client.XXXXXX)" + trap '\''rm -f "$defaults_file"'\'' EXIT HUP INT TERM + chmod 600 "$defaults_file" + printf "[client]\nuser=root\npassword=%s\n" "$MYSQL_ROOT_PASSWORD" > "$defaults_file" + mysql --defaults-extra-file="$defaults_file" --database="$MYSQL_DATABASE" "$@" + ' sh "$@" +} + +run_disposal_migration_precheck() { + local mysql_status tree_anomaly_count active_rows active_count active_fingerprint + + echo ">>> 执行报废流程迁移前只读核查..." + if [ ! -r "$DISPOSAL_PRECHECK_SQL" ]; then + echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2 + return 1 + fi + + mysql_status="$(get_container_status "$MYSQL_CONTAINER")" + if [ "$mysql_status" != "running" ]; then + echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2 + return 1 + fi + + if ! run_equipment_mysql < "$DISPOSAL_PRECHECK_SQL"; then + echo "报废流程迁移前核查执行失败,已阻止部署。" >&2 + return 1 + fi + + if ! tree_anomaly_count="$(run_equipment_mysql --batch --skip-column-names --raw <<'SQL' +WITH RECURSIVE active_disposal_tree AS ( + SELECT + disposal.id AS disposal_id, + equipment.id AS equipment_id, + equipment.parent_id, + 0 AS depth, + CAST(CONCAT('/', equipment.id, '/') AS CHAR(4096)) AS visited_path, + 0 AS cycle_detected + FROM disposal_record disposal + INNER JOIN equipment equipment ON equipment.id = disposal.equipment_id + WHERE disposal.status IN ('pending', 'approved') + + UNION ALL + + SELECT + tree.disposal_id, + child.id, + child.parent_id, + tree.depth + 1, + CONCAT(tree.visited_path, child.id, '/'), + LOCATE(CONCAT('/', child.id, '/'), tree.visited_path) > 0 + FROM active_disposal_tree tree + INNER JOIN equipment child ON child.parent_id = tree.equipment_id + WHERE tree.depth < 100 + AND tree.cycle_detected = 0 +) +SELECT COUNT(*) +FROM active_disposal_tree +WHERE cycle_detected = 1 OR depth >= 100; +SQL + )"; then + echo "无法确认未完成报废资产树是否存在环或超深层级,已阻止部署。" >&2 + return 1 + fi + tree_anomaly_count="$(printf '%s' "$tree_anomaly_count" | tr -d '[:space:]')" + if ! [[ "$tree_anomaly_count" =~ ^[0-9]+$ ]]; then + echo "资产树异常数量不可解析,已阻止部署。" >&2 + return 1 + fi + if [ "$tree_anomaly_count" -gt 0 ]; then + echo "检测到 $tree_anomaly_count 个父子环或深度达到 100 的未完成报废资产节点。" >&2 + echo "此类数据异常不允许通过确认指纹绕过,请先修复数据。" >&2 + return 1 + fi + + if ! active_rows="$( + printf '%s\n' \ + "SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;" | + run_equipment_mysql --batch --skip-column-names --raw + )"; then + echo "无法读取未完成报废单指纹数据,已阻止部署。" >&2 + return 1 + fi + active_count="$(printf '%s\n' "$active_rows" | awk 'NF { count += 1 } END { print count + 0 }')" + + if [ "$active_count" -gt 0 ]; then + if ! command -v sha256sum >/dev/null 2>&1; then + echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2 + return 1 + fi + active_fingerprint="$(printf '%s\n' "$active_rows" | sha256sum | awk '{ print $1 }')" + if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then + echo "未完成报废单指纹不可解析,已阻止部署。" >&2 + return 1 + fi + echo "检测到 $active_count 条未完成报废单。" >&2 + echo "本次未完成报废单指纹: $active_fingerprint" >&2 + if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]; then + echo "已默认阻止部署。请业务负责人逐单核对上方资产树。" >&2 + echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$active_fingerprint。" >&2 + return 1 + fi + echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。" + else + echo "未发现未完成报废单,迁移前核查通过。" + fi +} + if docker compose version >/dev/null 2>&1; then COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE") elif command -v docker-compose >/dev/null 2>&1; then @@ -124,8 +211,6 @@ fi cd "$PROJECT_DIR" -record_legacy_container_statuses - if [ "$SKIP_BACKUP" = "1" ]; then echo ">>> 已设置 SKIP_BACKUP=1,跳过备份" else @@ -138,6 +223,8 @@ else "$BACKUP_SCRIPT" fi +run_disposal_migration_precheck + echo ">>> 先构建固定资产系统镜像..." "${COMPOSE_CMD[@]}" build @@ -154,10 +241,6 @@ if ! verify_equipment_containers; then deployment_failed=1 fi -if ! verify_legacy_container_statuses; then - deployment_failed=1 -fi - if [ "$deployment_failed" -ne 0 ]; then echo "固定资产系统部署校验失败,请根据上述错误排查。" >&2 exit 1 @@ -169,6 +252,4 @@ echo " 部署完成" SERVER_HOST="${SERVER_HOST:-}" echo " 固定资产前端: http://$SERVER_HOST" echo " 固定资产后端: http://$SERVER_HOST:3000/api" -echo " 摄影设备前端: http://$SERVER_HOST:8081" -echo " 摄影设备后端: http://$SERVER_HOST:3001" echo "==========================================" diff --git a/docker-compose.yml b/docker-compose.yml index bdf4940..fe9e0e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,12 @@ services: HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5} HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流} HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-} + DINGTALK_APP_KEY: ${DINGTALK_APP_KEY:-} + DINGTALK_APP_SECRET: ${DINGTALK_APP_SECRET:-} + DINGTALK_AGENT_ID: ${DINGTALK_AGENT_ID:-} + DINGTALK_CORP_ID: ${DINGTALK_CORP_ID:-} + DINGTALK_PUBLIC_BASE_URL: ${DINGTALK_PUBLIC_BASE_URL:-} + DINGTALK_SEND_ENABLED: ${DINGTALK_SEND_ENABLED:-false} DOWNLOADS_DIR: /app/downloads depends_on: mysql: diff --git a/frontend/src/api/batch.ts b/frontend/src/api/batch.ts index 6f635a7..8842f2e 100644 --- a/frontend/src/api/batch.ts +++ b/frontend/src/api/batch.ts @@ -35,7 +35,9 @@ export const batchReturn = (data: { // 批量转移部门 export const batchTransfer = (data: { equipment_ids: number[] - department_id: number + target_type: 'department' | 'idle' + department_id?: number + reason: string }) => { 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 268fb1d..772acf5 100644 --- a/frontend/src/api/borrow.ts +++ b/frontend/src/api/borrow.ts @@ -7,13 +7,6 @@ import type { StorageLocation } from '@/types' -export type BorrowDepartmentAction = 'keep' | 'clear' | 'set' - -export interface ReturnDepartmentPayload { - department_action?: BorrowDepartmentAction - department_id?: number | null -} - export interface ScanBorrowResult { id: number equipment_code: string @@ -99,7 +92,7 @@ export const returnEquipment = async (data: { return_condition?: string notes?: string confirm_children_diff?: boolean -} & ReturnDepartmentPayload) => { +}) => { const response = await api.post>('/return', data) return response.data } @@ -112,7 +105,7 @@ export const scanReturn = async (data: { person_id?: number borrow_record_id?: number confirm_children_diff?: boolean -} & ReturnDepartmentPayload) => { +}) => { const response = await api.post< ApiResponse<{ id: number diff --git a/frontend/src/api/dingtalk.ts b/frontend/src/api/dingtalk.ts new file mode 100644 index 0000000..39e5047 --- /dev/null +++ b/frontend/src/api/dingtalk.ts @@ -0,0 +1,57 @@ +import api from './index' +import type { ApiResponse, User } from '@/types' + +export interface DingTalkPublicConfig { + configured: boolean + clientId?: string + client_id?: string + corpId?: string + corp_id?: string + agentId?: string + agent_id?: string +} + +export interface DingTalkLoginResponse { + user: User + token?: string + redirectPath?: string + redirect_path?: string +} + +export interface DingTalkDeliveryStatus { + configured: boolean + send_enabled?: boolean + enabled?: boolean + missing?: string[] + pending: number + failed: number + sent?: number + read?: number + unread?: number + error_summary?: string[] +} + +export const getDingTalkPublicConfig = async () => { + const response = await api.get>('/dingtalk/public-config') + return response.data +} + +export const loginWithDingTalk = async (data: { code: string; redirectPath: string }) => { + const response = await api.post>('/dingtalk/h5-login', data) + return response.data +} + +export const getDingTalkDeliveryStatus = async () => { + const response = await api.get>('/dingtalk/deliveries/status') + return response.data +} + +export const getDingTalkDeliveries = async (params: { page?: number; pageSize?: number; status?: string } = {}) => { + const response = await api.get>('/dingtalk/deliveries', { params }) + return response.data +} + +export const retryDingTalkDelivery = async (id: number) => { + const response = await api.post>(`/dingtalk/deliveries/${id}/retry`) + return response.data +} diff --git a/frontend/src/api/disposal.ts b/frontend/src/api/disposal.ts index 22123b8..ac26e9d 100644 --- a/frontend/src/api/disposal.ts +++ b/frontend/src/api/disposal.ts @@ -15,6 +15,10 @@ export interface DisposalRecord { approved_by?: number approved_at?: string completed_at?: string + completed_by?: number + reject_reason?: string + completion_notes?: string + pre_status?: string notes?: string equipment?: { equipment_name: string @@ -23,10 +27,48 @@ export interface DisposalRecord { } applier?: { id: number; name: string; username: string } approver?: { id: number; name: string; username: string } + completer?: { id: number; name: string; username: string } + items?: DisposalItem[] + actions?: DisposalAction[] +} + +export interface DisposalItem { + id: number + equipment_id: number + equipment_code: string + equipment_name?: string + equipment_type?: string + parent_equipment_id?: number | null + parent_id?: number | null + pre_status?: string + status_before?: string + is_root?: boolean + equipment?: { + id?: number + equipment_code?: string + equipment_name?: string + equipment_type?: string + } +} + +export interface DisposalAction { + id: number + action_type?: string + action?: string + from_status?: string | null + to_status?: string | null + reason?: string | null + notes?: string | null + detail?: string | null + operator_id?: number + created_at: string + actor?: { id: number; name: string; username: string } + operator?: { id: number; name: string; username: string } } export interface CreateDisposalParams { equipment_id: number + item_ids?: number[] disposal_reason: string disposal_method: string estimated_value: number @@ -39,7 +81,7 @@ export const createDisposal = (data: CreateDisposalParams) => { } // 获取报废记录列表 -export const getDisposalList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number }) => { +export const getDisposalList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number; scope?: 'pending' | 'mine' | 'processed' | 'all' }) => { return request .get>>('/disposal/list', { params }) .then((response) => response.data) @@ -47,7 +89,9 @@ export const getDisposalList = (params: { page?: number; pageSize?: number; stat // 获取报废记录详情 export const getDisposalDetail = (id: number) => { - return request.get(`/disposal/${id}`) + return request + .get>(`/disposal/${id}`) + .then((response) => response.data) } // 审批报废申请 @@ -56,7 +100,7 @@ export const approveDisposal = (id: number, data: { approved: boolean; reject_re } // 完成报废处置 -export const completeDisposal = (id: number, data: { actual_value?: number; notes?: string }) => { +export const completeDisposal = (id: number, data: { actual_value?: number; completion_notes?: string }) => { return request.put(`/disposal/${id}/complete`, data) } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 41a90bb..76263e1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,7 +1,7 @@ import axios from 'axios' import type { AxiosInstance, AxiosResponse } from 'axios' import type { ApiResponse } from '@/types' -import { resolveApiBaseUrl, resolveAuthMode } from '@/utils/authMode' +import { resolveApiBaseUrl } from '@/utils/authMode' // 创建axios实例 const api: AxiosInstance = axios.create({ @@ -29,8 +29,6 @@ api.interceptors.request.use( } ) -const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) - const getResponseErrorMessage = (error: any) => { const responseData = error?.response?.data if (responseData && typeof responseData === 'object') { @@ -92,11 +90,11 @@ api.interceptors.response.use( localStorage.removeItem('token') { const isAuthStatusCheck = requestUrl.includes('/auth/user') - const isLoginAttempt = requestUrl.includes('/auth/local/login') - const shouldRedirect = - authMode !== 'local' || (!isAuthStatusCheck && !isLoginAttempt) + const isLoginAttempt = + requestUrl.includes('/auth/local/login') || requestUrl.includes('/dingtalk/h5-login') + const shouldRedirect = !isAuthStatusCheck && !isLoginAttempt if (shouldRedirect && window.location.pathname !== '/login') { - window.location.href = '/login' + window.location.href = '/login' } } break diff --git a/frontend/src/api/mount.ts b/frontend/src/api/mount.ts index d1184e6..53d0630 100644 --- a/frontend/src/api/mount.ts +++ b/frontend/src/api/mount.ts @@ -1,6 +1,5 @@ import api from './index' import type { ApiResponse } from '@/types' -import type { EquipmentStatus } from '@/types' export interface MountChildRequest { child_id: number @@ -10,14 +9,12 @@ export interface MountChildRequest { export interface UnmountChildRequest { child_id: number notes?: string - released_child_status?: Extract } export interface ReplaceChildRequest { old_child_id: number new_child_id: number notes?: string - released_child_status?: Extract } export interface ChildEquipment { diff --git a/frontend/src/api/myAssets.ts b/frontend/src/api/myAssets.ts new file mode 100644 index 0000000..72fc1eb --- /dev/null +++ b/frontend/src/api/myAssets.ts @@ -0,0 +1,21 @@ +import api from './index' +import type { ApiResponse, BoundPersonSummary, Equipment, EquipmentTreeNode } from '@/types' + +export interface MyAssetRecord { + borrow_record_id: number + borrow_time: string + expected_return_time?: string | null + is_long_term?: boolean + equipment: Equipment + children?: EquipmentTreeNode[] +} + +export interface MyAssetsResponse { + person: BoundPersonSummary + assets: MyAssetRecord[] +} + +export const getMyAssets = async () => { + const response = await api.get>('/my-assets') + return response.data +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 6f5ed13..7f2b5ac 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -7,6 +7,7 @@ export interface UserListParams { keyword?: string role?: string status?: string + person_id?: number | null } export const getUserList = async (params: UserListParams = {}) => { @@ -23,6 +24,7 @@ export const createUser = async (data: { roles?: string[] role?: string status?: string + person_id?: number | null }) => { const response = await api.post>('/users', data) return response.data @@ -37,6 +39,7 @@ export const updateUser = async ( roles?: string[] role?: string status?: string + person_id?: number | null } ) => { const response = await api.put>(`/users/${id}`, data) diff --git a/frontend/src/auth/permissions.ts b/frontend/src/auth/permissions.ts new file mode 100644 index 0000000..ee5eca1 --- /dev/null +++ b/frontend/src/auth/permissions.ts @@ -0,0 +1,34 @@ +import type { UserRole } from '@/types' + +export const PERMISSIONS = { + ASSETS_VIEW_ALL: 'assets.view_all', + ASSETS_VIEW_OWN: 'assets.view_own', + ASSETS_CREATE: 'assets.create', + ASSETS_IMPORT: 'assets.import', + ASSETS_CORRECT: 'assets.correct', + ASSETS_DELETE: 'assets.delete', + ASSETS_BORROW: 'assets.borrow', + ASSETS_TRANSFER: 'assets.transfer', + ASSETS_MAINTENANCE: 'assets.maintenance', + ASSETS_MOUNT: 'assets.mount', + DISPOSALS_APPLY: 'disposals.apply', + DISPOSALS_APPROVE: 'disposals.approve', + DISPOSALS_COMPLETE: 'disposals.complete', + INVENTORY_MANAGE: 'inventory.manage', + NOTIFICATIONS_VIEW: 'notifications.view', + USERS_MANAGE: 'users.manage', + SYNC_MANAGE: 'sync.manage', + SYSTEM_SETTINGS: 'system.settings', + REPORTS_VIEW: 'reports.view' +} as const + +export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS] + +export const ROLE_LABELS: Record = { + admin: '管理员', + administrative: '行政', + it: 'IT', + user: '普通用户' +} + +export const getRoleLabel = (role?: string | null) => ROLE_LABELS[role as UserRole] || '普通用户' diff --git a/frontend/src/auth/user.ts b/frontend/src/auth/user.ts new file mode 100644 index 0000000..7635f3e --- /dev/null +++ b/frontend/src/auth/user.ts @@ -0,0 +1,21 @@ +import type { User, UserRole } from '@/types' + +const USER_ROLES: UserRole[] = ['admin', 'administrative', 'it', 'user'] + +export const normalizeUser = (rawUser: any): User => { + const roleCandidate = rawUser?.role || rawUser?.roles?.find((role: string) => USER_ROLES.includes(role as UserRole)) + const role = USER_ROLES.includes(roleCandidate as UserRole) ? (roleCandidate as UserRole) : 'user' + + return { + ...rawUser, + id: String(rawUser?.id ?? rawUser?.username ?? ''), + username: rawUser?.username || '', + name: rawUser?.name || rawUser?.username || '', + role, + roles: [role], + permissions: Array.isArray(rawUser?.permissions) ? rawUser.permissions : [], + person_id: rawUser?.person_id ?? null, + person: rawUser?.person ?? null, + person_bound: Boolean(rawUser?.person_bound ?? rawUser?.person_id) + } +} diff --git a/frontend/src/components/GlobalSearch.vue b/frontend/src/components/GlobalSearch.vue index 6ed0cc4..794e296 100644 --- a/frontend/src/components/GlobalSearch.vue +++ b/frontend/src/components/GlobalSearch.vue @@ -55,6 +55,7 @@ import { useRouter } from 'vue-router' import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline' import { globalSearch, type SearchResult } from '@/api/search' import { useAuthStore } from '@/stores/auth' +import { PERMISSIONS } from '@/auth/permissions' const router = useRouter() const authStore = useAuthStore() @@ -98,15 +99,17 @@ const goToDetail = (item: SearchResult) => { keyword.value = '' results.value = [] - if (authStore.user?.role === 'admin') { + if (authStore.can(PERMISSIONS.ASSETS_VIEW_ALL)) { router.push(`/equipment/edit/${item.id}`) return } - router.push({ - path: '/borrow/scan', - query: { equipment_code: item.code } - }) + if (authStore.can(PERMISSIONS.ASSETS_BORROW)) { + router.push({ + path: '/borrow/scan', + query: { equipment_code: item.code } + }) + } } const getStatusClass = (status: string) => { diff --git a/frontend/src/components/MountManager.vue b/frontend/src/components/MountManager.vue index 8632e96..233b266 100644 --- a/frontend/src/components/MountManager.vue +++ b/frontend/src/components/MountManager.vue @@ -111,7 +111,7 @@

更换配件:{{ replacingChild?.equipment_name }}

-

这里只显示空闲可挂载的新配件:未挂载、当前可用、且没有未归还记录。替换时会同步让新配件继承父资产状态,并为旧配件选择拆下后的状态。

+

这里只显示空闲可挂载的新配件:未挂载、当前可用、且没有未归还记录。替换后新配件继承父资产状态,旧配件恢复可用并清空部门;如需维修或报废,再走对应流程。

@@ -159,7 +159,6 @@ import { computed, ref, onMounted } from 'vue' import { getChildren, getAvailableChildren, mountChild, unmountChild, replaceChild } from '@/api/mount' import type { ChildEquipment } from '@/api/mount' -import type { EquipmentStatus } from '@/types' const props = defineProps<{ parentId: number @@ -177,10 +176,6 @@ const showMountModal = ref(false) const showReplaceModal = ref(false) const searchKeyword = ref('') const replacingChild = ref(null) - -type ReleasedChildStatus = Extract - -const releasedChildStatusOptions: ReleasedChildStatus[] = ['可用', '维护中', '待报废', '已报废'] const childTypeSortOrder: Record = { cpu: 1, 主板: 2, @@ -251,25 +246,6 @@ const openMountModal = async () => { await fetchAvailableChildren() } -const promptReleasedChildStatus = (childName: string, actionLabel: string): ReleasedChildStatus | null => { - const input = window.prompt( - `${actionLabel}后,"${childName}" 要设置成什么状态?请输入:${releasedChildStatusOptions.join(' / ')}`, - '可用' - ) - - if (input == null) { - return null - } - - const normalized = input.trim() as ReleasedChildStatus - if (!releasedChildStatusOptions.includes(normalized)) { - alert(`请输入以下状态之一:${releasedChildStatusOptions.join(' / ')}`) - return null - } - - return normalized -} - const selectChild = async (child: ChildEquipment) => { if (!confirm(`确定要挂载 "${child.equipment_name}" 吗?`)) return try { @@ -283,13 +259,10 @@ const selectChild = async (child: ChildEquipment) => { } const handleUnmount = async (child: ChildEquipment) => { - if (!confirm(`确定要卸载 "${child.equipment_name}" 吗?`)) return - const releasedChildStatus = promptReleasedChildStatus(child.equipment_name, '卸载') - if (!releasedChildStatus) return + if (!confirm(`确定要卸载 "${child.equipment_name}" 吗?拆下后将恢复可用并清空部门;如需维修或报废,再走对应流程。`)) return try { await unmountChild(props.parentId, { - child_id: child.id, - released_child_status: releasedChildStatus + child_id: child.id }) await fetchChildren() emit('updated') @@ -307,14 +280,11 @@ const handleReplace = (child: ChildEquipment) => { const confirmReplace = async (newChild: ChildEquipment) => { if (!replacingChild.value) return - if (!confirm(`确定要用 "${newChild.equipment_name}" 替换 "${replacingChild.value.equipment_name}" 吗?`)) return - const releasedChildStatus = promptReleasedChildStatus(replacingChild.value.equipment_name, '更换') - if (!releasedChildStatus) return + if (!confirm(`确定要用 "${newChild.equipment_name}" 替换 "${replacingChild.value.equipment_name}" 吗?旧配件将恢复可用并清空部门;如需维修或报废,再走对应流程。`)) return try { await replaceChild(props.parentId, { old_child_id: replacingChild.value.id, - new_child_id: newChild.id, - released_child_status: releasedChildStatus + new_child_id: newChild.id }) showReplaceModal.value = false replacingChild.value = null diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 6097199..c7a89a0 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -56,14 +56,14 @@ {{ authStore.user?.name || authStore.user?.username || '用户' }}

- {{ authStore.user?.role === 'admin' ? '管理员' : '用户' }} + {{ getRoleLabel(authStore.user?.role) }}

-
+
- {{ authStore.user?.role === 'admin' ? '管理员' : '协作' }} + {{ getRoleLabel(authStore.user?.role) }}
@@ -143,8 +143,12 @@ import { BellIcon, Cog6ToothIcon, Bars3Icon, - XMarkIcon + XMarkIcon, + WrenchScrewdriverIcon, + ArchiveBoxXMarkIcon, + UserCircleIcon } from '@heroicons/vue/24/outline' +import { getRoleLabel, PERMISSIONS } from '@/auth/permissions' const router = useRouter() const route = useRoute() @@ -155,17 +159,28 @@ const mobileNavOpen = ref(false) const navItems = [ { path: '/dashboard', title: '首页', icon: HomeIcon }, - { path: '/equipment', title: '资产管理', icon: CubeIcon, requiresAdmin: true }, - { path: '/inventory', title: '资产盘点', icon: ClipboardDocumentCheckIcon, requiresAdmin: true }, - { path: '/borrow', title: '使用管理', icon: ArrowRightIcon }, - { path: '/statistics', title: '统计', icon: ChartBarIcon }, - { path: '/notifications', title: '消息', icon: BellIcon, requiresAdmin: true }, - { path: '/settings', title: '设置', icon: Cog6ToothIcon, requiresAdmin: true } + { path: '/equipment', title: '资产管理', icon: CubeIcon, permission: PERMISSIONS.ASSETS_VIEW_ALL }, + { path: '/my-assets', title: '我的资产', icon: UserCircleIcon, permission: PERMISSIONS.ASSETS_VIEW_OWN }, + { path: '/inventory', title: '资产盘点', icon: ClipboardDocumentCheckIcon, permission: PERMISSIONS.INVENTORY_MANAGE }, + { path: '/borrow', title: '使用管理', icon: ArrowRightIcon, permission: PERMISSIONS.ASSETS_BORROW }, + { path: '/maintenance', title: '维修管理', icon: WrenchScrewdriverIcon, permission: PERMISSIONS.ASSETS_MAINTENANCE }, + { + path: '/workflows/disposals', + title: '报废流程', + icon: ArchiveBoxXMarkIcon, + anyPermissions: [PERMISSIONS.DISPOSALS_APPLY, PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_COMPLETE] + }, + { path: '/statistics', title: '统计', icon: ChartBarIcon, permission: PERMISSIONS.REPORTS_VIEW }, + { path: '/notifications', title: '消息', icon: BellIcon, permission: PERMISSIONS.NOTIFICATIONS_VIEW }, + { path: '/settings', title: '设置', icon: Cog6ToothIcon, permission: PERMISSIONS.SYSTEM_SETTINGS } ] const visibleNavItems = computed(() => { - const isAdmin = authStore.user?.role === 'admin' - return navItems.filter((item) => !item.requiresAdmin || isAdmin) + return navItems.filter((item) => { + if (item.permission && !authStore.can(item.permission)) return false + if (item.anyPermissions && !authStore.canAny(item.anyPermissions)) return false + return true + }) }) const currentSection = computed(() => { @@ -182,6 +197,9 @@ const currentSubtitle = computed(() => { if (route.path.startsWith('/inventory/')) return '查看盘点人、资产与异常结果' if (route.path.startsWith('/borrow')) return '借还管理' if (route.path.startsWith('/return/')) return '扫码归还' + if (route.path === '/my-assets') return '当前未归还资产' + if (route.path.startsWith('/maintenance')) return '维修任务与结果' + if (route.path.startsWith('/workflows/disposals')) return '申请、审批与处置' if (route.path === '/statistics') return '数据分析' if (route.path === '/notifications') return '同步提醒与待办' if (route.path === '/settings') return '系统配置' @@ -208,9 +226,9 @@ watch( ) watch( - () => authStore.user?.role, - (role) => { - if (role === 'admin') { + () => authStore.user?.permissions, + () => { + if (authStore.can(PERMISSIONS.NOTIFICATIONS_VIEW)) { notificationStore.fetchUnreadCount() } }, diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 90c15f8..6b6150e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,8 +8,10 @@ import { useAuthStore } from './stores/auth' import api from './api' import type { User } from './types' import { resolveAuthMode } from './utils/authMode' +import { normalizeUser } from './auth/user' const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/' +const PUBLIC_AUTH_ROUTES = ['/dingtalk-entry'] const bootstrap = async () => { const app = createApp(App) @@ -21,20 +23,14 @@ const bootstrap = async () => { const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) const isPublicInventoryRoute = window.location.pathname.startsWith(PUBLIC_INVENTORY_ROUTE_PREFIX) + const isPublicAuthRoute = PUBLIC_AUTH_ROUTES.includes(window.location.pathname) if (authMode === 'local') { try { const resp = await api.get('/auth/user') if (resp?.data?.data) { const user = resp.data.data - const normalizedUser: User = { - ...user, - id: String(user.id ?? user.username ?? ''), - username: user.username || '', - name: user.name || user.username || '', - role: user.role || (user.roles?.includes('admin') ? 'admin' : 'user'), - roles: user.roles || [] - } + const normalizedUser: User = normalizeUser(user) authStore.setUser(normalizedUser) authStore.setToken('session') } else { @@ -59,12 +55,24 @@ const bootstrap = async () => { return } - if (isPublicInventoryRoute) { + if (isPublicInventoryRoute || isPublicAuthRoute) { app.use(router) app.mount('#app') return } + if (localStorage.getItem('token') === 'session') { + try { + await authStore.refreshUser() + app.use(router) + app.mount('#app') + return + } catch { + authStore.$patch({ token: null, user: null, isAuthenticated: false }) + localStorage.removeItem('token') + } + } + try { const authenticated = await keycloak.init({ onLoad: 'login-required', @@ -81,20 +89,17 @@ const bootstrap = async () => { authStore.setToken(keycloak.token) } - const roles = (keycloak.tokenParsed?.realm_access?.roles as string[]) || [] - authStore.setUser({ - id: (keycloak.tokenParsed?.sub as string) || '', - username: (keycloak.tokenParsed?.preferred_username as string) || '', - name: (keycloak.tokenParsed?.name as string) || (keycloak.tokenParsed?.preferred_username as string) || '', - role: roles.includes('admin') ? 'admin' : 'user', - email: (keycloak.tokenParsed?.email as string) || '' - }) + await authStore.refreshUser() // 定期刷新token setInterval(() => { - keycloak.updateToken(60).catch(() => { - keycloak.login() - }) + keycloak.updateToken(60) + .then((refreshed) => { + if (refreshed && keycloak.token) authStore.setToken(keycloak.token) + }) + .catch(() => { + keycloak.login() + }) }, 60000) app.use(router) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e59bde1..9c24979 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router' import { useAuthStore } from '@/stores/auth' import keycloak from '@/auth/keycloak' import { resolveAuthMode } from '@/utils/authMode' +import { PERMISSIONS } from '@/auth/permissions' const routes: RouteRecordRaw[] = [ { @@ -17,6 +18,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/inventory/PublicInventoryTask.vue'), meta: { requiresAuth: false, title: '手机盘点' } }, + { + path: '/dingtalk-entry', + name: 'DingTalkEntry', + component: () => import('../views/auth/DingTalkEntry.vue'), + meta: { requiresAuth: false, title: '钉钉免登' } + }, { path: '/', component: () => import('../layouts/MainLayout.vue'), @@ -33,73 +40,103 @@ const routes: RouteRecordRaw[] = [ path: 'equipment', name: 'Equipment', component: () => import('../views/equipment/EquipmentList.vue'), - meta: { title: '设备管理', requiresAdmin: true } + meta: { title: '资产管理', permission: PERMISSIONS.ASSETS_VIEW_ALL } }, { path: 'equipment/add', name: 'EquipmentAdd', component: () => import('../views/equipment/EquipmentForm.vue'), - meta: { title: '新增设备', requiresAdmin: true } + meta: { title: '新增资产', permission: PERMISSIONS.ASSETS_CREATE } }, { path: 'equipment/edit/:id', name: 'EquipmentEdit', component: () => import('../views/equipment/EquipmentForm.vue'), - meta: { title: '编辑设备', requiresAdmin: true } + meta: { title: '资产详情', permission: PERMISSIONS.ASSETS_VIEW_ALL } }, { path: 'borrow', name: 'Borrow', component: () => import('../views/borrow/BorrowList.vue'), - meta: { title: '借用管理' } + meta: { title: '借用管理', permission: PERMISSIONS.ASSETS_BORROW } }, { path: 'borrow/scan', name: 'BorrowScan', component: () => import('../views/borrow/BorrowScan.vue'), - meta: { title: '扫码出库' } + meta: { title: '扫码出库', permission: PERMISSIONS.ASSETS_BORROW } }, { path: 'return/scan', name: 'ReturnScan', component: () => import('../views/borrow/ReturnScan.vue'), - meta: { title: '扫码入库' } + meta: { title: '扫码入库', permission: PERMISSIONS.ASSETS_BORROW } }, { path: 'statistics', name: 'Statistics', component: () => import('../views/statistics/Statistics.vue'), - meta: { title: '统计分析' } + meta: { title: '统计分析', permission: PERMISSIONS.REPORTS_VIEW } }, { path: 'inventory', name: 'InventoryList', component: () => import('../views/inventory/InventoryList.vue'), - meta: { title: '资产盘点', requiresAdmin: true } + meta: { title: '资产盘点', permission: PERMISSIONS.INVENTORY_MANAGE } }, { path: 'inventory/create', name: 'InventoryCreate', component: () => import('../views/inventory/InventoryCreate.vue'), - meta: { title: '新建盘点任务', requiresAdmin: true } + meta: { title: '新建盘点任务', permission: PERMISSIONS.INVENTORY_MANAGE } }, { path: 'inventory/:id', name: 'InventoryDetail', component: () => import('../views/inventory/InventoryDetail.vue'), - meta: { title: '盘点任务详情', requiresAdmin: true } + meta: { title: '盘点任务详情', permission: PERMISSIONS.INVENTORY_MANAGE } }, { path: 'notifications', name: 'Notifications', component: () => import('../views/notifications/NotificationCenter.vue'), - meta: { title: '站内消息', requiresAdmin: true } + meta: { title: '站内消息', permission: PERMISSIONS.NOTIFICATIONS_VIEW } + }, + { + path: 'workflows/disposals', + name: 'Disposals', + component: () => import('../views/workflows/DisposalList.vue'), + meta: { + title: '报废流程', + anyPermissions: [PERMISSIONS.DISPOSALS_APPLY, PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_COMPLETE] + } + }, + { + path: 'workflows/disposals/:id', + name: 'DisposalDetail', + component: () => import('../views/workflows/DisposalDetail.vue'), + meta: { + title: '报废详情', + anyPermissions: [PERMISSIONS.DISPOSALS_APPLY, PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_COMPLETE] + } + }, + { + path: 'maintenance', + name: 'Maintenance', + component: () => import('../views/maintenance/MaintenanceList.vue'), + meta: { title: '维修管理', permission: PERMISSIONS.ASSETS_MAINTENANCE } + }, + { + path: 'my-assets', + name: 'MyAssets', + component: () => import('../views/assets/MyAssets.vue'), + meta: { title: '我的资产', permission: PERMISSIONS.ASSETS_VIEW_OWN } }, { path: 'settings', name: 'Settings', component: () => import('../views/settings/Settings.vue'), - meta: { title: '系统设置', requiresAdmin: true } + meta: { title: '系统设置', permission: PERMISSIONS.SYSTEM_SETTINGS } } ] } @@ -110,13 +147,13 @@ const router = createRouter({ routes }) -router.beforeEach((to, _from, next) => { +router.beforeEach(async (to, _from, next) => { const authStore = useAuthStore() const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) const isAuthenticated = authMode === 'local' ? authStore.isAuthenticated - : Boolean(keycloak.authenticated && authStore.isAuthenticated) + : Boolean(authStore.isAuthenticated && (keycloak.authenticated || authStore.token === 'session')) if (to.path === '/login' && isAuthenticated) { next('/dashboard') @@ -133,7 +170,29 @@ router.beforeEach((to, _from, next) => { return } - if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') { + if (to.meta.requiresAuth && isAuthenticated) { + try { + await authStore.refreshUser() + } catch { + if (authMode === 'local' || authStore.token === 'session') { + next('/login') + } else { + await keycloak.login() + next(false) + } + return + } + } + + const requiredPermission = to.meta.permission as string | undefined + const anyPermissions = to.meta.anyPermissions as string[] | undefined + + if (requiredPermission && !authStore.can(requiredPermission)) { + next('/dashboard') + return + } + + if (anyPermissions?.length && !authStore.canAny(anyPermissions)) { next('/dashboard') return } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 621eb2d..ec424ae 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -3,6 +3,7 @@ import type { User } from '@/types' import keycloak from '@/auth/keycloak' import api from '@/api' import { resolveAuthMode } from '@/utils/authMode' +import { normalizeUser } from '@/auth/user' interface AuthState { user: User | null @@ -19,10 +20,26 @@ export const useAuthStore = defineStore('auth', { actions: { setUser(user: User) { - this.user = user + this.user = normalizeUser(user) this.isAuthenticated = true }, + can(permission: string) { + return Boolean(this.user?.permissions?.includes(permission)) + }, + + canAny(permissions: string[]) { + return permissions.some((permission) => this.can(permission)) + }, + + async refreshUser() { + const response = await api.get('/auth/user') + const user = response?.data?.data + if (!user) throw new Error('当前账号不可用') + this.setUser(user) + return this.user + }, + setToken(token: string) { this.token = token this.isAuthenticated = true @@ -30,19 +47,25 @@ export const useAuthStore = defineStore('auth', { }, async logout() { + const usedSession = this.token === 'session' + const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) this.user = null this.token = null this.isAuthenticated = false localStorage.removeItem('token') + try { - const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) - if (authMode === 'local') { - await api.post('/auth/logout') - } else if (keycloak.authenticated) { - await keycloak.logout() - } + await api.post('/auth/logout') } catch (error) { - console.error('Keycloak 登出失败:', error) + console.error('后端会话退出失败:', error) + } + + if (authMode !== 'local' && !usedSession && keycloak.authenticated) { + try { + await keycloak.logout() + } catch (error) { + console.error('Keycloak 退出失败:', error) + } } } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2d81bb9..ca695bb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -113,6 +113,21 @@ export interface OrganizationTreeResponse { summary: OrganizationTreeSummary } +export interface EquipmentTreeNode { + id: number + equipment_code: string + equipment_name: string + equipment_model?: string + equipment_type?: string + unit_price?: number + status?: string + mount_status?: string + parent_id?: number | null + updated_at?: string + current_borrow?: EquipmentCurrentBorrow | null + children?: EquipmentTreeNode[] +} + // 设备接口 export interface Equipment { id: number @@ -152,18 +167,8 @@ export interface Equipment { borrow_anomaly?: BorrowAnomalyInfo | null recent_change_at?: string | null latest_mount_change_at?: string | null - children?: Array<{ - id: number - equipment_code: string - equipment_name: string - equipment_model?: string - equipment_type?: string - unit_price?: number - status?: string - mount_status?: string - updated_at?: string - current_borrow?: EquipmentCurrentBorrow | null - }> + can_correct_code?: boolean + children?: EquipmentTreeNode[] created_at: string updated_at: string } @@ -505,15 +510,31 @@ export interface InventoryPublicConfirmResponse { export type InventoryPublicMeResponse = PublicInventorySessionData export type InventoryPublicItemsResponse = PublicInventoryItemsResponse +export type UserRole = 'admin' | 'administrative' | 'it' | 'user' + +export interface BoundPersonSummary { + id: number + real_name: string + nickname: string + status: 'active' | 'inactive' + source_employee_status?: string | null +} + // 用户接口 export interface User { id: number | string username: string name: string - role: 'admin' | 'user' + role: UserRole email?: string phone?: string roles?: string[] + status?: 'active' | 'inactive' + permissions: string[] + person_id?: number | null + person?: BoundPersonSummary | null + person_bound?: boolean + last_login_at?: string | null } // API响应接口 diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 65800ce..817e104 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -83,7 +83,6 @@ import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import keycloak from '@/auth/keycloak' import api from '@/api' -import type { User } from '@/types' import { useSiteStore } from '@/stores/site' import { resolveAuthMode } from '@/utils/authMode' @@ -122,22 +121,14 @@ const handleLocalLogin = async () => { password: password.value }) const payload = resp?.data?.data - const rawUser = payload?.user as Partial | undefined + const rawUser = payload?.user if (!payload?.token || !rawUser) { throw new Error('登录响应数据不完整') } authStore.setToken(payload.token) - const normalizedUser: User = { - ...rawUser, - id: String(rawUser.id ?? rawUser.username ?? ''), - username: rawUser.username || '', - name: rawUser.name || rawUser.username || '', - role: rawUser.role || (rawUser.roles?.includes('admin') ? 'admin' : 'user'), - roles: rawUser.roles || [] - } - authStore.setUser(normalizedUser) + await authStore.refreshUser() await router.push('/dashboard') } catch (e: any) { errorText.value = e?.message || '登录失败,请重试' diff --git a/frontend/src/views/assets/MyAssets.vue b/frontend/src/views/assets/MyAssets.vue new file mode 100644 index 0000000..9d3992c --- /dev/null +++ b/frontend/src/views/assets/MyAssets.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/src/views/auth/DingTalkEntry.vue b/frontend/src/views/auth/DingTalkEntry.vue new file mode 100644 index 0000000..fbdf692 --- /dev/null +++ b/frontend/src/views/auth/DingTalkEntry.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/views/dashboard/Dashboard.vue b/frontend/src/views/dashboard/Dashboard.vue index ced9d19..4e472bc 100644 --- a/frontend/src/views/dashboard/Dashboard.vue +++ b/frontend/src/views/dashboard/Dashboard.vue @@ -2,7 +2,7 @@
@@ -14,16 +14,18 @@
- @@ -257,6 +308,9 @@ import * as echarts from 'echarts' import { getDashboardStats } from '@/api/statistics' import { getBorrowRecordList } from '@/api/borrow' import type { DashboardStats, BorrowRecord } from '@/types' +import { getMyAssets, type MyAssetRecord } from '@/api/myAssets' +import { getDisposalList } from '@/api/disposal' +import { PERMISSIONS } from '@/auth/permissions' import { formatCurrencyNoFraction, formatDateTime, formatNumber, formatPercent } from '@/utils/format' import { BanknotesIcon, @@ -273,10 +327,18 @@ const recentRecords = ref([]) const statusChartRef = ref() const categoryChartRef = ref() const isLoading = ref(false) +const pendingDisposals = ref(0) +const myAssets = ref([]) +const myAssetsLoading = ref(false) +const myAssetsError = ref('') let statusChart: echarts.ECharts | null = null let categoryChart: echarts.ECharts | null = null -const isAdmin = computed(() => authStore.user?.role === 'admin') +const canViewReports = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW)) +const canViewAllAssets = computed(() => authStore.can(PERMISSIONS.ASSETS_VIEW_ALL)) +const canBorrow = computed(() => authStore.can(PERMISSIONS.ASSETS_BORROW)) +const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE)) +const canApproveDisposals = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPROVE)) const statCards = computed(() => { const data = stats.value @@ -356,6 +418,30 @@ const fetchDashboardData = async () => { } } +const fetchRoleSummary = async () => { + if (canApproveDisposals.value) { + try { + const response = await getDisposalList({ page: 1, pageSize: 1, scope: 'pending' }) + pendingDisposals.value = response.data?.total || 0 + } catch (error) { + console.error('获取待审批数量失败:', error) + } + } +} + +const fetchMyAssets = async () => { + myAssetsLoading.value = true + myAssetsError.value = '' + try { + const response = await getMyAssets() + myAssets.value = response.data?.assets || [] + } catch (error: any) { + myAssetsError.value = error?.message || '本人资产加载失败' + } finally { + myAssetsLoading.value = false + } +} + const initCharts = () => { if (statusChartRef.value && !statusChart) { statusChart = echarts.init(statusChartRef.value) @@ -456,9 +542,13 @@ const resizeHandler = () => { } onMounted(async () => { - await fetchDashboardData() - initCharts() - window.addEventListener('resize', resizeHandler) + if (canViewReports.value) { + await Promise.all([fetchDashboardData(), fetchRoleSummary()]) + initCharts() + window.addEventListener('resize', resizeHandler) + } else { + await fetchMyAssets() + } }) onUnmounted(() => { diff --git a/frontend/src/views/equipment/EquipmentForm.vue b/frontend/src/views/equipment/EquipmentForm.vue index f3ccd61..2ca9ea5 100644 --- a/frontend/src/views/equipment/EquipmentForm.vue +++ b/frontend/src/views/equipment/EquipmentForm.vue @@ -4,12 +4,12 @@ @@ -18,6 +18,7 @@

基础信息

+
@@ -39,7 +40,7 @@
- +
@@ -70,7 +71,7 @@
- +
+ + + diff --git a/frontend/src/views/workflows/DisposalList.vue b/frontend/src/views/workflows/DisposalList.vue new file mode 100644 index 0000000..8642587 --- /dev/null +++ b/frontend/src/views/workflows/DisposalList.vue @@ -0,0 +1,7 @@ + + +