feat(流程): 增加角色权限、报废审批与钉钉通知

This commit is contained in:
WuYongJie 2026-07-15 13:09:20 +08:00
parent 373825dbfe
commit a6463fc30a
100 changed files with 7885 additions and 1633 deletions

View File

@ -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

View File

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

View File

@ -0,0 +1,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`;

View File

@ -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())

View 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, /不允许通过确认指纹绕过/)
})

View File

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

View File

@ -0,0 +1,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
}
})

View File

@ -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'), /允许范围/)
})

View File

@ -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
}
})

View File

@ -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
}
})

View File

@ -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
}
})

View File

@ -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
}
})

View File

@ -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

View File

@ -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}" 不存在` })

View File

@ -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(

View 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 || '补发失败')
}
}

View File

@ -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 || '获取报废历史失败')

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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))
}
}

View 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 || '获取本人资产失败')
}
}

View File

@ -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,

View File

@ -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, '该用户已存在业务历史记录,无法删除')
}

View File

@ -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()

View 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
}
})

View File

@ -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 })

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View 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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)
})

View 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]
}
}
})

View File

@ -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
}
})

View File

@ -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
}
})

View 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
}
}

View 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
}

View 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 || {}
}

View 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()
}

View 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)
})

View File

@ -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('出库时使用者有主部门就同步资产归属', () => {

View 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'), [])
})

View File

@ -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 }
: {})
}
})
}

View File

@ -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)
}

View File

@ -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) => {

View 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}`
}

View File

@ -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 '该资产已存在维修、报废或挂载历史,无法删除'
}

View 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
View File

@ -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 "=========================================="

View File

@ -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:

View File

@ -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)
}

View File

@ -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

View 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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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 {

View 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
}

View File

@ -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)

View 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
View 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)
}
}

View File

@ -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) => {

View File

@ -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

View File

@ -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()
}
},

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}
}
}
}

View File

@ -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响应接口

View File

@ -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 || '登录失败,请重试'

View 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>

View 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>

View File

@ -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(() => {

View File

@ -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>

View File

@ -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()

View File

@ -0,0 +1,7 @@
<template>
<MaintenanceManagement />
</template>
<script setup lang="ts">
import MaintenanceManagement from '@/views/settings/components/MaintenanceManagement.vue'
</script>

View File

@ -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()
})

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -0,0 +1,7 @@
<template>
<DisposalManagement />
</template>
<script setup lang="ts">
import DisposalManagement from '@/views/settings/components/DisposalManagement.vue'
</script>