feat(流程): 增加角色权限、报废审批与钉钉通知
This commit is contained in:
parent
373825dbfe
commit
a6463fc30a
@ -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
|
||||
|
||||
@ -0,0 +1,291 @@
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `person_id` INT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX `user_person_id_key`
|
||||
ON `user`(`person_id`);
|
||||
|
||||
ALTER TABLE `user`
|
||||
ADD CONSTRAINT `user_person_id_fkey`
|
||||
FOREIGN KEY (`person_id`) REFERENCES `person`(`id`)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE `disposal_record`
|
||||
ADD COLUMN `completed_by` INT NULL,
|
||||
ADD COLUMN `pre_status` VARCHAR(20) NOT NULL DEFAULT '可用',
|
||||
ADD COLUMN `reject_reason` TEXT NULL,
|
||||
ADD COLUMN `completion_notes` TEXT NULL,
|
||||
ADD COLUMN `reminded_at` DATETIME(3) NULL;
|
||||
|
||||
CREATE INDEX `disposal_record_applied_by_created_at_idx`
|
||||
ON `disposal_record`(`applied_by`, `created_at`);
|
||||
CREATE INDEX `disposal_record_approved_by_approved_at_idx`
|
||||
ON `disposal_record`(`approved_by`, `approved_at`);
|
||||
CREATE INDEX `disposal_record_completed_by_completed_at_idx`
|
||||
ON `disposal_record`(`completed_by`, `completed_at`);
|
||||
|
||||
ALTER TABLE `disposal_record`
|
||||
ADD CONSTRAINT `disposal_record_completed_by_fkey`
|
||||
FOREIGN KEY (`completed_by`) REFERENCES `user`(`id`)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE `disposal_item` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`disposal_id` INT NOT NULL,
|
||||
`equipment_id` INT NOT NULL,
|
||||
`equipment_code` VARCHAR(50) NOT NULL,
|
||||
`equipment_name` VARCHAR(100) NOT NULL,
|
||||
`parent_equipment_id` INT NULL,
|
||||
`parent_equipment_code` VARCHAR(50) NULL,
|
||||
`pre_status` VARCHAR(20) NOT NULL,
|
||||
`snapshot_json` TEXT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `disposal_item_disposal_id_equipment_id_key`(`disposal_id`, `equipment_id`),
|
||||
INDEX `disposal_item_equipment_id_idx`(`equipment_id`),
|
||||
INDEX `disposal_item_parent_equipment_id_idx`(`parent_equipment_id`),
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `disposal_item_disposal_id_fkey`
|
||||
FOREIGN KEY (`disposal_id`) REFERENCES `disposal_record`(`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT `disposal_item_equipment_id_fkey`
|
||||
FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`)
|
||||
ON DELETE RESTRICT
|
||||
ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `disposal_action` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`disposal_id` INT NOT NULL,
|
||||
`action_type` VARCHAR(30) NOT NULL,
|
||||
`operator_id` INT NULL,
|
||||
`from_status` VARCHAR(20) NULL,
|
||||
`to_status` VARCHAR(20) NULL,
|
||||
`detail` TEXT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `disposal_action_disposal_id_created_at_idx`(`disposal_id`, `created_at`),
|
||||
INDEX `disposal_action_operator_id_idx`(`operator_id`),
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `disposal_action_disposal_id_fkey`
|
||||
FOREIGN KEY (`disposal_id`) REFERENCES `disposal_record`(`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT `disposal_action_operator_id_fkey`
|
||||
FOREIGN KEY (`operator_id`) REFERENCES `user`(`id`)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `asset_transfer_record` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`root_equipment_id` INT NOT NULL,
|
||||
`action_type` VARCHAR(20) NOT NULL,
|
||||
`from_department_id` INT NULL,
|
||||
`from_department_name` VARCHAR(100) NULL,
|
||||
`to_department_id` INT NULL,
|
||||
`to_department_name` VARCHAR(100) NULL,
|
||||
`reason` TEXT NULL,
|
||||
`operated_by` INT NOT NULL,
|
||||
`operated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `asset_transfer_record_root_equipment_id_operated_at_idx`(`root_equipment_id`, `operated_at`),
|
||||
INDEX `asset_transfer_record_operated_by_idx`(`operated_by`),
|
||||
INDEX `asset_transfer_record_action_type_operated_at_idx`(`action_type`, `operated_at`),
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `asset_transfer_record_root_equipment_id_fkey`
|
||||
FOREIGN KEY (`root_equipment_id`) REFERENCES `equipment`(`id`)
|
||||
ON DELETE RESTRICT
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT `asset_transfer_record_operated_by_fkey`
|
||||
FOREIGN KEY (`operated_by`) REFERENCES `user`(`id`)
|
||||
ON DELETE RESTRICT
|
||||
ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `asset_transfer_item` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`transfer_id` INT NOT NULL,
|
||||
`equipment_id` INT NOT NULL,
|
||||
`equipment_code` VARCHAR(50) NOT NULL,
|
||||
`equipment_name` VARCHAR(100) NOT NULL,
|
||||
`parent_equipment_id` INT NULL,
|
||||
`from_department_id` INT NULL,
|
||||
`to_department_id` INT NULL,
|
||||
`storage_location_id` INT NULL,
|
||||
`snapshot_status` VARCHAR(20) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `asset_transfer_item_transfer_id_equipment_id_key`(`transfer_id`, `equipment_id`),
|
||||
INDEX `asset_transfer_item_equipment_id_created_at_idx`(`equipment_id`, `created_at`),
|
||||
INDEX `asset_transfer_item_parent_equipment_id_idx`(`parent_equipment_id`),
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `asset_transfer_item_transfer_id_fkey`
|
||||
FOREIGN KEY (`transfer_id`) REFERENCES `asset_transfer_record`(`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT `asset_transfer_item_equipment_id_fkey`
|
||||
FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`)
|
||||
ON DELETE RESTRICT
|
||||
ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `dingtalk_delivery` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`idempotency_key` VARCHAR(191) NOT NULL,
|
||||
`event_type` VARCHAR(50) NOT NULL,
|
||||
`business_type` VARCHAR(30) NOT NULL,
|
||||
`business_id` INT NOT NULL,
|
||||
`recipient_user_id` INT NOT NULL,
|
||||
`recipient_dingtalk_userid` VARCHAR(100) NULL,
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
`attempt_count` INT NOT NULL DEFAULT 0,
|
||||
`max_attempts` INT NOT NULL DEFAULT 5,
|
||||
`next_attempt_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`lease_owner` VARCHAR(100) NULL,
|
||||
`lease_expires_at` DATETIME(3) NULL,
|
||||
`dingtalk_task_id` VARCHAR(100) NULL,
|
||||
`payload_json` TEXT NOT NULL,
|
||||
`last_error_code` VARCHAR(100) NULL,
|
||||
`last_error_message` TEXT NULL,
|
||||
`sent_at` DATETIME(3) NULL,
|
||||
`read_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `dingtalk_delivery_idempotency_key_key`(`idempotency_key`),
|
||||
INDEX `dingtalk_delivery_status_next_attempt_at_idx`(`status`, `next_attempt_at`),
|
||||
INDEX `dingtalk_delivery_lease_expires_at_idx`(`lease_expires_at`),
|
||||
INDEX `dingtalk_delivery_business_type_business_id_idx`(`business_type`, `business_id`),
|
||||
INDEX `dingtalk_delivery_recipient_user_id_created_at_idx`(`recipient_user_id`, `created_at`),
|
||||
INDEX `dingtalk_delivery_dingtalk_task_id_idx`(`dingtalk_task_id`),
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `dingtalk_delivery_recipient_user_id_fkey`
|
||||
FOREIGN KEY (`recipient_user_id`) REFERENCES `user`(`id`)
|
||||
ON DELETE RESTRICT
|
||||
ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO `disposal_item` (
|
||||
`disposal_id`,
|
||||
`equipment_id`,
|
||||
`equipment_code`,
|
||||
`equipment_name`,
|
||||
`parent_equipment_id`,
|
||||
`parent_equipment_code`,
|
||||
`pre_status`,
|
||||
`snapshot_json`
|
||||
)
|
||||
SELECT
|
||||
disposal.`id`,
|
||||
equipment.`id`,
|
||||
equipment.`equipment_code`,
|
||||
equipment.`equipment_name`,
|
||||
equipment.`parent_id`,
|
||||
parent.`equipment_code`,
|
||||
disposal.`pre_status`,
|
||||
JSON_OBJECT(
|
||||
'equipment_code', equipment.`equipment_code`,
|
||||
'equipment_name', equipment.`equipment_name`,
|
||||
'equipment_model', equipment.`equipment_model`,
|
||||
'equipment_type', equipment.`equipment_type`,
|
||||
'department_id', equipment.`department_id`,
|
||||
'storage_location_id', equipment.`storage_location_id`,
|
||||
'parent_id', equipment.`parent_id`
|
||||
)
|
||||
FROM `disposal_record` disposal
|
||||
INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
|
||||
LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id`
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `disposal_item` existing
|
||||
WHERE existing.`disposal_id` = disposal.`id`
|
||||
AND existing.`equipment_id` = equipment.`id`
|
||||
);
|
||||
|
||||
-- 旧版报废申请会将根资产及当时挂载的后代统一进入待报废,
|
||||
-- 驳回时又统一恢复为“可用”。因此只对仍未完成的历史单按当前关系递归补录,
|
||||
-- 后代 pre_status 按旧版可逆行为记为“可用”;已驳回/已完成单不猜测后续可能已变化的挂载关系。
|
||||
INSERT INTO `disposal_item` (
|
||||
`disposal_id`,
|
||||
`equipment_id`,
|
||||
`equipment_code`,
|
||||
`equipment_name`,
|
||||
`parent_equipment_id`,
|
||||
`parent_equipment_code`,
|
||||
`pre_status`,
|
||||
`snapshot_json`
|
||||
)
|
||||
WITH RECURSIVE `active_disposal_tree` AS (
|
||||
SELECT
|
||||
disposal.`id` AS `disposal_id`,
|
||||
equipment.`id` AS `equipment_id`,
|
||||
equipment.`parent_id` AS `parent_equipment_id`,
|
||||
0 AS `depth`,
|
||||
CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`
|
||||
FROM `disposal_record` disposal
|
||||
INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
|
||||
WHERE disposal.`status` IN ('pending', 'approved')
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
tree.`disposal_id`,
|
||||
child.`id`,
|
||||
child.`parent_id`,
|
||||
tree.`depth` + 1,
|
||||
CONCAT(tree.`visited_path`, child.`id`, '/')
|
||||
FROM `active_disposal_tree` tree
|
||||
INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id`
|
||||
WHERE tree.`depth` < 100
|
||||
AND LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) = 0
|
||||
)
|
||||
SELECT
|
||||
tree.`disposal_id`,
|
||||
equipment.`id`,
|
||||
equipment.`equipment_code`,
|
||||
equipment.`equipment_name`,
|
||||
equipment.`parent_id`,
|
||||
parent.`equipment_code`,
|
||||
'可用',
|
||||
JSON_OBJECT(
|
||||
'equipment_code', equipment.`equipment_code`,
|
||||
'equipment_name', equipment.`equipment_name`,
|
||||
'equipment_model', equipment.`equipment_model`,
|
||||
'equipment_type', equipment.`equipment_type`,
|
||||
'department_id', equipment.`department_id`,
|
||||
'storage_location_id', equipment.`storage_location_id`,
|
||||
'parent_id', equipment.`parent_id`
|
||||
)
|
||||
FROM `active_disposal_tree` tree
|
||||
INNER JOIN `equipment` equipment ON equipment.`id` = tree.`equipment_id`
|
||||
LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id`
|
||||
WHERE tree.`depth` > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `disposal_item` existing
|
||||
WHERE existing.`disposal_id` = tree.`disposal_id`
|
||||
AND existing.`equipment_id` = tree.`equipment_id`
|
||||
);
|
||||
|
||||
INSERT INTO `disposal_action` (
|
||||
`disposal_id`,
|
||||
`action_type`,
|
||||
`operator_id`,
|
||||
`from_status`,
|
||||
`to_status`,
|
||||
`detail`,
|
||||
`created_at`
|
||||
)
|
||||
SELECT
|
||||
disposal.`id`,
|
||||
'legacy_imported',
|
||||
disposal.`applied_by`,
|
||||
NULL,
|
||||
disposal.`status`,
|
||||
'历史报废单迁移补录',
|
||||
disposal.`created_at`
|
||||
FROM `disposal_record` disposal;
|
||||
@ -0,0 +1,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`;
|
||||
@ -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())
|
||||
|
||||
59
backend/scripts/__tests__/deployPrecheckSafety.test.js
Normal file
59
backend/scripts/__tests__/deployPrecheckSafety.test.js
Normal file
@ -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, /不允许通过确认指纹绕过/)
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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'), /允许范围/)
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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}" 不存在` })
|
||||
|
||||
@ -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<string, any>) => {
|
||||
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<string, any>
|
||||
|
||||
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<string, any>
|
||||
|
||||
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(
|
||||
|
||||
188
backend/src/controllers/dingtalkController.ts
Normal file
188
backend/src/controllers/dingtalkController.ts
Normal file
@ -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<string, unknown>
|
||||
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<void>((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 || '补发失败')
|
||||
}
|
||||
}
|
||||
@ -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<string, any>
|
||||
const userId = Number(res.locals.user?.id)
|
||||
const rootId = Number(equipment_id)
|
||||
if (!userId) return errorResponse(res, '用户未登录', 401)
|
||||
if (!Number.isInteger(rootId) || rootId <= 0) {
|
||||
return errorResponse(res, '请选择要报废的主资产', 422)
|
||||
}
|
||||
if (!disposal_reason?.trim() || !disposal_method?.trim()) {
|
||||
return errorResponse(res, '报废原因和处置方式不能为空', 422)
|
||||
}
|
||||
const estimatedValue = Number(estimated_value ?? 0)
|
||||
if (!Number.isFinite(estimatedValue) || estimatedValue < 0) {
|
||||
return errorResponse(res, '残值估价必须是大于或等于0的有效数字', 422)
|
||||
}
|
||||
|
||||
const 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<string, any>
|
||||
const userId = Number(res.locals.user?.id)
|
||||
const actualValue = actual_value === undefined ? undefined : Number(actual_value)
|
||||
if (actualValue !== undefined && (!Number.isFinite(actualValue) || actualValue < 0)) {
|
||||
return errorResponse(res, '实际处置价值必须是大于或等于0的有效数字', 422)
|
||||
}
|
||||
|
||||
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 || '获取报废历史失败')
|
||||
|
||||
@ -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<number>([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<string, unknown> = {}
|
||||
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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<MountLifecycleStatus>(['可用', '已借出', '维护中'])
|
||||
const DETACHED_CHILD_ALLOWED_STATUSES = new Set<MountLifecycleStatus>(['可用', '维护中', '待报废', '已报废'])
|
||||
|
||||
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<number>()
|
||||
let currentId: number | null = parentId
|
||||
for (let depth = 0; currentId !== null; depth += 1) {
|
||||
if (depth >= 100) {
|
||||
throw new MountOperationError('资产父子关系层级过深,请先检查资产关系', 409)
|
||||
}
|
||||
if (currentId === childId) {
|
||||
throw new MountOperationError('不能把资产挂载到自己的下级节点上')
|
||||
}
|
||||
if (visited.has(currentId)) {
|
||||
throw new MountOperationError('现有资产关系已存在循环,请先修复关系', 409)
|
||||
}
|
||||
visited.add(currentId)
|
||||
|
||||
current = await prisma.equipment.findUnique({
|
||||
where: { id: current.parent_id },
|
||||
await lockEquipmentRows(client, [currentId])
|
||||
const current: { parent_id: number | null } | null = await client.equipment.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { parent_id: true }
|
||||
})
|
||||
currentId = current?.parent_id ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const getMountErrorStatus = (error: any) => {
|
||||
if (error instanceof MountOperationError) return error.statusCode
|
||||
if (Number.isInteger(error?.statusCode)) return error.statusCode
|
||||
if (Number.isInteger(error?.status)) return error.status
|
||||
if (error?.code === 'P2034' || /deadlock|lock wait timeout/i.test(String(error?.message || ''))) {
|
||||
return 409
|
||||
}
|
||||
return 500
|
||||
}
|
||||
|
||||
export const mountChild = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const numericParentId = Number(req.params.parentId)
|
||||
const { child_id, notes } = req.body as { child_id: number; notes?: string }
|
||||
const numericChildId = Number(child_id)
|
||||
const userId = Number(res.locals.user?.id)
|
||||
|
||||
if (!userId) {
|
||||
return errorResponse(res, '用户未登录', 401)
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureNoMountCycle(numericParentId, child_id)
|
||||
} catch (error: any) {
|
||||
return errorResponse(res, error.message || '挂载关系校验失败', 422)
|
||||
if (!Number.isInteger(numericParentId) || !Number.isInteger(numericChildId) || numericParentId <= 0 || numericChildId <= 0) {
|
||||
return errorResponse(res, '资产参数不合法', 422)
|
||||
}
|
||||
|
||||
const parent = await prisma.equipment.findUnique({
|
||||
where: { id: numericParentId },
|
||||
include: { assetType: true }
|
||||
})
|
||||
if (!parent) {
|
||||
return errorResponse(res, '父资产不存在', 404)
|
||||
}
|
||||
if (!parent.assetType?.can_be_parent) {
|
||||
return errorResponse(res, '该资产类型不支持挂载配件', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
82
backend/src/controllers/myAssetController.ts
Normal file
82
backend/src/controllers/myAssetController.ts
Normal file
@ -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 || '获取本人资产失败')
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<string, any>
|
||||
const { name, email, phone, roles, role, status, person_id } = req.body as Record<string, any>
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
||||
if (!user) {
|
||||
@ -125,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, '该用户已存在业务历史记录,无法删除')
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
150
backend/src/middleware/__tests__/auth.priority.test.ts
Normal file
150
backend/src/middleware/__tests__/auth.priority.test.ts
Normal file
@ -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
|
||||
}
|
||||
})
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<string, any>
|
||||
|
||||
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<void>((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<void>((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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
22
backend/src/routes/dingtalk.ts
Normal file
22
backend/src/routes/dingtalk.ts
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
10
backend/src/routes/myAssets.ts
Normal file
10
backend/src/routes/myAssets.ts
Normal file
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
36
backend/src/services/__tests__/dingtalkClient.config.test.ts
Normal file
36
backend/src/services/__tests__/dingtalkClient.config.test.ts
Normal file
@ -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]
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
493
backend/src/services/assetMovementService.ts
Normal file
493
backend/src/services/assetMovementService.ts
Normal file
@ -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<string, unknown>): 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<AssetTreeItem[]> => {
|
||||
const root = await client.equipment.findUnique({
|
||||
where: { id: rootEquipmentId },
|
||||
select: treeSelect
|
||||
})
|
||||
if (!root) {
|
||||
throw new Error('资产不存在')
|
||||
}
|
||||
|
||||
const result: AssetTreeItem[] = [root]
|
||||
const visited = new Set<number>([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<AssetTreeItem[]> => {
|
||||
if (!Number.isInteger(rootEquipmentId) || rootEquipmentId <= 0) {
|
||||
throw new Error('资产参数不合法')
|
||||
}
|
||||
|
||||
const rootRows = await client.$queryRaw<Array<Record<string, unknown>>>(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<number>([root.id])
|
||||
let parentIds = [root.id]
|
||||
let depth = 0
|
||||
while (parentIds.length > 0) {
|
||||
if (depth >= 100) {
|
||||
throw new Error('资产父子关系层级过深,请先检查资产关系')
|
||||
}
|
||||
const childRows = await client.$queryRaw<Array<Record<string, unknown>>>(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<Array<{ id: number }>>(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
|
||||
}
|
||||
}
|
||||
101
backend/src/services/businessNotificationService.ts
Normal file
101
backend/src/services/businessNotificationService.ts
Normal file
@ -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<NotificationRecipient[]> => 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<NotificationRecipient[]> => {
|
||||
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<number, NotificationRecipient>()
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
214
backend/src/services/dingtalkClient.ts
Normal file
214
backend/src/services/dingtalkClient.ts
Normal file
@ -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 <T extends DingTalkResponse>(
|
||||
url: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> => {
|
||||
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<string> | 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<DingTalkResponse>(
|
||||
`${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<DingTalkResponse>(
|
||||
`${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<DingTalkResponse>(
|
||||
`${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<DingTalkResponse>(
|
||||
`${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 || {}
|
||||
}
|
||||
269
backend/src/services/dingtalkDeliveryService.ts
Normal file
269
backend/src/services/dingtalkDeliveryService.ts
Normal file
@ -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<Awaited<ReturnType<typeof claimNextDelivery>>>,
|
||||
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<Awaited<ReturnType<typeof claimNextDelivery>>>
|
||||
) => {
|
||||
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()
|
||||
}
|
||||
76
backend/src/utils/__tests__/assetValue.recursion.test.ts
Normal file
76
backend/src/utils/__tests__/assetValue.recursion.test.ts
Normal file
@ -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)
|
||||
})
|
||||
@ -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('出库时使用者有主部门就同步资产归属', () => {
|
||||
|
||||
34
backend/src/utils/__tests__/permissions.test.ts
Normal file
34
backend/src/utils/__tests__/permissions.test.ts
Normal file
@ -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'), [])
|
||||
})
|
||||
@ -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<number>([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 }
|
||||
: {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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<AuthIdentityUser> => {
|
||||
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<AuthIdentityUser> => {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
28
backend/src/utils/dingtalkRedirect.ts
Normal file
28
backend/src/utils/dingtalkRedirect.ts
Normal file
@ -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}`
|
||||
}
|
||||
@ -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 '该资产已存在维修、报废或挂载历史,无法删除'
|
||||
}
|
||||
|
||||
|
||||
68
backend/src/utils/permissions.ts
Normal file
68
backend/src/utils/permissions.ts
Normal file
@ -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<UserRole, readonly Permission[]> = {
|
||||
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)
|
||||
165
deploy.sh
165
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:-<server-host>}"
|
||||
echo " 固定资产前端: http://$SERVER_HOST"
|
||||
echo " 固定资产后端: http://$SERVER_HOST:3000/api"
|
||||
echo " 摄影设备前端: http://$SERVER_HOST:8081"
|
||||
echo " 摄影设备后端: http://$SERVER_HOST:3001"
|
||||
echo "=========================================="
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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<ApiResponse<BatchResult>>('/batch/transfer', data).then((response) => response.data)
|
||||
}
|
||||
|
||||
@ -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<ApiResponse<BorrowRecord>>('/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
|
||||
|
||||
57
frontend/src/api/dingtalk.ts
Normal file
57
frontend/src/api/dingtalk.ts
Normal file
@ -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<ApiResponse<DingTalkPublicConfig>>('/dingtalk/public-config')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const loginWithDingTalk = async (data: { code: string; redirectPath: string }) => {
|
||||
const response = await api.post<ApiResponse<DingTalkLoginResponse>>('/dingtalk/h5-login', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getDingTalkDeliveryStatus = async () => {
|
||||
const response = await api.get<ApiResponse<DingTalkDeliveryStatus>>('/dingtalk/deliveries/status')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getDingTalkDeliveries = async (params: { page?: number; pageSize?: number; status?: string } = {}) => {
|
||||
const response = await api.get<ApiResponse<any>>('/dingtalk/deliveries', { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const retryDingTalkDelivery = async (id: number) => {
|
||||
const response = await api.post<ApiResponse<null>>(`/dingtalk/deliveries/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
@ -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<ApiResponse<PaginatedResponse<DisposalRecord>>>('/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<ApiResponse<DisposalRecord>>(`/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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<EquipmentStatus, '可用' | '维护中' | '待报废' | '已报废'>
|
||||
}
|
||||
|
||||
export interface ReplaceChildRequest {
|
||||
old_child_id: number
|
||||
new_child_id: number
|
||||
notes?: string
|
||||
released_child_status?: Extract<EquipmentStatus, '可用' | '维护中' | '待报废' | '已报废'>
|
||||
}
|
||||
|
||||
export interface ChildEquipment {
|
||||
|
||||
21
frontend/src/api/myAssets.ts
Normal file
21
frontend/src/api/myAssets.ts
Normal file
@ -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<ApiResponse<MyAssetsResponse>>('/my-assets')
|
||||
return response.data
|
||||
}
|
||||
@ -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<ApiResponse<User>>('/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<ApiResponse<User>>(`/users/${id}`, data)
|
||||
|
||||
34
frontend/src/auth/permissions.ts
Normal file
34
frontend/src/auth/permissions.ts
Normal file
@ -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<UserRole, string> = {
|
||||
admin: '管理员',
|
||||
administrative: '行政',
|
||||
it: 'IT',
|
||||
user: '普通用户'
|
||||
}
|
||||
|
||||
export const getRoleLabel = (role?: string | null) => ROLE_LABELS[role as UserRole] || '普通用户'
|
||||
21
frontend/src/auth/user.ts
Normal file
21
frontend/src/auth/user.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -111,7 +111,7 @@
|
||||
<div class="sheet-panel max-w-3xl">
|
||||
<div class="form-panel__header">
|
||||
<h3 class="form-panel__title">更换配件:{{ replacingChild?.equipment_name }}</h3>
|
||||
<p class="form-panel__desc">这里只显示空闲可挂载的新配件:未挂载、当前可用、且没有未归还记录。替换时会同步让新配件继承父资产状态,并为旧配件选择拆下后的状态。</p>
|
||||
<p class="form-panel__desc">这里只显示空闲可挂载的新配件:未挂载、当前可用、且没有未归还记录。替换后新配件继承父资产状态,旧配件恢复可用并清空部门;如需维修或报废,再走对应流程。</p>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">搜索新配件</label>
|
||||
@ -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<ChildEquipment | null>(null)
|
||||
|
||||
type ReleasedChildStatus = Extract<EquipmentStatus, '可用' | '维护中' | '待报废' | '已报废'>
|
||||
|
||||
const releasedChildStatusOptions: ReleasedChildStatus[] = ['可用', '维护中', '待报废', '已报废']
|
||||
const childTypeSortOrder: Record<string, number> = {
|
||||
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
|
||||
|
||||
@ -56,14 +56,14 @@
|
||||
{{ authStore.user?.name || authStore.user?.username || '用户' }}
|
||||
</p>
|
||||
<p class="text-xs text-text-tertiary">
|
||||
{{ authStore.user?.role === 'admin' ? '管理员' : '用户' }}
|
||||
{{ getRoleLabel(authStore.user?.role) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-1.5" :class="authStore.user?.role === 'admin' ? 'grid-cols-2' : 'grid-cols-1'">
|
||||
<div class="mt-3 grid gap-1.5" :class="authStore.can(PERMISSIONS.SYSTEM_SETTINGS) ? 'grid-cols-2' : 'grid-cols-1'">
|
||||
<button
|
||||
v-if="authStore.user?.role === 'admin'"
|
||||
v-if="authStore.can(PERMISSIONS.SYSTEM_SETTINGS)"
|
||||
type="button"
|
||||
class="btn-secondary min-h-[34px] px-2.5 text-xs"
|
||||
@click="router.push('/settings')"
|
||||
@ -98,7 +98,7 @@
|
||||
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<button
|
||||
v-if="authStore.user?.role === 'admin'"
|
||||
v-if="authStore.can(PERMISSIONS.NOTIFICATIONS_VIEW)"
|
||||
type="button"
|
||||
class="btn-icon relative"
|
||||
title="站内消息"
|
||||
@ -113,7 +113,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<div class="rounded-full border border-border px-2.5 py-1 text-xs text-text-tertiary">
|
||||
{{ authStore.user?.role === 'admin' ? '管理员' : '协作' }}
|
||||
{{ getRoleLabel(authStore.user?.role) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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响应接口
|
||||
|
||||
@ -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<User> | 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 || '登录失败,请重试'
|
||||
|
||||
107
frontend/src/views/assets/MyAssets.vue
Normal file
107
frontend/src/views/assets/MyAssets.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">My Assets</p>
|
||||
<h2 class="page-title">我的资产</h2>
|
||||
</div>
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">未归还</p>
|
||||
<p class="page-meta__value">{{ assets.length }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">员工绑定</p>
|
||||
<p class="page-meta__value text-base">{{ personLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="loading" class="card empty-state">正在加载本人资产...</section>
|
||||
<section v-else-if="errorText" class="card">
|
||||
<p class="text-sm text-status-error">{{ errorText }}</p>
|
||||
</section>
|
||||
<section v-else-if="assets.length === 0" class="card empty-state">当前没有未归还资产。</section>
|
||||
|
||||
<section v-else class="grid gap-4 lg:grid-cols-2">
|
||||
<article v-for="record in assets" :key="record.borrow_record_id" class="card">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-base font-semibold text-text-primary">{{ record.equipment.equipment_name }}</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ record.equipment.equipment_code }}</p>
|
||||
</div>
|
||||
<span class="badge badge-warning">使用中</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="panel">
|
||||
<p class="mobile-card__label">借出时间</p>
|
||||
<p class="mt-1 text-sm text-text-primary">{{ formatDateTime(record.borrow_time) }}</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="mobile-card__label">预计归还</p>
|
||||
<p class="mt-1 text-sm text-text-primary">{{ getReturnLabel(record) }}</p>
|
||||
</div>
|
||||
<div class="panel sm:col-span-2">
|
||||
<p class="mobile-card__label">存放位置</p>
|
||||
<p class="mt-1 text-sm text-text-primary">{{ record.equipment.storageLocation?.name || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="record.children?.length" class="mt-4 border-t border-border pt-4">
|
||||
<h3 class="section-title">随借配件</h3>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div v-for="child in record.children" :key="child.id" class="panel flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm text-text-primary">{{ child.equipment_name }}</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ child.equipment_code }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-text-secondary">{{ child.equipment_type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getMyAssets, type MyAssetRecord } from '@/api/myAssets'
|
||||
import type { BoundPersonSummary } from '@/types'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const loading = ref(false)
|
||||
const errorText = ref('')
|
||||
const person = ref<BoundPersonSummary | null>(null)
|
||||
const assets = ref<MyAssetRecord[]>([])
|
||||
|
||||
const personLabel = computed(() => {
|
||||
if (!person.value) return '未绑定'
|
||||
if (person.value.nickname && person.value.nickname !== person.value.real_name) {
|
||||
return `${person.value.nickname}(${person.value.real_name})`
|
||||
}
|
||||
return person.value.nickname || person.value.real_name
|
||||
})
|
||||
|
||||
const getReturnLabel = (record: MyAssetRecord) => {
|
||||
if (record.is_long_term) return '长期使用'
|
||||
return record.expected_return_time ? formatDateTime(record.expected_return_time) : '未设置'
|
||||
}
|
||||
|
||||
const loadAssets = async () => {
|
||||
loading.value = true
|
||||
errorText.value = ''
|
||||
try {
|
||||
const response = await getMyAssets()
|
||||
person.value = response.data?.person || null
|
||||
assets.value = response.data?.assets || []
|
||||
} catch (error: any) {
|
||||
errorText.value = error?.message || '本人资产加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAssets)
|
||||
</script>
|
||||
122
frontend/src/views/auth/DingTalkEntry.vue
Normal file
122
frontend/src/views/auth/DingTalkEntry.vue
Normal file
@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-bg-primary p-4">
|
||||
<section class="card w-full max-w-md text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-accent-primary/15 text-xl text-accent-primary">钉</div>
|
||||
<h1 class="mt-4 text-xl font-semibold text-text-primary">固定资产免登</h1>
|
||||
<p class="mt-2 text-sm" :class="errorText ? 'text-status-error' : 'text-text-secondary'">
|
||||
{{ errorText || statusText }}
|
||||
</p>
|
||||
<button v-if="errorText" type="button" class="btn-secondary mt-5 w-full" @click="startLogin">重试</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getDingTalkPublicConfig, loginWithDingTalk } from '@/api/dingtalk'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dd?: any
|
||||
}
|
||||
}
|
||||
|
||||
const DINGTALK_SDK_URL = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.39/dingtalk.open.js'
|
||||
const SAFE_PATH_PREFIXES = ['/dashboard', '/equipment', '/workflows/disposals', '/maintenance', '/my-assets', '/notifications']
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const statusText = ref('正在确认钉钉身份...')
|
||||
const errorText = ref('')
|
||||
|
||||
const sanitizeRedirectPath = (value: unknown) => {
|
||||
const path = typeof value === 'string' ? value : '/dashboard'
|
||||
if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\') || path.includes('://')) return '/dashboard'
|
||||
return SAFE_PATH_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`))
|
||||
? path
|
||||
: '/dashboard'
|
||||
}
|
||||
|
||||
const loadDingTalkSdk = () => new Promise<void>((resolve, reject) => {
|
||||
if (window.dd) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${DINGTALK_SDK_URL}"]`)
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true })
|
||||
existing.addEventListener('error', () => reject(new Error('钉钉组件加载失败')), { once: true })
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = DINGTALK_SDK_URL
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('钉钉组件加载失败'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
const requestAuthCode = (clientId: string, corpId: string) => new Promise<string>((resolve, reject) => {
|
||||
if (!window.dd) {
|
||||
reject(new Error('请从钉钉工作通知中打开此页面'))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
callback()
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => finish(() => reject(new Error('钉钉免登授权超时,请重试'))), 15000)
|
||||
|
||||
window.dd.ready(() => {
|
||||
window.dd.runtime.permission.requestAuthCode({
|
||||
clientId,
|
||||
corpId,
|
||||
onSuccess: (result: { code?: string }) => finish(() => result.code ? resolve(result.code) : reject(new Error('未取得免登码'))),
|
||||
onFail: () => finish(() => reject(new Error('钉钉免登授权失败')))
|
||||
})
|
||||
})
|
||||
window.dd.error(() => finish(() => reject(new Error('钉钉环境校验失败'))))
|
||||
})
|
||||
|
||||
const startLogin = async () => {
|
||||
errorText.value = ''
|
||||
statusText.value = '正在确认钉钉身份...'
|
||||
const requestedPath = sanitizeRedirectPath(route.query.redirect || route.query.redirectPath)
|
||||
|
||||
try {
|
||||
if (!/DingTalk/i.test(navigator.userAgent)) {
|
||||
throw new Error('请从钉钉工作通知或固定资产微应用中打开此页面')
|
||||
}
|
||||
const configResponse = await getDingTalkPublicConfig()
|
||||
const config = configResponse.data
|
||||
const clientId = config?.clientId || config?.client_id
|
||||
const corpId = config?.corpId || config?.corp_id
|
||||
if (!config?.configured || !clientId || !corpId) throw new Error('固定资产钉钉应用尚未配置完整')
|
||||
|
||||
await loadDingTalkSdk()
|
||||
const code = await requestAuthCode(clientId, corpId)
|
||||
statusText.value = '身份已确认,正在进入系统...'
|
||||
const loginResponse = await loginWithDingTalk({ code, redirectPath: requestedPath })
|
||||
const payload = loginResponse.data
|
||||
if (!payload?.user) throw new Error('当前钉钉账号未绑定可用的系统账号')
|
||||
|
||||
authStore.setToken('session')
|
||||
authStore.setUser(payload.user)
|
||||
const targetPath = sanitizeRedirectPath(payload.redirectPath || payload.redirect_path || requestedPath)
|
||||
await router.replace(targetPath)
|
||||
} catch (error: any) {
|
||||
errorText.value = error?.message || '钉钉免登失败,请联系管理员检查员工绑定'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(startLogin)
|
||||
</script>
|
||||
@ -2,7 +2,7 @@
|
||||
<div class="space-y-6">
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
v-if="canViewReports && isLoading"
|
||||
class="flex items-center justify-center py-20"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-3 text-text-secondary">
|
||||
@ -14,16 +14,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<template v-else-if="canViewReports">
|
||||
<!-- 页面头部 -->
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">Overview</p>
|
||||
<h2 class="page-title">资产运行总览</h2>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<router-link to="/borrow/scan" class="btn-primary text-sm">扫码出库</router-link>
|
||||
<router-link to="/return/scan" class="btn-secondary text-sm">扫码入库</router-link>
|
||||
<router-link v-if="isAdmin" to="/equipment" class="btn-secondary text-sm">资产管理</router-link>
|
||||
<router-link v-if="canBorrow" to="/borrow/scan" class="btn-primary text-sm">扫码出库</router-link>
|
||||
<router-link v-if="canBorrow" to="/return/scan" class="btn-secondary text-sm">扫码入库</router-link>
|
||||
<router-link v-if="canViewAllAssets" to="/equipment" class="btn-secondary text-sm">资产管理</router-link>
|
||||
<router-link v-if="canApproveDisposals" to="/workflows/disposals?scope=pending" class="btn-secondary text-sm">待我审批</router-link>
|
||||
<router-link v-if="canMaintain" to="/maintenance" class="btn-secondary text-sm">维修管理</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -33,6 +35,11 @@
|
||||
<p class="page-meta__value">{{ formatNumber(stats?.today_borrow || 0) }}</p>
|
||||
<p class="page-meta__subvalue">{{ formatCurrencyNoFraction(stats?.today_borrow_value || 0) }}</p>
|
||||
</div>
|
||||
<div v-if="canApproveDisposals" class="page-meta">
|
||||
<p class="page-meta__label">待我审批</p>
|
||||
<p class="page-meta__value text-status-warning">{{ pendingDisposals }}</p>
|
||||
<p class="page-meta__subvalue">报废申请</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">今日归还</p>
|
||||
<p class="page-meta__value">{{ formatNumber(stats?.today_return || 0) }}</p>
|
||||
@ -125,7 +132,7 @@
|
||||
<section class="card lg:col-span-2">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">最近借用记录</h3>
|
||||
<router-link to="/borrow" class="btn-secondary text-sm">查看全部</router-link>
|
||||
<router-link v-if="canBorrow" to="/borrow" class="btn-secondary text-sm">查看全部</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 移动端:卡片列表 -->
|
||||
@ -247,6 +254,50 @@
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">My Assets</p>
|
||||
<h2 class="page-title">我的资产</h2>
|
||||
<router-link to="/my-assets" class="btn-primary mt-3 text-sm">查看全部</router-link>
|
||||
</div>
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">当前未归还</p>
|
||||
<p class="page-meta__value">{{ myAssets.length }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">员工绑定</p>
|
||||
<p class="page-meta__value text-base">{{ authStore.user?.person_bound ? '已绑定' : '未绑定' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">正在使用</h3>
|
||||
<span class="badge badge-neutral">只读</span>
|
||||
</div>
|
||||
<p v-if="myAssetsLoading" class="empty-state">正在加载...</p>
|
||||
<p v-else-if="myAssetsError" class="text-sm text-status-error">{{ myAssetsError }}</p>
|
||||
<p v-else-if="myAssets.length === 0" class="empty-state">当前没有未归还资产。</p>
|
||||
<div v-else class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<article v-for="record in myAssets.slice(0, 6)" :key="record.borrow_record_id" class="panel">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-text-primary">{{ record.equipment.equipment_name }}</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ record.equipment.equipment_code }}</p>
|
||||
</div>
|
||||
<span class="badge badge-warning">使用中</span>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-text-secondary">
|
||||
预计归还:{{ record.is_long_term ? '长期使用' : record.expected_return_time ? formatDateTime(record.expected_return_time) : '未设置' }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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<BorrowRecord[]>([])
|
||||
const statusChartRef = ref<HTMLElement>()
|
||||
const categoryChartRef = ref<HTMLElement>()
|
||||
const isLoading = ref(false)
|
||||
const pendingDisposals = ref(0)
|
||||
const myAssets = ref<MyAssetRecord[]>([])
|
||||
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(() => {
|
||||
|
||||
@ -4,12 +4,12 @@
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">Field Entry</p>
|
||||
<h2 class="page-title">{{ isEdit ? '编辑资产' : '新增资产' }}</h2>
|
||||
<h2 class="page-title">{{ isEdit ? (canEditBasic ? '资产资料纠错' : '资产详情') : '新增资产' }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="isEdit" class="flex gap-3">
|
||||
<button @click="handleDelete" class="btn-danger text-sm">删除资产</button>
|
||||
<button @click="handlePrintBarcode" class="btn-secondary btn-secondary--highlight text-sm">打印标签</button>
|
||||
<button v-if="canDelete" @click="handleDelete" class="btn-danger text-sm">删除资产</button>
|
||||
<button v-if="canPrintLabels" @click="handlePrintBarcode" class="btn-secondary btn-secondary--highlight text-sm">打印标签</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
<h3 class="section-title mb-5">基础信息</h3>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<fieldset :disabled="isEdit && !canEditBasic" class="space-y-5">
|
||||
<div class="grid gap-3 md:hidden">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="panel">
|
||||
@ -39,7 +40,7 @@
|
||||
<div class="field-stack">
|
||||
<label class="field-label">资产编码 *</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input v-model="form.equipment_code" type="text" class="input min-w-0 flex-1" :readonly="isEdit" required />
|
||||
<input v-model="form.equipment_code" type="text" class="input min-w-0 flex-1" :readonly="isEdit && !canEditEquipmentCode" required />
|
||||
<div v-if="!isEdit" class="flex flex-col gap-2 sm:flex-row">
|
||||
<button type="button" class="btn-secondary text-sm w-full sm:w-auto" @click="handleGenerateCode">重新生成</button>
|
||||
<button type="button" class="btn-secondary text-sm w-full sm:w-auto" @click="showCodeScanner = true">扫码填入</button>
|
||||
@ -70,7 +71,7 @@
|
||||
|
||||
<div class="field-stack">
|
||||
<label class="field-label">使用部门</label>
|
||||
<select v-model="form.department_id" class="select">
|
||||
<select v-model="form.department_id" class="select" :disabled="isEdit || !canTransfer">
|
||||
<option value="">请选择使用部门</option>
|
||||
<template v-for="department in departments" :key="department.id">
|
||||
<option :value="department.id">{{ department.name }}</option>
|
||||
@ -125,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 子资产录入区域 -->
|
||||
<div v-if="showAccessorySection" class="border-t border-border pt-5">
|
||||
<div v-if="showAccessorySection && canMount" class="border-t border-border pt-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="section-title">子资产列表</h4>
|
||||
@ -241,10 +242,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="mobile-sticky-actions md:static">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:justify-end">
|
||||
<button type="submit" class="btn-primary w-full md:w-auto" :disabled="loading">
|
||||
<button v-if="canEditBasic" type="submit" class="btn-primary w-full md:w-auto" :disabled="loading">
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
<button type="button" @click="handleCancel" class="btn-secondary w-full md:w-auto">取消</button>
|
||||
@ -254,20 +257,20 @@
|
||||
</section>
|
||||
|
||||
<!-- 挂载管理(仅编辑模式) -->
|
||||
<section v-if="isEdit && canBeParent" class="card">
|
||||
<section v-if="isEdit && canBeParent && canMount" class="card">
|
||||
<MountManager :parent-id="Number(route.params.id)" @updated="fetchEquipmentData" />
|
||||
</section>
|
||||
|
||||
<!-- 挂载历史(仅编辑模式) -->
|
||||
<section v-if="isEdit" class="card">
|
||||
<section v-if="isEdit && canMount" class="card">
|
||||
<MountHistory :equipment-id="Number(route.params.id)" />
|
||||
</section>
|
||||
|
||||
<!-- 维修记录(仅编辑模式) -->
|
||||
<section v-if="isEdit" class="card">
|
||||
<section v-if="isEdit && canMaintain" id="maintenance-section" class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">维修记录</h3>
|
||||
<button type="button" class="btn-secondary text-sm" @click="showMaintenanceForm = true">+ 新增维修</button>
|
||||
<button v-if="canStartMaintenance" type="button" class="btn-secondary text-sm" @click="showMaintenanceForm = true">+ 新增维修</button>
|
||||
</div>
|
||||
<div v-if="maintenanceRecords.length === 0" class="empty-state">暂无维修记录</div>
|
||||
<div v-else class="space-y-3">
|
||||
@ -289,10 +292,10 @@
|
||||
</section>
|
||||
|
||||
<!-- 报废记录(仅编辑模式) -->
|
||||
<section v-if="isEdit" class="card">
|
||||
<section v-if="isEdit && canViewDisposals" id="disposal-section" class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">报废记录</h3>
|
||||
<button v-if="currentEquipment && currentEquipment.status !== '已报废' && currentEquipment.status !== '待报废'" type="button" class="btn-secondary text-sm text-status-error" @click="showDisposalForm = true">申请报废</button>
|
||||
<button v-if="canStartDisposal" type="button" class="btn-secondary text-sm text-status-error" @click="openDisposalForm">申请报废</button>
|
||||
</div>
|
||||
<div v-if="disposalRecords.length === 0" class="empty-state">暂无报废记录</div>
|
||||
<div v-else class="space-y-3">
|
||||
@ -327,7 +330,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 维修申请弹窗 -->
|
||||
<div v-if="showMaintenanceForm" class="sheet-backdrop">
|
||||
<div v-if="showMaintenanceForm && canMaintain" class="sheet-backdrop">
|
||||
<div class="sheet-panel max-w-2xl">
|
||||
<h3 class="text-xl font-semibold text-text-primary mb-4">新增维修记录</h3>
|
||||
<div class="space-y-4">
|
||||
@ -358,10 +361,37 @@
|
||||
</div>
|
||||
|
||||
<!-- 报废申请弹窗 -->
|
||||
<div v-if="showDisposalForm" class="sheet-backdrop">
|
||||
<div v-if="showDisposalForm && canApplyDisposal" class="sheet-backdrop">
|
||||
<div class="sheet-panel max-w-2xl">
|
||||
<h3 class="text-xl font-semibold text-text-primary mb-4">申请报废</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">本次报废资产 *</label>
|
||||
<div class="space-y-2 rounded-lg border border-border bg-bg-elevated p-3">
|
||||
<label
|
||||
v-for="item in disposalTreeItems"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-bg-hover"
|
||||
:style="{ paddingLeft: `${12 + item.depth * 20}px` }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="rounded"
|
||||
:checked="selectedDisposalItemIds.includes(item.id)"
|
||||
:disabled="item.isRoot"
|
||||
@change="toggleDisposalItem(item.id)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm text-text-primary">{{ item.name }}</span>
|
||||
<span class="mt-1 block font-mono text-xs text-text-tertiary">{{ item.code }}</span>
|
||||
</span>
|
||||
<span v-if="item.isRoot" class="badge badge-neutral">主资产</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="unselectedMountedDisposalItems.length" class="text-xs text-status-error">
|
||||
仍有 {{ unselectedMountedDisposalItems.length }} 个挂载配件未勾选。若不随主机报废,请先取消并手工卸载,再重新提交。
|
||||
</p>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">报废原因 *</label>
|
||||
<textarea v-model="disposalForm.disposal_reason" class="input" rows="3" placeholder="请说明报废原因"></textarea>
|
||||
@ -385,7 +415,7 @@
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button class="btn-secondary" @click="showDisposalForm = false">取消</button>
|
||||
<button class="btn-danger" @click="submitDisposal" :disabled="!disposalForm.disposal_reason">提交申请</button>
|
||||
<button class="btn-danger" @click="submitDisposal" :disabled="!canSubmitDisposal">提交申请</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -395,12 +425,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { PERMISSIONS } from '@/auth/permissions'
|
||||
import { createEquipment, updateEquipment, getEquipmentDetail, generateEquipmentCode, deleteEquipment, type EquipmentCreatePayload, type EquipmentUpdatePayload } from '@/api/equipment'
|
||||
import { getStorageLocationOptions } from '@/api/storageLocation'
|
||||
import { getAssetTypeOptions } from '@/api/assetType'
|
||||
import { getDepartmentOptions } from '@/api/department'
|
||||
import { getMaintenanceList, createMaintenance } from '@/api/maintenance'
|
||||
import { getDisposalList, createDisposal } from '@/api/disposal'
|
||||
import { getEquipmentDisposalHistory, createDisposal } from '@/api/disposal'
|
||||
import type { Equipment, StorageLocation, AssetType, Department, CodeGenerationMode, EquipmentStatus } from '@/types'
|
||||
import { formatCurrency, formatDateTime } from '@/utils/format'
|
||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||
@ -453,6 +485,7 @@ const getTodayDateString = () => {
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const loading = ref(false)
|
||||
const bundleChildren = ref<BundleChildDraft[]>([])
|
||||
@ -473,6 +506,49 @@ const showMaintenanceForm = ref(false)
|
||||
const showDisposalForm = ref(false)
|
||||
const showBarcodePrint = ref(false)
|
||||
const currentEquipment = ref<Equipment | null>(null)
|
||||
const selectedDisposalItemIds = ref<number[]>([])
|
||||
|
||||
interface DisposalTreeItem {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
depth: number
|
||||
isRoot: boolean
|
||||
}
|
||||
|
||||
const canCreate = computed(() => authStore.can(PERMISSIONS.ASSETS_CREATE))
|
||||
const canCorrect = computed(() => authStore.can(PERMISSIONS.ASSETS_CORRECT))
|
||||
const canDelete = computed(() => authStore.can(PERMISSIONS.ASSETS_DELETE))
|
||||
const canTransfer = computed(() => authStore.can(PERMISSIONS.ASSETS_TRANSFER))
|
||||
const canMount = computed(() => authStore.can(PERMISSIONS.ASSETS_MOUNT))
|
||||
const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE))
|
||||
const canApplyDisposal = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY))
|
||||
const canViewDisposals = computed(() => authStore.canAny([
|
||||
PERMISSIONS.DISPOSALS_APPLY,
|
||||
PERMISSIONS.DISPOSALS_APPROVE,
|
||||
PERMISSIONS.DISPOSALS_COMPLETE
|
||||
]))
|
||||
const canPrintLabels = computed(() => canCreate.value || canCorrect.value)
|
||||
const canOverrideCorrectionRestrictions = computed(() => authStore.can(PERMISSIONS.SYSTEM_SETTINGS))
|
||||
const hasActiveMaintenance = computed(() => maintenanceRecords.value.some((record) => record.status === 'in_progress'))
|
||||
const hasActiveDisposal = computed(() => disposalRecords.value.some((record) => record.status === 'pending' || record.status === 'approved'))
|
||||
const canEditBasic = computed(() => {
|
||||
if (!isEdit.value) return canCreate.value
|
||||
if (canOverrideCorrectionRestrictions.value) return canCorrect.value
|
||||
return canCorrect.value && currentEquipment.value?.status === '可用' && !hasActiveMaintenance.value && !hasActiveDisposal.value
|
||||
})
|
||||
const canEditEquipmentCode = computed(() => {
|
||||
if (!isEdit.value) return true
|
||||
return canEditBasic.value && currentEquipment.value?.can_correct_code === true
|
||||
})
|
||||
const canStartMaintenance = computed(() => {
|
||||
if (!canMaintain.value || !currentEquipment.value || currentEquipment.value.parent_id) return false
|
||||
return !['已借出', '维护中', '待报废', '已报废'].includes(currentEquipment.value.status)
|
||||
})
|
||||
const canStartDisposal = computed(() => {
|
||||
if (!canApplyDisposal.value || !currentEquipment.value || currentEquipment.value.parent_id) return false
|
||||
return !['已借出', '维护中', '待报废', '已报废'].includes(currentEquipment.value.status)
|
||||
})
|
||||
|
||||
const maintenanceForm = ref({ issue_description: '', repair_vendor: '', estimated_cost: 0, expected_end_date: '' })
|
||||
const disposalForm = ref({ disposal_reason: '', disposal_method: '报废', estimated_value: 0, notes: '' })
|
||||
@ -496,6 +572,31 @@ const createDefaultFormState = (): EquipmentFormState => ({
|
||||
|
||||
const form = ref<EquipmentFormState>(createDefaultFormState())
|
||||
|
||||
const disposalTreeItems = computed<DisposalTreeItem[]>(() => {
|
||||
if (!currentEquipment.value) return []
|
||||
const items: DisposalTreeItem[] = []
|
||||
const walk = (equipment: any, depth: number, isRoot = false) => {
|
||||
items.push({
|
||||
id: Number(equipment.id),
|
||||
code: equipment.equipment_code || '-',
|
||||
name: equipment.equipment_name || equipment.equipment_type || '未命名资产',
|
||||
depth,
|
||||
isRoot
|
||||
})
|
||||
;(equipment.children || []).forEach((child: any) => walk(child, depth + 1, false))
|
||||
}
|
||||
walk(currentEquipment.value, 0, true)
|
||||
return items
|
||||
})
|
||||
const unselectedMountedDisposalItems = computed(() =>
|
||||
disposalTreeItems.value.filter((item) => !item.isRoot && !selectedDisposalItemIds.value.includes(item.id))
|
||||
)
|
||||
const canSubmitDisposal = computed(() => Boolean(
|
||||
disposalForm.value.disposal_reason.trim() &&
|
||||
disposalTreeItems.value.length > 0 &&
|
||||
unselectedMountedDisposalItems.value.length === 0
|
||||
))
|
||||
|
||||
const buildBundleChildDraft = (draft?: Partial<Pick<BundleChildDraft, 'equipment_name' | 'equipment_type'>>): BundleChildDraft => {
|
||||
bundleChildSeed += 1
|
||||
return {
|
||||
@ -740,7 +841,7 @@ const fetchEquipmentData = async () => {
|
||||
}
|
||||
|
||||
const fetchMaintenanceRecords = async () => {
|
||||
if (!isEdit.value) return
|
||||
if (!isEdit.value || !canMaintain.value) return
|
||||
try {
|
||||
const response = await getMaintenanceList({ equipment_id: Number(route.params.id), page: 1, pageSize: 100 })
|
||||
if (response.data) maintenanceRecords.value = response.data.list || []
|
||||
@ -748,14 +849,15 @@ const fetchMaintenanceRecords = async () => {
|
||||
}
|
||||
|
||||
const fetchDisposalRecords = async () => {
|
||||
if (!isEdit.value) return
|
||||
if (!isEdit.value || !canViewDisposals.value) return
|
||||
try {
|
||||
const response = await getDisposalList({ equipment_id: Number(route.params.id), page: 1, pageSize: 100 })
|
||||
if (response.data) disposalRecords.value = response.data.list || []
|
||||
const response = await getEquipmentDisposalHistory(Number(route.params.id))
|
||||
disposalRecords.value = response.data?.data || []
|
||||
} catch (error) { console.error('获取报废记录失败:', error) }
|
||||
}
|
||||
|
||||
const submitMaintenance = async () => {
|
||||
if (!canStartMaintenance.value) return
|
||||
try {
|
||||
await createMaintenance({ equipment_id: Number(route.params.id), ...maintenanceForm.value })
|
||||
showMaintenanceForm.value = false
|
||||
@ -765,14 +867,35 @@ const submitMaintenance = async () => {
|
||||
} catch (error: any) { alert(error?.message || '添加维修记录失败') }
|
||||
}
|
||||
|
||||
const openDisposalForm = () => {
|
||||
if (!canStartDisposal.value || !currentEquipment.value) return
|
||||
selectedDisposalItemIds.value = [currentEquipment.value.id]
|
||||
showDisposalForm.value = true
|
||||
}
|
||||
|
||||
const toggleDisposalItem = (id: number) => {
|
||||
if (currentEquipment.value?.id === id) return
|
||||
selectedDisposalItemIds.value = selectedDisposalItemIds.value.includes(id)
|
||||
? selectedDisposalItemIds.value.filter((itemId) => itemId !== id)
|
||||
: [...selectedDisposalItemIds.value, id]
|
||||
}
|
||||
|
||||
const submitDisposal = async () => {
|
||||
if (!canSubmitDisposal.value) return
|
||||
try {
|
||||
await createDisposal({ equipment_id: Number(route.params.id), ...disposalForm.value })
|
||||
const response = await createDisposal({
|
||||
equipment_id: Number(route.params.id),
|
||||
item_ids: selectedDisposalItemIds.value,
|
||||
...disposalForm.value
|
||||
})
|
||||
showDisposalForm.value = false
|
||||
disposalForm.value = { disposal_reason: '', disposal_method: '报废', estimated_value: 0, notes: '' }
|
||||
selectedDisposalItemIds.value = []
|
||||
await fetchEquipmentData()
|
||||
await fetchDisposalRecords()
|
||||
alert('报废申请已提交')
|
||||
const disposalId = response.data?.data?.id
|
||||
if (disposalId) await router.push(`/workflows/disposals/${disposalId}`)
|
||||
} catch (error: any) { alert(error?.message || '提交报废申请失败') }
|
||||
}
|
||||
|
||||
@ -787,6 +910,7 @@ const getDisposalStatusText = (status: string) => {
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canEditBasic.value) return
|
||||
if (!isEdit.value && parentPriceOverflow.value) {
|
||||
alert('当前子资产合计已经超过整机总价,请先调整价格后再保存')
|
||||
return
|
||||
@ -796,7 +920,17 @@ const handleSubmit = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const updatePayload: EquipmentUpdatePayload = {
|
||||
...form.value
|
||||
equipment_code: form.value.equipment_code,
|
||||
equipment_name: form.value.equipment_name,
|
||||
equipment_model: form.value.equipment_model,
|
||||
equipment_type: form.value.equipment_type,
|
||||
code_generation_mode: form.value.code_generation_mode,
|
||||
unit_price: form.value.unit_price,
|
||||
total_quantity: form.value.total_quantity,
|
||||
purchase_date: form.value.purchase_date,
|
||||
supplier: form.value.supplier,
|
||||
description: form.value.description,
|
||||
storage_location_id: form.value.storage_location_id
|
||||
}
|
||||
await updateEquipment(Number(route.params.id), updatePayload)
|
||||
alert('资产已更新')
|
||||
@ -900,12 +1034,21 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadStorageLocations()
|
||||
loadAssetTypes()
|
||||
loadDepartments()
|
||||
fetchEquipmentData()
|
||||
fetchMaintenanceRecords()
|
||||
fetchDisposalRecords()
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadStorageLocations(),
|
||||
loadAssetTypes(),
|
||||
loadDepartments(),
|
||||
fetchEquipmentData(),
|
||||
fetchMaintenanceRecords(),
|
||||
fetchDisposalRecords()
|
||||
])
|
||||
|
||||
if (route.query.action === 'maintenance' && canStartMaintenance.value) {
|
||||
showMaintenanceForm.value = true
|
||||
}
|
||||
if (route.query.action === 'disposal' && canStartDisposal.value) {
|
||||
openDisposalForm()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<p class="page-kicker">Asset Control</p>
|
||||
<h2 class="page-title">资产管理中心</h2>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button @click="handleAdd" class="btn-primary text-sm">新增资产</button>
|
||||
<button v-if="canCreate" @click="handleAdd" class="btn-primary text-sm">新增资产</button>
|
||||
<button @click="showAssetScanner = true" class="btn-secondary text-sm">扫码找资产</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -74,7 +74,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="relative">
|
||||
<div v-if="canExport || canImport" class="relative">
|
||||
<button @click="showMoreActions = !showMoreActions" class="btn-icon h-10 w-10" title="更多操作">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
@ -85,6 +85,7 @@
|
||||
class="absolute right-0 top-full z-20 mt-2 w-44 rounded-xl border border-border bg-bg-secondary p-2 shadow-[0_18px_44px_rgba(0,0,0,0.36)]"
|
||||
>
|
||||
<button
|
||||
v-if="canExport"
|
||||
type="button"
|
||||
class="w-full rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isExporting"
|
||||
@ -92,10 +93,10 @@
|
||||
>
|
||||
{{ isExporting ? '正在导出...' : '导出 Excel' }}
|
||||
</button>
|
||||
<button class="w-full rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary" @click="downloadTemplateAndClose">
|
||||
<button v-if="canImport" class="w-full rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary" @click="downloadTemplateAndClose">
|
||||
下载模板
|
||||
</button>
|
||||
<label class="mt-1 flex w-full cursor-pointer rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary">
|
||||
<label v-if="canImport" class="mt-1 flex w-full cursor-pointer rounded-lg px-3 py-2 text-left text-sm text-text-secondary transition hover:bg-bg-hover hover:text-text-primary">
|
||||
批量导入
|
||||
<input type="file" accept=".csv,.xlsx,.xls" class="hidden" @click="showMoreActions = false" @change="handleFileImport" />
|
||||
</label>
|
||||
@ -160,15 +161,15 @@
|
||||
</section>
|
||||
|
||||
<!-- 使用操作区 - 页面级核心工作区 -->
|
||||
<section class="card card-elevated border-accent-primary/20">
|
||||
<section v-if="canSelectAssets" class="card card-elevated border-accent-primary/20">
|
||||
<div class="flex items-center gap-3 border-b border-border pb-3 mb-4">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-accent-primary/15">
|
||||
<svg class="h-4 w-4 text-accent-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-text-primary">使用操作区</h3>
|
||||
<span class="badge badge-neutral">批量借还</span>
|
||||
<h3 class="text-base font-semibold text-text-primary">批量操作</h3>
|
||||
<span class="badge badge-neutral">按权限显示</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态:未选择资产 -->
|
||||
@ -178,8 +179,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-text-secondary">请在下方资产列表中选择需要借出或归还的资产</p>
|
||||
<p class="mt-1 text-xs text-text-tertiary">支持多选批量操作,也可点击单行「借出/归还」快捷操作</p>
|
||||
<p class="mt-3 text-sm text-text-secondary">请在下方资产列表中选择需要处理的资产</p>
|
||||
<p class="mt-1 text-xs text-text-tertiary">可执行操作会按当前账号权限显示。</p>
|
||||
</div>
|
||||
|
||||
<!-- 已选择状态:展示选中资产摘要和操作按钮 -->
|
||||
@ -211,7 +212,7 @@
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<button @click="openBatchBorrowModal" class="btn-primary">
|
||||
<button v-if="canBorrow" @click="openBatchBorrowModal" class="btn-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
@ -219,7 +220,7 @@
|
||||
批量借出
|
||||
</span>
|
||||
</button>
|
||||
<button @click="openBatchReturnModal" class="btn-secondary btn-secondary--highlight">
|
||||
<button v-if="canBorrow" @click="openBatchReturnModal" class="btn-secondary btn-secondary--highlight">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
@ -227,7 +228,7 @@
|
||||
批量归还
|
||||
</span>
|
||||
</button>
|
||||
<button @click="showBatchTransferModal = true" class="btn-secondary">
|
||||
<button v-if="canTransfer" @click="openBatchTransferModal" class="btn-secondary">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
@ -235,7 +236,7 @@
|
||||
批量转移
|
||||
</span>
|
||||
</button>
|
||||
<button @click="handleBatchPrintBarcode" class="btn-secondary">
|
||||
<button v-if="canPrintLabels" @click="handleBatchPrintBarcode" class="btn-secondary">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4h2v-4zM8 12h2v4H8v-4zm10-4h2v4h-2V8zM6 8h2v4H6V8zM16 4h2v4h-2V4zM8 4h2v4H8V4z" />
|
||||
@ -260,7 +261,7 @@
|
||||
<article v-for="equipment in displayList" :key="'mobile-' + equipment.id" class="mobile-card">
|
||||
<div class="mobile-card__row">
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" :checked="selectedIds.includes(equipment.id)" @change="toggleSelect(equipment.id)" class="rounded" />
|
||||
<input v-if="canSelectAssets" type="checkbox" :checked="selectedIds.includes(equipment.id)" @change="toggleSelect(equipment.id)" class="rounded" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-text-primary">{{ equipment.equipment_name }}</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ equipment.equipment_code }}</p>
|
||||
@ -297,25 +298,26 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button v-if="canManageMountAsset(equipment)" @click="toggleExpand(equipment.id)" class="btn-secondary text-xs">
|
||||
{{ expandedIds.has(equipment.id) ? '收起配件' : hasMountedChildren(equipment) ? '管理配件(' + (equipment.children?.length || 0) + ')' : '管理配件' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canHandleBorrowAnomaly(equipment)"
|
||||
@click="openBorrowAnomalyHandlerForAsset(equipment)"
|
||||
class="btn-secondary text-xs"
|
||||
>
|
||||
异常处理
|
||||
</button>
|
||||
<button v-if="canBorrowSingle(equipment)" @click="openSingleBorrowModal(equipment)" class="btn-primary text-xs">
|
||||
借出
|
||||
</button>
|
||||
<button v-if="canReturnSingle(equipment)" @click="openSingleReturnAction(equipment)" class="btn-primary text-xs">
|
||||
归还
|
||||
</button>
|
||||
<button @click="handleEdit(equipment.id)" class="btn-secondary text-xs">编辑</button>
|
||||
<button @click="handlePrintBarcode(equipment.id)" class="btn-secondary btn-secondary--highlight text-xs">标签</button>
|
||||
<button @click="handleDelete(equipment.id)" class="btn-secondary text-xs !text-status-error">删除</button>
|
||||
<details class="relative">
|
||||
<summary class="btn-secondary cursor-pointer list-none text-xs">更多操作</summary>
|
||||
<div class="absolute right-0 z-20 mt-2 grid w-36 gap-1 rounded-lg border border-border bg-bg-secondary p-2 shadow-[0_18px_44px_rgba(0,0,0,0.36)]">
|
||||
<button v-if="canManageMountAsset(equipment)" @click="toggleExpand(equipment.id)" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">
|
||||
{{ expandedIds.has(equipment.id) ? '收起配件' : '管理配件' }}
|
||||
</button>
|
||||
<button v-if="canHandleBorrowAnomaly(equipment)" @click="openBorrowAnomalyHandlerForAsset(equipment)" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">异常处理</button>
|
||||
<button v-if="canMaintainAsset(equipment)" @click="openEquipmentAction(equipment.id, 'maintenance')" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">发起维修</button>
|
||||
<button v-if="canDisposeAsset(equipment)" @click="openEquipmentAction(equipment.id, 'disposal')" class="rounded-lg px-3 py-2 text-left text-xs text-status-error hover:bg-bg-hover">申请报废</button>
|
||||
<button @click="handleEdit(equipment.id)" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">{{ canCorrect ? '纠错资料' : '查看详情' }}</button>
|
||||
<button v-if="canPrintLabels" @click="handlePrintBarcode(equipment.id)" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">打印标签</button>
|
||||
<button v-if="canDelete" @click="handleDelete(equipment.id)" class="rounded-lg px-3 py-2 text-left text-xs text-status-error hover:bg-bg-hover">删除</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedIds.has(equipment.id) && canManageMountAsset(equipment)" class="mt-3 space-y-3 border-t border-border pt-3">
|
||||
@ -338,7 +340,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-14">
|
||||
<input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" class="rounded" />
|
||||
<input v-if="canSelectAssets" type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" class="rounded" />
|
||||
</th>
|
||||
<th>资产信息</th>
|
||||
<th>使用信息</th>
|
||||
@ -351,7 +353,7 @@
|
||||
<tr class="text-text-primary text-sm transition" :class="{ 'bg-status-success/5': expandedIds.has(equipment.id), 'cursor-pointer': canManageMountAsset(equipment) }" @click="canManageMountAsset(equipment) && toggleExpand(equipment.id)">
|
||||
<td class="align-top">
|
||||
<div class="flex items-start gap-3 pt-1">
|
||||
<input type="checkbox" :checked="selectedIds.includes(equipment.id)" @change="toggleSelect(equipment.id)" @click.stop class="rounded" />
|
||||
<input v-if="canSelectAssets" type="checkbox" :checked="selectedIds.includes(equipment.id)" @change="toggleSelect(equipment.id)" @click.stop class="rounded" />
|
||||
<button v-if="canManageMountAsset(equipment)" type="button" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-elevated text-text-secondary transition hover:bg-bg-hover hover:text-text-primary">
|
||||
<ChevronRightIcon class="h-4 w-4 transition-transform" :class="{ 'rotate-90': expandedIds.has(equipment.id) }" />
|
||||
</button>
|
||||
@ -364,7 +366,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="truncate text-sm font-semibold text-text-primary">{{ equipment.equipment_name }}</p>
|
||||
<span v-if="canManageMountAsset(equipment)" class="badge badge-success">父资产</span>
|
||||
<span v-if="isParentAsset(equipment)" class="badge badge-success">父资产</span>
|
||||
<span v-else class="badge badge-neutral">独立资产</span>
|
||||
<span v-if="hasMountedChildren(equipment)" class="badge badge-neutral">{{ equipment.children?.length || 0 }} 个配件</span>
|
||||
</div>
|
||||
@ -411,9 +413,11 @@
|
||||
<button v-if="canReturnSingle(equipment)" @click.stop="openSingleReturnAction(equipment)" class="table-action table-action--primary">
|
||||
归还
|
||||
</button>
|
||||
<button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">编辑</button>
|
||||
<button @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button>
|
||||
<button @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button>
|
||||
<button v-if="canMaintainAsset(equipment)" @click.stop="openEquipmentAction(equipment.id, 'maintenance')" class="table-action">维修</button>
|
||||
<button v-if="canDisposeAsset(equipment)" @click.stop="openEquipmentAction(equipment.id, 'disposal')" class="table-action table-action--danger">报废</button>
|
||||
<button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">{{ canCorrect ? '纠错' : '详情' }}</button>
|
||||
<button v-if="canPrintLabels" @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button>
|
||||
<button v-if="canDelete" @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -473,7 +477,7 @@
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">当前资产</p>
|
||||
<p class="mt-2 text-sm font-medium text-text-primary">{{ singleActionContext.equipment.equipment_name }}</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ singleActionContext.equipment.equipment_code }}</p>
|
||||
<p class="mt-2 text-xs text-text-secondary">仅借出当前资产,不会连带挂载配件一起操作。</p>
|
||||
<p class="mt-2 text-xs text-text-secondary">借出主资产时,当前已挂载配件会随整机同步为借出;独立资产仅处理自身。</p>
|
||||
</div>
|
||||
|
||||
<!-- 多资产摘要 -->
|
||||
@ -782,17 +786,37 @@
|
||||
<!-- 批量转移弹窗 -->
|
||||
<div v-if="showBatchTransferModal" class="sheet-backdrop">
|
||||
<div class="sheet-panel max-w-lg">
|
||||
<h3 class="text-xl font-semibold text-text-primary mb-4">批量转移部门 ({{ selectedIds.length }}项)</h3>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">目标部门 *</label>
|
||||
<select v-model="batchTransferForm.department_id" class="select">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="d in departments" :key="d.id" :value="String(d.id)">{{ d.name }}</option>
|
||||
</select>
|
||||
<h3 class="text-xl font-semibold text-text-primary mb-4">资产调拨 ({{ selectedIds.length }}项)</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">调拨目标 *</label>
|
||||
<select v-model="batchTransferForm.target_type" class="select">
|
||||
<option value="department">调拨至部门</option>
|
||||
<option value="idle">转为仓库闲置</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="batchTransferForm.target_type === 'department'" class="field-stack">
|
||||
<label class="field-label">目标部门 *</label>
|
||||
<select v-model="batchTransferForm.department_id" class="select">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="d in departments" :key="d.id" :value="String(d.id)">{{ d.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else class="panel">
|
||||
<p class="text-sm text-text-primary">转闲置后资产保持“可用”,清空部门,并保留当前存放位置。</p>
|
||||
<p v-if="selectedBorrowedCount" class="mt-2 text-xs text-status-error">选中项中有 {{ selectedBorrowedCount }} 项仍在借用,请先完成归还。</p>
|
||||
</div>
|
||||
<p v-if="selectedTransferBlockedCount" class="text-xs text-status-error">
|
||||
选中项中有 {{ selectedTransferBlockedCount }} 项处于维修、待报废或已报废状态,不能调拨。
|
||||
</p>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">调拨原因 *</label>
|
||||
<textarea v-model.trim="batchTransferForm.reason" class="input" rows="3" placeholder="请填写本次调拨或转闲置原因"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button class="btn-secondary" @click="showBatchTransferModal = false">取消</button>
|
||||
<button class="btn-primary" @click="submitBatchTransfer" :disabled="!batchTransferForm.department_id">确认转移</button>
|
||||
<button class="btn-primary" @click="submitBatchTransfer" :disabled="!canSubmitTransfer">确认调拨</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -810,6 +834,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { PERMISSIONS } from '@/auth/permissions'
|
||||
import { ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { getEquipmentList, deleteEquipment, scanEquipment } from '@/api/equipment'
|
||||
@ -822,7 +848,7 @@ import { scanBorrow, scanReturn, getBorrowRecordByCode, searchReturnAssets } fro
|
||||
import { exportEquipment, downloadFile } from '@/api/export'
|
||||
import { batchImportEquipments } from '@/api/batchImport'
|
||||
import { formatCurrencyNoFraction, formatNumber, formatDateTime } from '@/utils/format'
|
||||
import { canHandleBorrowAnomaly } from '@/utils/borrowAnomaly'
|
||||
import { canHandleBorrowAnomaly as hasBorrowAnomaly } from '@/utils/borrowAnomaly'
|
||||
import BarcodePrint from '@/components/BarcodePrint.vue'
|
||||
import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
|
||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||
@ -848,6 +874,19 @@ import {
|
||||
} from '@/views/borrow/manualReturnRecordSearch'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const canCreate = computed(() => authStore.can(PERMISSIONS.ASSETS_CREATE))
|
||||
const canImport = computed(() => authStore.can(PERMISSIONS.ASSETS_IMPORT))
|
||||
const canCorrect = computed(() => authStore.can(PERMISSIONS.ASSETS_CORRECT))
|
||||
const canDelete = computed(() => authStore.can(PERMISSIONS.ASSETS_DELETE))
|
||||
const canBorrow = computed(() => authStore.can(PERMISSIONS.ASSETS_BORROW))
|
||||
const canTransfer = computed(() => authStore.can(PERMISSIONS.ASSETS_TRANSFER))
|
||||
const canMount = computed(() => authStore.can(PERMISSIONS.ASSETS_MOUNT))
|
||||
const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE))
|
||||
const canApplyDisposal = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY))
|
||||
const canExport = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW))
|
||||
const canPrintLabels = computed(() => canCreate.value || canCorrect.value)
|
||||
const canSelectAssets = computed(() => canBorrow.value || canTransfer.value || canPrintLabels.value)
|
||||
const list = ref<Equipment[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
@ -875,7 +914,7 @@ const filters = ref({
|
||||
const pageSummary = computed(() => {
|
||||
const available = list.value.filter(e => e.status === '可用').length
|
||||
const borrowed = list.value.filter(e => e.status === '已借出').length
|
||||
const parentAssets = list.value.filter(canManageMountAsset).length
|
||||
const parentAssets = list.value.filter(isParentAsset).length
|
||||
const childAssets = list.value.reduce((sum, e) => sum + (e.children?.length || 0), 0)
|
||||
return { available, borrowed, parentAssets, childAssets }
|
||||
})
|
||||
@ -954,7 +993,18 @@ const returnChildrenDiffInfo = ref<{
|
||||
added: Array<Record<string, any>>
|
||||
} | null>(null)
|
||||
|
||||
const batchTransferForm = ref({ department_id: '' })
|
||||
const batchTransferForm = ref({ target_type: 'department' as 'department' | 'idle', department_id: '', reason: '' })
|
||||
const mountedChildIds = computed(() => new Set(list.value.flatMap((equipment) => (equipment.children || []).map((child) => child.id))))
|
||||
const selectedBorrowedCount = computed(() => list.value.filter((equipment) => selectedIds.value.includes(equipment.id) && equipment.status === '已借出').length)
|
||||
const selectedTransferBlockedCount = computed(() => list.value.filter((equipment) =>
|
||||
selectedIds.value.includes(equipment.id) && ['维护中', '待报废', '已报废'].includes(equipment.status)
|
||||
).length)
|
||||
const canSubmitTransfer = computed(() => Boolean(
|
||||
batchTransferForm.value.reason.trim() &&
|
||||
(batchTransferForm.value.target_type === 'idle' || batchTransferForm.value.department_id) &&
|
||||
!(batchTransferForm.value.target_type === 'idle' && selectedBorrowedCount.value > 0) &&
|
||||
selectedTransferBlockedCount.value === 0
|
||||
))
|
||||
const singleActionContext = ref<{
|
||||
mode: 'borrow' | 'return'
|
||||
previousSelectedIds: number[]
|
||||
@ -1371,6 +1421,9 @@ const handleReset = () => {
|
||||
|
||||
const handleAdd = () => router.push('/equipment/add')
|
||||
const handleEdit = (id: number) => router.push(`/equipment/edit/${id}`)
|
||||
const openEquipmentAction = (id: number, action: 'maintenance' | 'disposal') => {
|
||||
router.push({ path: `/equipment/edit/${id}`, query: { action } })
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('确定要删除这条资产记录吗?')) {
|
||||
@ -1755,23 +1808,45 @@ const submitBatchReturn = async () => {
|
||||
}
|
||||
|
||||
const submitBatchTransfer = async () => {
|
||||
const res = await batchTransfer({
|
||||
equipment_ids: selectedIds.value,
|
||||
department_id: Number(batchTransferForm.value.department_id)
|
||||
})
|
||||
alert(`批量转移完成:成功 ${res.data?.success_count || 0} 项,失败 ${res.data?.failed_count || 0} 项`)
|
||||
showBatchTransferModal.value = false
|
||||
selectedIds.value = []
|
||||
fetchData()
|
||||
if (!canSubmitTransfer.value) return
|
||||
try {
|
||||
const res = await batchTransfer({
|
||||
equipment_ids: selectedIds.value,
|
||||
target_type: batchTransferForm.value.target_type,
|
||||
department_id: batchTransferForm.value.target_type === 'department' ? Number(batchTransferForm.value.department_id) : undefined,
|
||||
reason: batchTransferForm.value.reason.trim()
|
||||
})
|
||||
alert(`批量调拨完成:成功 ${res.data?.success_count || 0} 项,失败 ${res.data?.failed_count || 0} 项`)
|
||||
showBatchTransferModal.value = false
|
||||
batchTransferForm.value = { target_type: 'department', department_id: '', reason: '' }
|
||||
selectedIds.value = []
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '资产调拨失败')
|
||||
}
|
||||
}
|
||||
|
||||
function canManageMountAsset(equipment: Equipment) {
|
||||
const openBatchTransferModal = () => {
|
||||
const selectedMountedChild = selectedIds.value.find((id) => mountedChildIds.value.has(id))
|
||||
if (selectedMountedChild) {
|
||||
alert('挂载中的配件不能单独调拨,请选择主资产,系统会同步整棵资产树。')
|
||||
return
|
||||
}
|
||||
batchTransferForm.value = { target_type: 'department', department_id: '', reason: '' }
|
||||
showBatchTransferModal.value = true
|
||||
}
|
||||
|
||||
function isParentAsset(equipment: Equipment) {
|
||||
return Boolean(
|
||||
assetTypes.value.some((assetType) => assetType.can_be_parent && assetType.name === equipment.equipment_type) ||
|
||||
equipment.children?.length
|
||||
)
|
||||
}
|
||||
|
||||
function canManageMountAsset(equipment: Equipment) {
|
||||
return canMount.value && isParentAsset(equipment)
|
||||
}
|
||||
|
||||
function hasMountedChildren(equipment: Equipment) {
|
||||
return Boolean(equipment.children && equipment.children.length > 0)
|
||||
}
|
||||
@ -1846,7 +1921,7 @@ const getStatusBadgeClass = (status: string) => {
|
||||
}
|
||||
|
||||
const canBorrowSingle = (equipment: Equipment) => {
|
||||
return equipment.status === '可用' && equipment.remaining_quantity > 0
|
||||
return canBorrow.value && equipment.status === '可用' && equipment.remaining_quantity > 0
|
||||
}
|
||||
|
||||
const canQuickReturnInline = (equipment: Equipment) => {
|
||||
@ -1858,11 +1933,17 @@ const canQuickReturnInline = (equipment: Equipment) => {
|
||||
}
|
||||
|
||||
const canReturnSingle = (equipment: Equipment) => {
|
||||
return equipment.status === '已借出' &&
|
||||
return canBorrow.value && equipment.status === '已借出' &&
|
||||
equipment.current_borrow !== null &&
|
||||
!canHandleBorrowAnomaly(equipment)
|
||||
}
|
||||
|
||||
const canHandleBorrowAnomaly = (equipment: Equipment) => canBorrow.value && hasBorrowAnomaly(equipment)
|
||||
|
||||
const canMaintainAsset = (equipment: Equipment) => canMaintain.value && !equipment.parent_id && !['已借出', '维护中', '待报废', '已报废'].includes(equipment.status)
|
||||
|
||||
const canDisposeAsset = (equipment: Equipment) => canApplyDisposal.value && !equipment.parent_id && !['已借出', '维护中', '待报废', '已报废'].includes(equipment.status)
|
||||
|
||||
const openSingleBorrowModal = (equipment: Equipment) => {
|
||||
startSingleAction('borrow', equipment)
|
||||
resetBorrowForm()
|
||||
|
||||
7
frontend/src/views/maintenance/MaintenanceList.vue
Normal file
7
frontend/src/views/maintenance/MaintenanceList.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<MaintenanceManagement />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MaintenanceManagement from '@/views/settings/components/MaintenanceManagement.vue'
|
||||
</script>
|
||||
@ -239,7 +239,7 @@
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="getChangeAssets(change).length > 0"
|
||||
v-if="canBorrow && getChangeAssets(change).length > 0"
|
||||
class="mt-4 rounded-xl border border-border bg-bg-primary/40 p-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
@ -292,7 +292,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="order-1 xl:order-2">
|
||||
<aside v-if="canBorrow" class="order-1 xl:order-2">
|
||||
<div class="panel space-y-4 xl:sticky xl:top-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@ -388,51 +388,8 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field-stack">
|
||||
<label class="field-label">部门归属处理</label>
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:class="quickReturnForm.department_action === 'keep' ? '!border-accent-primary !text-text-primary' : ''"
|
||||
@click="quickReturnForm.department_action = 'keep'"
|
||||
>
|
||||
保留当前归属
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:class="quickReturnForm.department_action === 'clear' ? '!border-accent-primary !text-text-primary' : ''"
|
||||
@click="quickReturnForm.department_action = 'clear'"
|
||||
>
|
||||
清空归属
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:class="quickReturnForm.department_action === 'set' ? '!border-accent-primary !text-text-primary' : ''"
|
||||
@click="quickReturnForm.department_action = 'set'"
|
||||
>
|
||||
改为指定部门
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
v-if="quickReturnForm.department_action === 'set'"
|
||||
v-model="quickReturnForm.department_id"
|
||||
class="select"
|
||||
>
|
||||
<option :value="null">请选择业务部门</option>
|
||||
<option
|
||||
v-for="department in activeDepartments"
|
||||
:key="department.id"
|
||||
:value="department.id"
|
||||
>
|
||||
{{ department.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-xs text-text-tertiary">
|
||||
有些资产归还后需要保留部门归属,有些则应清空,这里可以直接一起处理。
|
||||
</p>
|
||||
<div class="panel text-sm text-text-secondary">
|
||||
全部归还后自动清空部门;多数量资产仍有在借数量时保留当前部门。
|
||||
</div>
|
||||
|
||||
<div class="field-stack">
|
||||
@ -486,7 +443,6 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type {
|
||||
BorrowRecord,
|
||||
Department,
|
||||
HrSyncBorrowedAssetPreview,
|
||||
HrSyncChangeItem,
|
||||
HrSyncChangeProcessingStatus,
|
||||
@ -496,7 +452,6 @@ import type {
|
||||
StorageLocation
|
||||
} from '@/types'
|
||||
import { getBorrowRecordByCode, scanReturn } from '@/api/borrow'
|
||||
import { getDepartmentOptions } from '@/api/department'
|
||||
import {
|
||||
getNotificationList,
|
||||
markAllNotificationsAsRead,
|
||||
@ -512,15 +467,18 @@ import {
|
||||
} from '@/views/borrow/manualReturnRecordSearch'
|
||||
import { canSubmitReturnScan } from '@/views/borrow/returnScanState'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { PERMISSIONS } from '@/auth/permissions'
|
||||
import {
|
||||
buildNotificationQuickReturnAssetKey,
|
||||
findNotificationQuickReturnRecord
|
||||
} from './notificationQuickReturn'
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
const authStore = useAuthStore()
|
||||
const canBorrow = computed(() => authStore.can(PERMISSIONS.ASSETS_BORROW))
|
||||
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const activeDepartments = ref<Department[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 12
|
||||
@ -548,8 +506,6 @@ const quickReturnRecordKeyword = ref('')
|
||||
const quickReturnSelectedBorrowRecordId = ref<number | null>(null)
|
||||
const quickReturnForm = ref({
|
||||
return_condition: '正常',
|
||||
department_action: 'keep' as 'keep' | 'clear' | 'set',
|
||||
department_id: null as number | null,
|
||||
notes: ''
|
||||
})
|
||||
const quickReturnNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
|
||||
@ -604,12 +560,10 @@ const showQuickReturnRecordResults = computed(() =>
|
||||
)
|
||||
|
||||
const canSubmitQuickReturn = computed(() =>
|
||||
canSubmitReturnScan({
|
||||
canBorrow.value && canSubmitReturnScan({
|
||||
hasBorrowRecordInfo: Boolean(quickReturnBorrowInfo.value),
|
||||
selectedBorrowRecordId: quickReturnSelectedBorrowRecordId.value,
|
||||
loading:
|
||||
quickReturnSubmitting.value ||
|
||||
(quickReturnForm.value.department_action === 'set' && !quickReturnForm.value.department_id)
|
||||
loading: quickReturnSubmitting.value
|
||||
})
|
||||
)
|
||||
|
||||
@ -800,17 +754,6 @@ const handleMarkAllRead = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadDepartmentOptions = async () => {
|
||||
try {
|
||||
const response = await getDepartmentOptions()
|
||||
activeDepartments.value = (response.data || [])
|
||||
.filter((department) => department.status === 'active')
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||
} catch (error) {
|
||||
console.error('获取业务部门失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closeSyncDetail = () => {
|
||||
syncDetailOpen.value = false
|
||||
selectedSyncJob.value = null
|
||||
@ -824,8 +767,6 @@ const resetQuickReturnForm = () => {
|
||||
quickReturnSelectedBorrowRecordId.value = null
|
||||
quickReturnForm.value = {
|
||||
return_condition: '正常',
|
||||
department_action: 'keep',
|
||||
department_id: null,
|
||||
notes: ''
|
||||
}
|
||||
}
|
||||
@ -878,6 +819,7 @@ const handleQuickReturnRecordKeywordSubmit = () => {
|
||||
}
|
||||
|
||||
const openQuickReturn = async (asset: HrSyncBorrowedAssetPreview) => {
|
||||
if (!canBorrow.value) return
|
||||
if (isQuickReturnAssetProcessed(asset)) {
|
||||
return
|
||||
}
|
||||
@ -927,6 +869,7 @@ const openQuickReturn = async (asset: HrSyncBorrowedAssetPreview) => {
|
||||
}
|
||||
|
||||
const submitQuickReturn = async () => {
|
||||
if (!canBorrow.value) return
|
||||
if (!quickReturnBorrowInfo.value || !quickReturnAsset.value) {
|
||||
return
|
||||
}
|
||||
@ -940,25 +883,12 @@ const submitQuickReturn = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (quickReturnForm.value.department_action === 'set' && !quickReturnForm.value.department_id) {
|
||||
quickReturnNotice.value = {
|
||||
type: 'warning',
|
||||
message: '请选择归还后要归属到的业务部门。'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
equipment_code: quickReturnBorrowInfo.value.equipment_code,
|
||||
return_condition: quickReturnForm.value.return_condition,
|
||||
notes: quickReturnForm.value.notes || undefined,
|
||||
person_id: record.person_id,
|
||||
borrow_record_id: record.id,
|
||||
department_action: quickReturnForm.value.department_action,
|
||||
department_id:
|
||||
quickReturnForm.value.department_action === 'set'
|
||||
? quickReturnForm.value.department_id
|
||||
: undefined
|
||||
borrow_record_id: record.id
|
||||
}
|
||||
|
||||
quickReturnSubmitting.value = true
|
||||
@ -1050,17 +980,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => quickReturnForm.value.department_action,
|
||||
(value) => {
|
||||
if (value !== 'set') {
|
||||
quickReturnForm.value.department_id = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDepartmentOptions()
|
||||
await notificationStore.fetchUnreadCount()
|
||||
await fetchNotifications()
|
||||
})
|
||||
|
||||
@ -28,8 +28,7 @@
|
||||
<StorageLocationManagement v-else-if="activeTab === 'locations'" />
|
||||
<AssetTypeManagement v-else-if="activeTab === 'assetTypes'" />
|
||||
<DepartmentManagement v-else-if="activeTab === 'departments'" />
|
||||
<MaintenanceManagement v-else-if="activeTab === 'maintenance'" />
|
||||
<DisposalManagement v-else-if="activeTab === 'disposal'" />
|
||||
<DingTalkSettings v-else-if="activeTab === 'dingtalk'" />
|
||||
<PrintTerminalManagement v-else-if="activeTab === 'printTerminals'" />
|
||||
<PrintJobManagement v-else-if="activeTab === 'printJobs'" />
|
||||
<OperationLogList v-else-if="activeTab === 'logs'" />
|
||||
@ -47,8 +46,7 @@ import PersonManagement from './components/PersonManagement.vue'
|
||||
import StorageLocationManagement from './components/StorageLocationManagement.vue'
|
||||
import AssetTypeManagement from './components/AssetTypeManagement.vue'
|
||||
import DepartmentManagement from './components/DepartmentManagement.vue'
|
||||
import MaintenanceManagement from './components/MaintenanceManagement.vue'
|
||||
import DisposalManagement from './components/DisposalManagement.vue'
|
||||
import DingTalkSettings from './components/DingTalkSettings.vue'
|
||||
import OperationLogList from './components/OperationLogList.vue'
|
||||
import PrintTerminalManagement from './components/PrintTerminalManagement.vue'
|
||||
import PrintJobManagement from './components/PrintJobManagement.vue'
|
||||
@ -61,8 +59,7 @@ type TabKey =
|
||||
| 'locations'
|
||||
| 'assetTypes'
|
||||
| 'departments'
|
||||
| 'maintenance'
|
||||
| 'disposal'
|
||||
| 'dingtalk'
|
||||
| 'printTerminals'
|
||||
| 'printJobs'
|
||||
| 'logs'
|
||||
@ -75,8 +72,7 @@ const tabs: Array<{ key: TabKey; label: string }> = [
|
||||
{ key: 'locations', label: '地址管理' },
|
||||
{ key: 'assetTypes', label: '资产类型' },
|
||||
{ key: 'departments', label: '组织架构' },
|
||||
{ key: 'maintenance', label: '维修管理' },
|
||||
{ key: 'disposal', label: '报废管理' },
|
||||
{ key: 'dingtalk', label: '钉钉通知' },
|
||||
{ key: 'printTerminals', label: '打印终端' },
|
||||
{ key: 'printJobs', label: '打印任务' },
|
||||
{ key: 'logs', label: '操作日志' }
|
||||
@ -125,6 +121,14 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.tab === 'maintenance') {
|
||||
router.replace('/maintenance')
|
||||
return
|
||||
}
|
||||
if (route.query.tab === 'disposal') {
|
||||
router.replace('/workflows/disposals')
|
||||
return
|
||||
}
|
||||
scrollActiveTabIntoView('auto')
|
||||
})
|
||||
</script>
|
||||
|
||||
185
frontend/src/views/settings/components/DingTalkSettings.vue
Normal file
185
frontend/src/views/settings/components/DingTalkSettings.vue
Normal file
@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">DingTalk</p>
|
||||
<h2 class="page-title">钉钉通知</h2>
|
||||
</div>
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">应用配置</p>
|
||||
<p class="page-meta__value text-base" :class="status?.configured ? 'text-status-success' : 'text-status-error'">
|
||||
{{ status?.configured ? '完整' : '不完整' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">待发送</p>
|
||||
<p class="page-meta__value">{{ status?.pending || 0 }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">发送开关</p>
|
||||
<p class="page-meta__value text-base" :class="status?.enabled ? 'text-status-success' : 'text-status-warning'">
|
||||
{{ status?.enabled ? '开启' : '关闭' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">失败</p>
|
||||
<p class="page-meta__value text-status-error">{{ status?.failed || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="status?.error_summary?.length" class="card">
|
||||
<h3 class="section-title">错误摘要</h3>
|
||||
<div class="mt-4 space-y-2">
|
||||
<p v-for="(message, index) in status.error_summary" :key="index" class="panel text-sm text-status-error">{{ message }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">投递记录</h3>
|
||||
<button type="button" class="btn-secondary text-sm" :disabled="loading" @click="loadData">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar mb-4">
|
||||
<div class="filter-bar__inputs">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">投递状态</label>
|
||||
<select v-model="filterStatus" class="select" @change="loadDeliveries">
|
||||
<option value="">全部</option>
|
||||
<option value="pending">待发送</option>
|
||||
<option value="sending">发送中</option>
|
||||
<option value="sent">已发送</option>
|
||||
<option value="read">已读</option>
|
||||
<option value="unread">未读</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-list md:hidden">
|
||||
<article v-for="item in deliveries" :key="item.id" class="mobile-card">
|
||||
<div class="mobile-card__row">
|
||||
<p class="text-sm font-medium text-text-primary">{{ item.event_type || item.event || '业务通知' }}</p>
|
||||
<span class="badge" :class="getDeliveryBadgeClass(item.status)">{{ getDeliveryStatusText(item.status) }}</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-xs text-text-secondary">
|
||||
<p>收件人:{{ getRecipientText(item) }}</p>
|
||||
<p>业务单:{{ item.business_type || '-' }} #{{ item.business_id || '-' }}</p>
|
||||
<p>尝试次数:{{ item.attempt_count ?? item.attempts ?? 0 }}</p>
|
||||
<p v-if="item.last_error" class="text-status-error">{{ item.last_error }}</p>
|
||||
</div>
|
||||
<button v-if="item.status === 'failed'" type="button" class="btn-secondary mt-3 w-full text-sm" @click="retry(item)">手工补发</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block">
|
||||
<div class="table-container">
|
||||
<table class="data-table min-w-[900px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>事件</th>
|
||||
<th>业务单</th>
|
||||
<th>收件人</th>
|
||||
<th>状态</th>
|
||||
<th>尝试</th>
|
||||
<th>错误摘要</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in deliveries" :key="item.id">
|
||||
<td class="text-sm text-text-primary">{{ item.event_type || item.event || '-' }}</td>
|
||||
<td class="font-mono text-xs text-text-secondary">{{ item.business_type || '-' }} #{{ item.business_id || '-' }}</td>
|
||||
<td class="text-sm text-text-secondary">{{ getRecipientText(item) }}</td>
|
||||
<td><span class="badge" :class="getDeliveryBadgeClass(item.status)">{{ getDeliveryStatusText(item.status) }}</span></td>
|
||||
<td class="text-sm text-text-secondary">{{ item.attempt_count ?? item.attempts ?? 0 }}</td>
|
||||
<td class="max-w-[280px]"><p class="truncate text-xs text-status-error">{{ item.last_error || '-' }}</p></td>
|
||||
<td class="text-right">
|
||||
<button v-if="item.status === 'failed'" type="button" class="table-action table-action--primary" @click="retry(item)">补发</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && deliveries.length === 0">
|
||||
<td colspan="7" class="py-8 text-center text-text-tertiary">暂无投递记录。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import {
|
||||
getDingTalkDeliveries,
|
||||
getDingTalkDeliveryStatus,
|
||||
retryDingTalkDelivery,
|
||||
type DingTalkDeliveryStatus
|
||||
} from '@/api/dingtalk'
|
||||
|
||||
const status = ref<DingTalkDeliveryStatus | null>(null)
|
||||
const deliveries = ref<any[]>([])
|
||||
const filterStatus = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const loadStatus = async () => {
|
||||
const response = await getDingTalkDeliveryStatus()
|
||||
status.value = response.data || null
|
||||
}
|
||||
|
||||
const loadDeliveries = async () => {
|
||||
const response = await getDingTalkDeliveries({ page: 1, pageSize: 100, status: filterStatus.value || undefined })
|
||||
deliveries.value = response.data?.list || response.data?.items || []
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([loadStatus(), loadDeliveries()])
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '钉钉投递状态加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const retry = async (item: any) => {
|
||||
if (!confirm('确定重新投递这条钉钉通知吗?')) return
|
||||
try {
|
||||
await retryDingTalkDelivery(Number(item.id))
|
||||
await loadData()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '补发失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getRecipientText = (item: any) =>
|
||||
item.recipient?.name ||
|
||||
item.recipient?.username ||
|
||||
item.recipient_name ||
|
||||
item.recipient_userid ||
|
||||
item.recipient_id ||
|
||||
'-'
|
||||
|
||||
const getDeliveryBadgeClass = (value?: string) => {
|
||||
if (value === 'failed') return 'badge-error'
|
||||
if (value === 'sent' || value === 'read') return 'badge-success'
|
||||
if (value === 'pending' || value === 'sending' || value === 'unread') return 'badge-warning'
|
||||
return 'badge-neutral'
|
||||
}
|
||||
|
||||
const getDeliveryStatusText = (value?: string) => ({
|
||||
pending: '待发送',
|
||||
sending: '发送中',
|
||||
sent: '已发送',
|
||||
read: '已读',
|
||||
unread: '未读',
|
||||
failed: '失败'
|
||||
}[value || ''] || value || '-')
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -4,7 +4,7 @@
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">Disposal</p>
|
||||
<h2 class="page-title">报废管理</h2>
|
||||
<h2 class="page-title">报废流程</h2>
|
||||
</div>
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
@ -26,6 +26,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card !p-3">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="segmented-toggle min-w-max" style="padding: 2px;">
|
||||
<button
|
||||
v-for="scope in availableScopes"
|
||||
:key="scope.value"
|
||||
type="button"
|
||||
class="segmented-toggle__item"
|
||||
style="min-height: 32px; padding: 0 12px; font-size: 12px;"
|
||||
:class="{ 'is-active': activeScope === scope.value }"
|
||||
@click="changeScope(scope.value)"
|
||||
>
|
||||
{{ scope.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<section class="card">
|
||||
<div class="filter-bar">
|
||||
@ -111,22 +129,23 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button class="btn-secondary text-xs sm:text-sm" @click="openDetail(item)">查看详情</button>
|
||||
<button
|
||||
v-if="item.status === 'pending'"
|
||||
v-if="item.status === 'pending' && canApprove"
|
||||
class="btn-primary text-xs sm:text-sm"
|
||||
@click="handleApprove(item, true)"
|
||||
>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'pending'"
|
||||
v-if="item.status === 'pending' && canApprove"
|
||||
class="btn-secondary text-xs sm:text-sm !text-status-error"
|
||||
@click="handleApprove(item, false)"
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'approved'"
|
||||
v-if="item.status === 'approved' && canComplete"
|
||||
class="btn-secondary text-xs sm:text-sm"
|
||||
@click="handleComplete(item)"
|
||||
>
|
||||
@ -178,22 +197,23 @@
|
||||
<td class="text-sm text-text-secondary">{{ formatDate(item.applied_at) }}</td>
|
||||
<td class="text-right">
|
||||
<div class="table-action-group justify-end">
|
||||
<button class="table-action" @click="openDetail(item)">详情</button>
|
||||
<button
|
||||
v-if="item.status === 'pending'"
|
||||
v-if="item.status === 'pending' && canApprove"
|
||||
class="table-action table-action--primary"
|
||||
@click="handleApprove(item, true)"
|
||||
>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'pending'"
|
||||
v-if="item.status === 'pending' && canApprove"
|
||||
class="table-action table-action--danger"
|
||||
@click="handleApprove(item, false)"
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'approved'"
|
||||
v-if="item.status === 'approved' && canComplete"
|
||||
class="table-action table-action--warning"
|
||||
@click="handleComplete(item)"
|
||||
>
|
||||
@ -242,9 +262,9 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 field-stack">
|
||||
<label class="field-label">备注</label>
|
||||
<label class="field-label">完成说明 *</label>
|
||||
<textarea
|
||||
v-model.trim="completeForm.notes"
|
||||
v-model.trim="completeForm.completion_notes"
|
||||
class="input"
|
||||
rows="4"
|
||||
placeholder="例如:已转交回收商,回收凭证已登记"
|
||||
@ -253,7 +273,7 @@
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button class="btn-secondary" @click="showCompleteModal = false">取消</button>
|
||||
<button class="btn-primary" :disabled="submittingComplete" @click="submitComplete">
|
||||
<button class="btn-primary" :disabled="submittingComplete || !completeForm.completion_notes" @click="submitComplete">
|
||||
{{ submittingComplete ? '提交中...' : '确认完成' }}
|
||||
</button>
|
||||
</div>
|
||||
@ -264,7 +284,27 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getDisposalList, approveDisposal, completeDisposal, type DisposalRecord } from '@/api/disposal'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { PERMISSIONS } from '@/auth/permissions'
|
||||
|
||||
type DisposalScope = 'pending' | 'mine' | 'processed' | 'all'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const canApprove = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPROVE))
|
||||
const canComplete = computed(() => authStore.can(PERMISSIONS.DISPOSALS_COMPLETE))
|
||||
const canApply = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY))
|
||||
const availableScopes = computed<Array<{ value: DisposalScope; label: string }>>(() => {
|
||||
const scopes: Array<{ value: DisposalScope; label: string }> = []
|
||||
if (canApprove.value) scopes.push({ value: 'pending', label: '待我审批' })
|
||||
if (canApply.value) scopes.push({ value: 'mine', label: '我发起的' })
|
||||
if (canApprove.value || canComplete.value) scopes.push({ value: 'processed', label: '已处理' })
|
||||
if (authStore.can(PERMISSIONS.SYSTEM_SETTINGS)) scopes.push({ value: 'all', label: '全部' })
|
||||
return scopes
|
||||
})
|
||||
|
||||
const list = ref<DisposalRecord[]>([])
|
||||
const total = ref(0)
|
||||
@ -274,8 +314,9 @@ const loading = ref(false)
|
||||
const filters = ref({ status: '', keyword: '' })
|
||||
const showCompleteModal = ref(false)
|
||||
const currentItem = ref<DisposalRecord | null>(null)
|
||||
const completeForm = ref({ actual_value: 0, notes: '' })
|
||||
const completeForm = ref({ actual_value: 0, completion_notes: '' })
|
||||
const submittingComplete = ref(false)
|
||||
const activeScope = ref<DisposalScope>('pending')
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@ -290,7 +331,7 @@ const pageSummary = computed(() => ({
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDisposalList({ page: page.value, pageSize: pageSize.value, ...filters.value })
|
||||
const res = await getDisposalList({ page: page.value, pageSize: pageSize.value, scope: activeScope.value, ...filters.value })
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (e) {
|
||||
@ -319,33 +360,48 @@ const resetFilters = () => {
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
const changeScope = (scope: DisposalScope) => {
|
||||
activeScope.value = scope
|
||||
page.value = 1
|
||||
router.replace({ query: { ...route.query, scope } })
|
||||
loadList()
|
||||
}
|
||||
|
||||
const openDetail = (item: DisposalRecord) => router.push(`/workflows/disposals/${item.id}`)
|
||||
|
||||
const handleApprove = async (item: DisposalRecord, approved: boolean) => {
|
||||
const msg = approved ? '确定通过此报废申请吗?' : '确定驳回此报废申请吗?'
|
||||
if (!confirm(msg)) return
|
||||
try {
|
||||
const rejectReason = approved ? undefined : window.prompt('可填写驳回原因,方便后续重新提交时参考。', '') || undefined
|
||||
const rejectReason = approved ? undefined : window.prompt('请填写驳回原因。', '')?.trim() || undefined
|
||||
if (!approved && !rejectReason) {
|
||||
alert('驳回原因不能为空')
|
||||
return
|
||||
}
|
||||
await approveDisposal(item.id, { approved, reject_reason: rejectReason })
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '申请状态已变化,请刷新后重试')
|
||||
await loadList()
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = (item: DisposalRecord) => {
|
||||
currentItem.value = item
|
||||
completeForm.value = { actual_value: Number(item.estimated_value || 0), notes: item.notes || '' }
|
||||
completeForm.value = { actual_value: Number(item.estimated_value || 0), completion_notes: item.completion_notes || '' }
|
||||
showCompleteModal.value = true
|
||||
}
|
||||
|
||||
const submitComplete = async () => {
|
||||
if (!currentItem.value) return
|
||||
if (!currentItem.value || !completeForm.value.completion_notes.trim()) return
|
||||
try {
|
||||
submittingComplete.value = true
|
||||
await completeDisposal(currentItem.value.id, completeForm.value)
|
||||
showCompleteModal.value = false
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '申请状态已变化,请刷新后重试')
|
||||
await loadList()
|
||||
} finally {
|
||||
submittingComplete.value = false
|
||||
}
|
||||
@ -390,5 +446,12 @@ const formatDate = (date: string) => {
|
||||
|
||||
const formatCurrency = (value: number) => `¥${Number(value || 0).toFixed(2)}`
|
||||
|
||||
onMounted(loadList)
|
||||
onMounted(() => {
|
||||
const requestedScope = route.query.scope as DisposalScope | undefined
|
||||
const allowedScopes = availableScopes.value.map((scope) => scope.value)
|
||||
activeScope.value = requestedScope && allowedScopes.includes(requestedScope)
|
||||
? requestedScope
|
||||
: allowedScopes[0] || 'mine'
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -25,9 +25,9 @@
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
class="badge"
|
||||
:class="user.role === 'admin' ? 'badge-warning' : 'badge-neutral'"
|
||||
:class="getUserRoleBadgeClass(user.role)"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
{{ getRoleLabel(user.role) }}
|
||||
</span>
|
||||
<span
|
||||
class="badge"
|
||||
@ -40,6 +40,7 @@
|
||||
<div class="mt-3 text-sm text-text-secondary space-y-1">
|
||||
<p v-if="user.email">{{ user.email }}</p>
|
||||
<p v-if="user.phone">{{ user.phone }}</p>
|
||||
<p>员工绑定: {{ getBoundPersonLabel(user) }}</p>
|
||||
<p>最近登录: {{ user.last_login_at ? formatDateTime(user.last_login_at) : '-' }}</p>
|
||||
</div>
|
||||
<div class="mt-3 table-action-group">
|
||||
@ -63,6 +64,7 @@
|
||||
<th>姓名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>绑定员工</th>
|
||||
<th>联系方式</th>
|
||||
<th>最近登录</th>
|
||||
<th class="text-right">操作</th>
|
||||
@ -75,9 +77,9 @@
|
||||
<td>
|
||||
<span
|
||||
class="badge"
|
||||
:class="user.role === 'admin' ? 'badge-warning' : 'badge-neutral'"
|
||||
:class="getUserRoleBadgeClass(user.role)"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
{{ getRoleLabel(user.role) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@ -88,6 +90,7 @@
|
||||
{{ user.status === 'active' ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm text-text-secondary">{{ getBoundPersonLabel(user) }}</td>
|
||||
<td>
|
||||
<div class="text-sm text-text-secondary space-y-1">
|
||||
<p v-if="user.email">{{ user.email }}</p>
|
||||
@ -185,9 +188,21 @@
|
||||
<label class="field-label">角色 *</label>
|
||||
<select v-model="newUser.role" class="select">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="administrative">行政</option>
|
||||
<option value="it">IT</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">绑定员工</label>
|
||||
<select v-model="newUser.person_id" class="select">
|
||||
<option value="">暂不绑定</option>
|
||||
<option v-for="person in personOptions" :key="person.id" :value="String(person.id)">
|
||||
{{ formatPersonOption(person) }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">仅管理员手工绑定,不会按姓名自动关联。</p>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">邮箱</label>
|
||||
<input
|
||||
@ -234,9 +249,20 @@
|
||||
<label class="field-label">角色</label>
|
||||
<select v-model="editingUserDraft.role" class="select">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="administrative">行政</option>
|
||||
<option value="it">IT</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">绑定员工</label>
|
||||
<select v-model="editingUserDraft.person_id" class="select">
|
||||
<option value="">解除绑定</option>
|
||||
<option v-for="person in personOptions" :key="person.id" :value="String(person.id)">
|
||||
{{ formatPersonOption(person) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">邮箱</label>
|
||||
@ -269,7 +295,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import type { User } from '@/types'
|
||||
import type { Person, User, UserRole } from '@/types'
|
||||
import {
|
||||
createUser,
|
||||
getUserList,
|
||||
@ -279,8 +305,11 @@ import {
|
||||
deleteUser
|
||||
} from '@/api/users'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { getPersonOptions } from '@/api/person'
|
||||
import { getRoleLabel } from '@/auth/permissions'
|
||||
|
||||
const users = ref<(User & { status: 'active' | 'inactive'; last_login_at?: string | null })[]>([])
|
||||
const personOptions = ref<Person[]>([])
|
||||
const listLoading = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const updateLoading = ref(false)
|
||||
@ -295,7 +324,8 @@ const newUser = reactive({
|
||||
password: '',
|
||||
role: 'user',
|
||||
email: '',
|
||||
phone: ''
|
||||
phone: '',
|
||||
person_id: ''
|
||||
})
|
||||
|
||||
const editingUser = ref<User | null>(null)
|
||||
@ -305,7 +335,8 @@ const editingUserDraft = reactive({
|
||||
role: 'user',
|
||||
email: '',
|
||||
phone: '',
|
||||
status: 'active'
|
||||
status: 'active',
|
||||
person_id: ''
|
||||
})
|
||||
|
||||
const canCreate = computed(() => {
|
||||
@ -326,6 +357,15 @@ const fetchUsers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPersonOptions = async () => {
|
||||
try {
|
||||
const response = await getPersonOptions({ includeInactive: true })
|
||||
personOptions.value = response.data || []
|
||||
} catch (error) {
|
||||
console.error('获取员工选项失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resetNewForm = () => {
|
||||
newUser.username = ''
|
||||
newUser.name = ''
|
||||
@ -333,6 +373,7 @@ const resetNewForm = () => {
|
||||
newUser.role = 'user'
|
||||
newUser.email = ''
|
||||
newUser.phone = ''
|
||||
newUser.person_id = ''
|
||||
}
|
||||
|
||||
const openCreate = async () => {
|
||||
@ -383,7 +424,8 @@ const handleCreateUser = async () => {
|
||||
password: newUser.password,
|
||||
role: newUser.role,
|
||||
email: newUser.email || undefined,
|
||||
phone: newUser.phone || undefined
|
||||
phone: newUser.phone || undefined,
|
||||
person_id: newUser.person_id ? Number(newUser.person_id) : null
|
||||
})
|
||||
alert('用户创建成功')
|
||||
showCreateUser.value = false
|
||||
@ -406,6 +448,7 @@ const openEdit = (user: User & { status: 'active' | 'inactive' }) => {
|
||||
editingUserDraft.email = user.email || ''
|
||||
editingUserDraft.phone = user.phone || ''
|
||||
editingUserDraft.status = user.status
|
||||
editingUserDraft.person_id = user.person_id ? String(user.person_id) : ''
|
||||
}
|
||||
|
||||
const closeEdit = () => {
|
||||
@ -421,7 +464,8 @@ const submitEdit = async () => {
|
||||
role: editingUserDraft.role,
|
||||
email: editingUserDraft.email || undefined,
|
||||
phone: editingUserDraft.phone || undefined,
|
||||
status: editingUserDraft.status
|
||||
status: editingUserDraft.status,
|
||||
person_id: editingUserDraft.person_id ? Number(editingUserDraft.person_id) : null
|
||||
})
|
||||
alert('用户信息已更新')
|
||||
closeEdit()
|
||||
@ -470,7 +514,40 @@ const handleDeleteUser = async (user: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getUserRoleBadgeClass = (role: UserRole) => {
|
||||
const classes: Record<UserRole, string> = {
|
||||
admin: 'badge-warning',
|
||||
administrative: 'badge-success',
|
||||
it: 'badge-neutral',
|
||||
user: 'badge-neutral'
|
||||
}
|
||||
return classes[role]
|
||||
}
|
||||
|
||||
const formatPersonOption = (person: Person) => {
|
||||
const name = person.nickname && person.nickname !== person.real_name
|
||||
? `${person.nickname}(${person.real_name})`
|
||||
: person.nickname || person.real_name
|
||||
const jobNumber = person.job_number ? ` · ${person.job_number}` : ''
|
||||
const inactive = person.status === 'inactive' ? ' · 已停用' : ''
|
||||
return `${name}${jobNumber}${inactive}`
|
||||
}
|
||||
|
||||
const getBoundPersonLabel = (user: User) => {
|
||||
if (user.person) {
|
||||
return user.person.nickname && user.person.nickname !== user.person.real_name
|
||||
? `${user.person.nickname}(${user.person.real_name})`
|
||||
: user.person.nickname || user.person.real_name
|
||||
}
|
||||
if (user.person_id) {
|
||||
const person = personOptions.value.find((item) => item.id === user.person_id)
|
||||
return person ? formatPersonOption(person) : `员工 #${user.person_id}`
|
||||
}
|
||||
return '未绑定'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
fetchPersonOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
261
frontend/src/views/workflows/DisposalDetail.vue
Normal file
261
frontend/src/views/workflows/DisposalDetail.vue
Normal file
@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">Disposal Detail</p>
|
||||
<h2 class="page-title">报废申请 #{{ route.params.id }}</h2>
|
||||
<button type="button" class="btn-secondary mt-3 text-sm" @click="router.push('/workflows/disposals')">返回列表</button>
|
||||
</div>
|
||||
<div v-if="record" class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">当前状态</p>
|
||||
<span class="badge mt-2" :class="getStatusBadgeClass(record.status)">{{ getStatusText(record.status) }}</span>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">包含资产</p>
|
||||
<p class="page-meta__value">{{ record.items?.length || 1 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="loading" class="card empty-state">正在加载报废详情...</section>
|
||||
<section v-else-if="errorText" class="card">
|
||||
<p class="text-sm text-status-error">{{ errorText }}</p>
|
||||
<button type="button" class="btn-secondary mt-4" @click="loadDetail">重新加载</button>
|
||||
</section>
|
||||
|
||||
<template v-else-if="record">
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">申请信息</h3>
|
||||
<span class="text-xs text-text-tertiary">{{ formatDateTime(record.applied_at) }}</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="panel">
|
||||
<p class="mobile-card__label">主资产</p>
|
||||
<p class="mt-1 text-sm font-medium text-text-primary">{{ record.equipment?.equipment_name || record.equipment_code }}</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ record.equipment_code }}</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="mobile-card__label">申请人</p>
|
||||
<p class="mt-1 text-sm text-text-primary">{{ record.applier?.name || record.applier?.username || '-' }}</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="mobile-card__label">处置方式</p>
|
||||
<p class="mt-1 text-sm text-text-primary">{{ record.disposal_method }}</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="mobile-card__label">残值估价</p>
|
||||
<p class="mt-1 text-sm text-text-primary">{{ formatCurrency(record.estimated_value) }}</p>
|
||||
</div>
|
||||
<div class="panel md:col-span-2">
|
||||
<p class="mobile-card__label">报废原因</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm text-text-primary">{{ record.disposal_reason }}</p>
|
||||
</div>
|
||||
<div v-if="record.reject_reason" class="panel border-status-error/30 md:col-span-2">
|
||||
<p class="mobile-card__label text-status-error">驳回原因</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm text-text-primary">{{ record.reject_reason }}</p>
|
||||
</div>
|
||||
<div v-if="record.completion_notes" class="panel md:col-span-2">
|
||||
<p class="mobile-card__label">完成说明</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm text-text-primary">{{ record.completion_notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">报废资产明细</h3>
|
||||
<span class="badge badge-neutral">申请后关系已锁定</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(item, index) in disposalItems" :key="item.id || item.equipment_id" class="panel flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="item.is_root || item.parent_equipment_id == null || index === 0" class="badge badge-neutral">主资产</span>
|
||||
<p class="truncate text-sm font-medium text-text-primary">{{ getItemName(item) }}</p>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ item.equipment_code || item.equipment?.equipment_code }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-text-tertiary">申请前状态</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">{{ item.pre_status || item.status_before || record.pre_status || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3 class="section-title">流程记录</h3>
|
||||
<div v-if="record.actions?.length" class="mt-4 space-y-3">
|
||||
<div v-for="action in record.actions" :key="action.id" class="panel">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm font-medium text-text-primary">{{ getActionText(action) }}</p>
|
||||
<p class="text-xs text-text-tertiary">{{ formatDateTime(action.created_at) }}</p>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-text-secondary">
|
||||
{{ action.actor?.name || action.operator?.name || action.actor?.username || action.operator?.username || '系统' }}
|
||||
<span v-if="action.detail || action.reason || action.notes"> · {{ action.detail || action.reason || action.notes }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty-state mt-4">暂无流程动作记录。</p>
|
||||
</section>
|
||||
|
||||
<section v-if="showActions" class="card">
|
||||
<h3 class="section-title">处理申请</h3>
|
||||
<div v-if="record.status === 'pending' && canApprove" class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<button type="button" class="btn-primary" :disabled="submitting" @click="submitApproval(true)">审批通过</button>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">驳回原因</label>
|
||||
<textarea v-model.trim="rejectReason" class="input" rows="3" placeholder="驳回时必须填写原因"></textarea>
|
||||
<button type="button" class="btn-secondary !text-status-error" :disabled="submitting || !rejectReason" @click="submitApproval(false)">确认驳回</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="record.status === 'approved' && canComplete" class="mt-4 space-y-4">
|
||||
<div class="field-grid">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">实际处置价值</label>
|
||||
<input v-model.number="completeForm.actual_value" type="number" min="0" step="0.01" class="input" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">完成说明</label>
|
||||
<textarea v-model.trim="completeForm.completion_notes" class="input" rows="3" placeholder="填写处置结果、凭证等"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn-primary" :disabled="submitting || !completeForm.completion_notes" @click="submitCompletion">完成报废</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
approveDisposal,
|
||||
completeDisposal,
|
||||
getDisposalDetail,
|
||||
type DisposalAction,
|
||||
type DisposalItem,
|
||||
type DisposalRecord
|
||||
} from '@/api/disposal'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { PERMISSIONS } from '@/auth/permissions'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const record = ref<DisposalRecord | null>(null)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const errorText = ref('')
|
||||
const rejectReason = ref('')
|
||||
const completeForm = ref({ actual_value: 0, completion_notes: '' })
|
||||
|
||||
const canApprove = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPROVE))
|
||||
const canComplete = computed(() => authStore.can(PERMISSIONS.DISPOSALS_COMPLETE))
|
||||
const showActions = computed(() =>
|
||||
(record.value?.status === 'pending' && canApprove.value) ||
|
||||
(record.value?.status === 'approved' && canComplete.value)
|
||||
)
|
||||
const disposalItems = computed<DisposalItem[]>(() => {
|
||||
if (record.value?.items?.length) return record.value.items
|
||||
if (!record.value) return []
|
||||
return [{
|
||||
id: record.value.id,
|
||||
equipment_id: record.value.equipment_id,
|
||||
equipment_code: record.value.equipment_code,
|
||||
equipment_name: record.value.equipment?.equipment_name,
|
||||
equipment_type: record.value.equipment?.equipment_type,
|
||||
pre_status: record.value.pre_status,
|
||||
is_root: true
|
||||
}]
|
||||
})
|
||||
|
||||
const loadDetail = async () => {
|
||||
loading.value = true
|
||||
errorText.value = ''
|
||||
try {
|
||||
const response = await getDisposalDetail(Number(route.params.id))
|
||||
record.value = response.data
|
||||
completeForm.value.actual_value = Number(response.data?.actual_value ?? response.data?.estimated_value ?? 0)
|
||||
completeForm.value.completion_notes = response.data?.completion_notes || ''
|
||||
} catch (error: any) {
|
||||
errorText.value = error?.message || '报废详情加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitApproval = async (approved: boolean) => {
|
||||
if (!record.value) return
|
||||
if (!approved && !rejectReason.value) return
|
||||
if (!confirm(approved ? '确定审批通过此报废申请吗?' : '确定驳回此报废申请吗?')) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await approveDisposal(record.value.id, { approved, reject_reason: approved ? undefined : rejectReason.value })
|
||||
await loadDetail()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '申请状态已变化,请刷新后重试')
|
||||
await loadDetail()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitCompletion = async () => {
|
||||
if (!record.value || !completeForm.value.completion_notes.trim() || !confirm('确定所有明细资产均已完成处置吗?')) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await completeDisposal(record.value.id, completeForm.value)
|
||||
await loadDetail()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '申请状态已变化,请刷新后重试')
|
||||
await loadDetail()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getItemName = (item: DisposalItem) => item.equipment_name || item.equipment?.equipment_name || item.equipment_type || '未命名资产'
|
||||
|
||||
const getActionText = (action: DisposalAction) => {
|
||||
const type = action.action_type || action.action || ''
|
||||
const map: Record<string, string> = {
|
||||
apply: '提交报废申请',
|
||||
applied: '提交报废申请',
|
||||
submitted: '提交报废申请',
|
||||
approve: '审批通过',
|
||||
approved: '审批通过',
|
||||
reject: '驳回申请',
|
||||
rejected: '驳回申请',
|
||||
complete: '完成报废',
|
||||
completed: '完成报废',
|
||||
legacy_imported: '历史记录补录'
|
||||
}
|
||||
return map[type] || type || `${action.from_status || '-'} → ${action.to_status || '-'}`
|
||||
}
|
||||
|
||||
const getStatusBadgeClass = (status: string) => ({
|
||||
pending: 'badge-warning',
|
||||
approved: 'badge-neutral',
|
||||
rejected: 'badge-error',
|
||||
completed: 'badge-success'
|
||||
}[status] || 'badge-neutral')
|
||||
|
||||
const getStatusText = (status: string) => ({
|
||||
pending: '待审批',
|
||||
approved: '已审批',
|
||||
rejected: '已驳回',
|
||||
completed: '已完成'
|
||||
}[status] || status)
|
||||
|
||||
const formatCurrency = (value?: number) => `¥${Number(value || 0).toFixed(2)}`
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
7
frontend/src/views/workflows/DisposalList.vue
Normal file
7
frontend/src/views/workflows/DisposalList.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<DisposalManagement />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DisposalManagement from '@/views/settings/components/DisposalManagement.vue'
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user