fix: 收紧资产流转安全与敏感数据权限

This commit is contained in:
WuYongJie 2026-07-16 23:12:59 +08:00
parent 6a38680aa7
commit 920fa1289d
59 changed files with 2857 additions and 651 deletions

View File

@ -4,9 +4,12 @@ SESSION_SECRET=change-me-session-secret
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
CORS_ALLOWED_ORIGINS=http://localhost:5173 CORS_ALLOWED_ORIGINS=http://localhost:5173
AUTH_MODE=local AUTH_MODE=local
KEYCLOAK_URL=
KEYCLOAK_REALM=equipment-management
KEYCLOAK_CLIENT_ID=equipment-client
KEYCLOAK_CLIENT_SECRET=
PORT=3000 PORT=3000
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api
VITE_AUTH_MODE=local
SQLSERVER_HOST= SQLSERVER_HOST=
SQLSERVER_PORT=1433 SQLSERVER_PORT=1433
SQLSERVER_DATABASE= SQLSERVER_DATABASE=

View File

@ -1,32 +1,42 @@
-- 只读核查:在执行迁移前,列出所有未完成的历史报废单。 -- 迁移前只读核查必须在一个一致性快照中同时生成:
-- 如查询有结果,应先由业务负责人确认当前挂载树是否就是本次报废范围。 -- 1. 未完成报废单数量;2. 缺失根资产/资产树异常数量;3. 供人工核对及计算指纹的完整资产树。
SELECT -- deploy.sh 会展示并哈希同一批 REVIEW_ROW,禁止把展示与指纹拆成两次查询。
disposal.`id` AS `disposal_id`, SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
disposal.`status`, START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;
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`;
-- 只读核查:展开未完成报废单的当前资产树。 WITH RECURSIVE `active_disposals` AS (
-- cycle_detected = 1 表示存在环;depth = 100 表示已达自动补录上限,两者都必须先修复数据。 SELECT
WITH RECURSIVE `active_disposal_tree` AS ( disposal.`id`,
disposal.`status`,
disposal.`equipment_id`,
disposal.`equipment_code`,
disposal.`applied_at`,
disposal.`applied_by`,
disposal.`created_at`,
disposal.`updated_at`
FROM `disposal_record` disposal
WHERE disposal.`status` IN ('pending', 'approved')
), `active_disposal_tree` AS (
SELECT SELECT
disposal.`id` AS `disposal_id`, disposal.`id` AS `disposal_id`,
equipment.`id` AS `equipment_id`, equipment.`id` AS `equipment_id`,
equipment.`equipment_code`, equipment.`equipment_code`,
equipment.`equipment_name`, equipment.`equipment_name`,
equipment.`equipment_model`,
equipment.`equipment_type`,
equipment.`parent_id`, equipment.`parent_id`,
parent.`equipment_code` AS `parent_equipment_code`,
equipment.`status` AS `current_status`, equipment.`status` AS `current_status`,
equipment.`department_id`,
equipment.`storage_location_id`,
equipment.`mount_status`,
equipment.`updated_at` AS `equipment_updated_at`,
0 AS `depth`, 0 AS `depth`,
CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`, CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`,
0 AS `cycle_detected` 0 AS `cycle_detected`
FROM `disposal_record` disposal FROM `active_disposals` disposal
INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id` INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
WHERE disposal.`status` IN ('pending', 'approved') LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id`
UNION ALL UNION ALL
@ -35,8 +45,15 @@ WITH RECURSIVE `active_disposal_tree` AS (
child.`id`, child.`id`,
child.`equipment_code`, child.`equipment_code`,
child.`equipment_name`, child.`equipment_name`,
child.`equipment_model`,
child.`equipment_type`,
child.`parent_id`, child.`parent_id`,
tree.`equipment_code`,
child.`status`, child.`status`,
child.`department_id`,
child.`storage_location_id`,
child.`mount_status`,
child.`updated_at`,
tree.`depth` + 1, tree.`depth` + 1,
CONCAT(tree.`visited_path`, child.`id`, '/'), CONCAT(tree.`visited_path`, child.`id`, '/'),
LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) > 0 LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) > 0
@ -44,15 +61,80 @@ WITH RECURSIVE `active_disposal_tree` AS (
INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id` INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id`
WHERE tree.`depth` < 100 WHERE tree.`depth` < 100
AND tree.`cycle_detected` = 0 AND tree.`cycle_detected` = 0
), `review_rows` AS (
SELECT
1 AS `sort_group`,
CONCAT('D:', LPAD(disposal.`id`, 20, '0')) AS `sort_key`,
CAST(JSON_OBJECT(
'record_type', 'disposal',
'disposal_id', disposal.`id`,
'status', disposal.`status`,
'root_equipment_id', disposal.`equipment_id`,
'root_equipment_code', disposal.`equipment_code`,
'applied_at', DATE_FORMAT(disposal.`applied_at`, '%Y-%m-%dT%H:%i:%s.%f'),
'applied_by', disposal.`applied_by`,
'created_at', DATE_FORMAT(disposal.`created_at`, '%Y-%m-%dT%H:%i:%s.%f'),
'updated_at', DATE_FORMAT(disposal.`updated_at`, '%Y-%m-%dT%H:%i:%s.%f')
) AS CHAR) AS `payload`
FROM `active_disposals` disposal
UNION ALL
SELECT
2 AS `sort_group`,
CONCAT(
'T:', LPAD(tree.`disposal_id`, 20, '0'), ':',
LPAD(tree.`depth`, 3, '0'), ':', LPAD(tree.`equipment_id`, 20, '0')
) AS `sort_key`,
CAST(JSON_OBJECT(
'record_type', 'asset_tree',
'disposal_id', tree.`disposal_id`,
'equipment_id', tree.`equipment_id`,
'equipment_code', tree.`equipment_code`,
'equipment_name', tree.`equipment_name`,
'equipment_model', tree.`equipment_model`,
'equipment_type', tree.`equipment_type`,
'parent_id', tree.`parent_id`,
'parent_equipment_code', tree.`parent_equipment_code`,
'current_status', tree.`current_status`,
'department_id', tree.`department_id`,
'storage_location_id', tree.`storage_location_id`,
'mount_status', tree.`mount_status`,
'equipment_updated_at', DATE_FORMAT(tree.`equipment_updated_at`, '%Y-%m-%dT%H:%i:%s.%f'),
'depth', tree.`depth`,
'cycle_detected', tree.`cycle_detected`
) AS CHAR) AS `payload`
FROM `active_disposal_tree` tree
), `precheck_output` AS (
SELECT 0 AS `sort_group`, '1' AS `sort_key`, 'MISSING_ROOT_COUNT' AS `record_type`,
CAST(COUNT(*) AS CHAR) AS `payload`
FROM `active_disposals` disposal
LEFT JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
WHERE equipment.`id` IS NULL
UNION ALL
SELECT 0, '2', 'TREE_ANOMALY_COUNT', CAST(COUNT(*) AS CHAR)
FROM `active_disposal_tree`
WHERE `cycle_detected` = 1 OR `depth` >= 100
UNION ALL
SELECT 0, '3', 'ACTIVE_DISPOSAL_COUNT', CAST(COUNT(*) AS CHAR)
FROM `active_disposals`
UNION ALL
SELECT 0, '4', 'REVIEW_ROW_COUNT', CAST(COUNT(*) AS CHAR)
FROM `review_rows`
UNION ALL
SELECT `sort_group`, `sort_key`, 'REVIEW_ROW', `payload`
FROM `review_rows`
) )
SELECT SELECT `record_type`, `payload`
`disposal_id`, FROM `precheck_output`
`equipment_id`, ORDER BY `sort_group`, `sort_key`;
`equipment_code`,
`equipment_name`, COMMIT;
`parent_id`,
`current_status`,
`depth`,
`cycle_detected`
FROM `active_disposal_tree`
ORDER BY `disposal_id`, `depth`, `equipment_id`;

View File

@ -0,0 +1,24 @@
-- 旧版驳回动作会把驳回原因写入 notes。只有 rejected 状态语义明确,才安全回填。
UPDATE `disposal_record`
SET `reject_reason` = `notes`
WHERE `status` = 'rejected'
AND (`reject_reason` IS NULL OR TRIM(`reject_reason`) = '')
AND `notes` IS NOT NULL
AND TRIM(`notes`) <> '';
-- completed 的旧 notes 可能是申请备注,也可能是处置备注,无法可靠区分。
-- 因此故意保留在 notes 中作为“历史备注”,不写入 completion_notes。
-- 历史版本曾把身份证号写入 borrow_record.person_identifier。
-- 只清理可明确识别为 15/18 位身份证、且不等于当前员工工号的旧值,避免误删合法工号。
UPDATE `borrow_record` borrow_record
INNER JOIN `person` person ON person.`id` = borrow_record.`person_id`
SET borrow_record.`person_identifier` = NULL
WHERE (
borrow_record.`person_identifier` REGEXP '^[1-9][0-9]{14}$'
OR borrow_record.`person_identifier` REGEXP '^[1-9][0-9]{16}[0-9Xx]$'
)
AND (
person.`job_number` IS NULL
OR borrow_record.`person_identifier` <> person.`job_number`
);

View File

@ -4,7 +4,12 @@ const fs = require('node:fs')
const path = require('node:path') const path = require('node:path')
const deployPath = path.resolve(__dirname, '../../../deploy.sh') const deployPath = path.resolve(__dirname, '../../../deploy.sh')
const precheckPath = path.resolve(
__dirname,
'../../prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql'
)
const deployScript = fs.readFileSync(deployPath, 'utf8') const deployScript = fs.readFileSync(deployPath, 'utf8')
const precheckSql = fs.readFileSync(precheckPath, 'utf8')
test('标准部署在 build/up 前必须通过报废迁移预检', () => { test('标准部署在 build/up 前必须通过报废迁移预检', () => {
const gateCall = deployScript.lastIndexOf('\nrun_disposal_migration_precheck\n') const gateCall = deployScript.lastIndexOf('\nrun_disposal_migration_precheck\n')
@ -32,16 +37,19 @@ test('预检只连接固定资产 MySQL,不在命令行暴露密码', () => {
}) })
test('无法确认或存在未核对报废单时默认阻断,仅允许本次显式确认', () => { test('无法确认或存在未核对报废单时默认阻断,仅允许本次显式确认', () => {
assert.ok( assert.match(precheckSql, /SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;/)
deployScript.includes( assert.match(precheckSql, /START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;/)
"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(precheckSql, /'ACTIVE_DISPOSAL_COUNT'/)
), assert.match(precheckSql, /'REVIEW_ROW'/)
'预检必须稳定读取待审批和已审批报废单的指纹字段' assert.match(precheckSql, /COMMIT;/)
)
assert.match(deployScript, /printf '%s\\n' "\$active_rows" \| sha256sum/)
assert.match( assert.match(
deployScript, deployScript,
/CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "\$active_fingerprint"/ /snapshot_output="\$\([\s\S]*run_equipment_mysql --batch --skip-column-names --raw < "\$DISPOSAL_PRECHECK_SQL"[\s\S]*\)"/
)
assert.match(deployScript, /printf '%s\\n' "\$review_rows" \| sha256sum/)
assert.match(
deployScript,
/CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "\$CURRENT_DISPOSAL_FINGERPRINT"/
) )
assert.match(deployScript, /已默认阻止部署/) assert.match(deployScript, /已默认阻止部署/)
}) })
@ -49,11 +57,41 @@ test('无法确认或存在未核对报废单时默认阻断,仅允许本次
test('未完成报废资产树存在环或深度达到 100 时禁止 override', () => { test('未完成报废资产树存在环或深度达到 100 时禁止 override', () => {
const anomalyBlock = deployScript.indexOf('if [ "$tree_anomaly_count" -gt 0 ]') const anomalyBlock = deployScript.indexOf('if [ "$tree_anomaly_count" -gt 0 ]')
const overrideCheck = deployScript.indexOf( const overrideCheck = deployScript.indexOf(
'if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]' 'if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$CURRENT_DISPOSAL_FINGERPRINT" ]'
) )
assert.match(deployScript, /WHERE cycle_detected = 1 OR depth >= 100;/) assert.match(precheckSql, /WHERE `cycle_detected` = 1 OR `depth` >= 100/)
assert.ok(anomalyBlock > 0, '必须对资产树异常数量做硬阻断') assert.ok(anomalyBlock > 0, '必须对资产树异常数量做硬阻断')
assert.ok(overrideCheck > anomalyBlock, '资产树异常阻断必须早于指纹 override') assert.ok(overrideCheck > anomalyBlock, '资产树异常阻断必须早于指纹 override')
assert.match(deployScript, /不允许通过确认指纹绕过/) assert.match(deployScript, /不允许通过确认指纹绕过/)
}) })
test('人工展示与指纹来自同一次数据库一致性快照', () => {
const functionBody = deployScript.slice(
deployScript.indexOf('calculate_active_disposal_fingerprint()'),
deployScript.indexOf('\nrun_disposal_migration_precheck()')
)
const mysqlCalls = functionBody.match(/run_equipment_mysql/g) || []
assert.equal(mysqlCalls.length, 1, '一次指纹计算只能打开一个数据库会话')
assert.match(functionBody, /review_rows=.*snapshot_output/)
assert.match(functionBody, /printf '%s\\n' "\$review_rows"/)
assert.doesNotMatch(
deployScript.slice(
deployScript.indexOf('run_disposal_migration_precheck()'),
deployScript.indexOf('\nrecheck_disposal_migration_precheck()')
),
/run_equipment_mysql\s+<\s+"\$DISPOSAL_PRECHECK_SQL"/
)
})
test('同名迁移同时存在完成和未完成记录时必须优先判为 incomplete', () => {
const stateQuery = deployScript.slice(
deployScript.indexOf('SELECT CASE'),
deployScript.indexOf('END\nFROM _prisma_migrations')
)
assert.ok(
stateQuery.indexOf("THEN 'incomplete'") < stateQuery.indexOf("THEN 'applied'"),
'未完成记录必须优先阻断,不能被旧的已完成记录掩盖'
)
})

View File

@ -0,0 +1,116 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { getBorrowRecordList, searchReturnAssets } from '../borrowController'
const createMockResponse = () => {
const responseBody: { statusCode: number; body: any } = { statusCode: 200, body: null }
const res = {
locals: { user: { id: 1, role: 'it' } },
status(code: number) {
responseBody.statusCode = code
return this
},
json(payload: any) {
responseBody.body = payload
return this
}
}
return { res, responseBody }
}
test('借用记录列表不按历史 person_identifier 搜索且不返回原始值', async () => {
const originalFindMany = prisma.borrowRecord.findMany
const originalCount = prisma.borrowRecord.count
let capturedWhere: any = null
;(prisma.borrowRecord.findMany as any) = async (args: any) => {
capturedWhere = args.where
return [{
id: 1,
equipment_id: 20,
equipment_code: 'JS-PC-0020',
person_id: 8,
person_name: '测试员工',
person_nickname: '员工',
person_identifier: '320311199001011234',
borrow_time: new Date('2026-07-01T00:00:00Z'),
expected_return_time: null,
is_long_term: false,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: null,
children_snapshot: null,
person: null,
equipment: {
equipment_name: '主机',
storage_location_id: 1,
storageLocation: null
}
}]
}
;(prisma.borrowRecord.count as any) = async () => 1
try {
const { res, responseBody } = createMockResponse()
await getBorrowRecordList({
query: { person_name: '320311', page: '1', pageSize: '10' }
} as any, res as any)
assert.equal(responseBody.statusCode, 200)
assert.equal(JSON.stringify(capturedWhere).includes('person_identifier'), false)
assert.equal(JSON.stringify(responseBody.body).includes('320311199001011234'), false)
assert.equal(
Object.prototype.hasOwnProperty.call(responseBody.body.data.list[0], 'person_identifier'),
false
)
} finally {
;(prisma.borrowRecord.findMany as any) = originalFindMany
;(prisma.borrowRecord.count as any) = originalCount
}
})
test('归还资产搜索不读取或返回历史借用人标识', async () => {
const originalFindMany = prisma.equipment.findMany
let capturedArgs: any = null
;(prisma.equipment.findMany as any) = async (args: any) => {
capturedArgs = args
return [{
id: 20,
equipment_code: 'JS-PC-0020',
equipment_name: '主机',
equipment_model: 'M720',
equipment_type: '主机',
status: '已借出',
total_quantity: 1,
parent_id: null,
storage_location_id: 1,
storageLocation: null,
borrow_records: [{
id: 1,
person_name: '测试员工',
person_nickname: '员工',
person_identifier: '320311199001011234',
person: { department: '测试部', mainDepartment: { name: '测试部' } }
}]
}]
}
try {
const { res, responseBody } = createMockResponse()
await searchReturnAssets({ query: { keyword: 'JS-PC' } } as any, res as any)
assert.equal(responseBody.statusCode, 200)
assert.equal(JSON.stringify(capturedArgs).includes('person_identifier'), false)
assert.equal(JSON.stringify(responseBody.body).includes('320311199001011234'), false)
assert.equal(
Object.prototype.hasOwnProperty.call(responseBody.body.data[0], 'borrower_identifiers'),
false
)
} finally {
;(prisma.equipment.findMany as any) = originalFindMany
}
})

View File

@ -8,7 +8,9 @@ import {
buildDisposalApprovedDingTalkContent, buildDisposalApprovedDingTalkContent,
buildDisposalCompletedDingTalkContent, buildDisposalCompletedDingTalkContent,
buildDisposalRejectedDingTalkContent, buildDisposalRejectedDingTalkContent,
createDisposal createDisposal,
getDisposalDetail,
getDisposalList
} from '../disposalController' } from '../disposalController'
const now = new Date('2026-07-14T03:00:00.000Z') const now = new Date('2026-07-14T03:00:00.000Z')
@ -349,3 +351,96 @@ test('驳回报废时按每个 item.pre_status 分别恢复', async () => {
;(prisma.operationLog.create as any) = originalLogCreate ;(prisma.operationLog.create as any) = originalLogCreate
} }
}) })
test('行政已处理列表包含全部已审批、已驳回和已完成记录,不按处理人收窄', async () => {
const originalFindMany = prisma.disposalRecord.findMany
const originalCount = prisma.disposalRecord.count
let capturedWhere: any = null
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
capturedWhere = args.where
return [{
id: 119,
status: 'completed',
notes: '旧版处置备注',
completion_notes: null
}]
}
;(prisma.disposalRecord.count as any) = async () => 1
try {
const { res, state } = createResponse('administrative')
await getDisposalList(
{ query: { scope: 'processed', page: '1', pageSize: '10' } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.deepEqual(capturedWhere.AND[0], {
status: { in: ['approved', 'rejected', 'completed'] }
})
assert.doesNotMatch(JSON.stringify(capturedWhere), /approved_by|completed_by/)
assert.equal(state.body.data.list[0].notes, '旧版处置备注')
assert.equal(state.body.data.list[0].historical_notes, '旧版处置备注')
} finally {
;(prisma.disposalRecord.findMany as any) = originalFindMany
;(prisma.disposalRecord.count as any) = originalCount
}
})
test('IT即使请求all范围也只能查询本人发起的报废记录', async () => {
const originalFindMany = prisma.disposalRecord.findMany
const originalCount = prisma.disposalRecord.count
let capturedWhere: any = null
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
capturedWhere = args.where
return []
}
;(prisma.disposalRecord.count as any) = async () => 0
try {
const { res, state } = createResponse('it')
await getDisposalList(
{ query: { scope: 'all', page: '1', pageSize: '10' } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.deepEqual(capturedWhere.AND[0], { applied_by: 6 })
} finally {
;(prisma.disposalRecord.findMany as any) = originalFindMany
;(prisma.disposalRecord.count as any) = originalCount
}
})
test('IT不能通过详情接口查看他人申请,行政可以查看全部流程详情', async () => {
const originalFindUnique = prisma.disposalRecord.findUnique
;(prisma.disposalRecord.findUnique as any) = async () => ({
id: 120,
applied_by: 99,
status: 'completed',
notes: '历史备注',
completion_notes: null
})
try {
const itResponse = createResponse('it')
await getDisposalDetail(
{ params: { id: '120' } } as any,
itResponse.res as any
)
assert.equal(itResponse.state.statusCode, 403)
const administrativeResponse = createResponse('administrative')
await getDisposalDetail(
{ params: { id: '120' } } as any,
administrativeResponse.res as any
)
assert.equal(administrativeResponse.state.statusCode, 200)
assert.equal(administrativeResponse.state.body.data.notes, '历史备注')
assert.equal(administrativeResponse.state.body.data.historical_notes, '历史备注')
} finally {
;(prisma.disposalRecord.findUnique as any) = originalFindUnique
}
})

View File

@ -29,8 +29,10 @@ test('资产列表搜索不应再按借用人身份证号或历史 person_identi
const originalCount = prisma.equipment.count const originalCount = prisma.equipment.count
let capturedWhere: any = null let capturedWhere: any = null
let capturedArgs: any = null
;(prisma.equipment.findMany as any) = async (args: any) => { ;(prisma.equipment.findMany as any) = async (args: any) => {
capturedArgs = args
capturedWhere = args.where capturedWhere = args.where
return [] return []
} }
@ -66,6 +68,11 @@ test('资产列表搜索不应再按借用人身份证号或历史 person_identi
false, false,
'资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中' '资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中'
) )
assert.equal(
JSON.stringify(capturedArgs).includes('person_identifier'),
false,
'资产接口也不应读取历史 person_identifier 后再返回给行政或 IT'
)
assert.equal( assert.equal(
serializedWhere.includes('job_number'), serializedWhere.includes('job_number'),
true, true,

View File

@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import prisma from '../../utils/prisma' import prisma from '../../utils/prisma'
import { completeMaintenance, createMaintenance } from '../maintenanceController' import { completeMaintenance, createMaintenance, updateMaintenance } from '../maintenanceController'
const createMockResponse = () => { const createMockResponse = () => {
const responseBody = { statusCode: 200, body: null as any } const responseBody = { statusCode: 200, body: null as any }
@ -129,3 +129,65 @@ test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', asyn
;(prisma.$transaction as any) = originalTransaction ;(prisma.$transaction as any) = originalTransaction
} }
}) })
test('维修资料更新只命中 in_progress,且不再接受未落库的 notes 字段', async () => {
const originalUpdateMany = prisma.maintenanceRecord.updateMany
const originalFindUnique = prisma.maintenanceRecord.findUnique
let updateArgs: any = null
;(prisma.maintenanceRecord.updateMany as any) = async (args: any) => {
updateArgs = args
return { count: 1 }
}
;(prisma.maintenanceRecord.findUnique as any) = async () => ({
id: 20,
status: 'in_progress',
repair_vendor: '测试维修商'
})
try {
const { res, responseBody } = createMockResponse()
await updateMaintenance(
{
params: { id: '20' },
body: {
repair_vendor: '测试维修商',
estimated_cost: 200,
notes: '这个字段不属于维修表,不能静默接收'
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.deepEqual(updateArgs.where, { id: 20, status: 'in_progress' })
assert.equal(updateArgs.data.repair_vendor, '测试维修商')
assert.equal(updateArgs.data.estimated_cost, 200)
assert.equal(Object.prototype.hasOwnProperty.call(updateArgs.data, 'notes'), false)
} finally {
;(prisma.maintenanceRecord.updateMany as any) = originalUpdateMany
;(prisma.maintenanceRecord.findUnique as any) = originalFindUnique
}
})
test('维修资料更新与完成并发时条件更新失败并返回409', async () => {
const originalUpdateMany = prisma.maintenanceRecord.updateMany
const originalFindUnique = prisma.maintenanceRecord.findUnique
;(prisma.maintenanceRecord.updateMany as any) = async () => ({ count: 0 })
;(prisma.maintenanceRecord.findUnique as any) = async () => ({ id: 20, status: 'completed' })
try {
const { res, responseBody } = createMockResponse()
await updateMaintenance(
{ params: { id: '20' }, body: { repair_vendor: '并发写入' } } as any,
res as any
)
assert.equal(responseBody.statusCode, 409)
assert.match(responseBody.body.message, /已被处理/)
} finally {
;(prisma.maintenanceRecord.updateMany as any) = originalUpdateMany
;(prisma.maintenanceRecord.findUnique as any) = originalFindUnique
}
})

View File

@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { getPersonOptions } from '../personController'
const createResponse = () => {
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
return { response, state }
}
test('业务人员选项只返回工作所需字段,不泄露联系方式和钉钉标识', async () => {
const originalFindMany = prisma.person.findMany
const originalCount = prisma.person.count
let capturedArgs: any = null
;(prisma.person.findMany as any) = async (args: any) => {
capturedArgs = args
return [{
id: 9,
nickname: '魔礼红',
real_name: '测试人员',
job_number: 'JS009',
id_number: '320000000000000000',
department: '信息部',
main_department_id: 3,
mainDepartment: { id: 3, name: '信息部', status: 'active' },
personDepartments: [],
position: 'IT',
phone: '13800000000',
email: 'private@example.com',
status: 'active',
source_userid: 'ding-private-user',
manager_source_userid: 'ding-manager'
}]
}
;(prisma.person.count as any) = async () => 1
try {
const { response, state } = createResponse()
await getPersonOptions({ query: { page: '2', page_size: '20', keyword: '魔礼红' } } as any, response as any)
assert.equal(state.status, 200)
assert.equal(state.body.data.total, 1)
assert.equal(state.body.data.page, 2)
assert.equal(state.body.data.page_size, 20)
assert.equal(capturedArgs.skip, 20)
assert.equal(capturedArgs.take, 20)
assert.equal(JSON.stringify(capturedArgs.where).includes('魔礼红'), true)
const option = state.body.data.list[0]
assert.equal(option.dingtalk_ready, true)
for (const sensitiveField of [
'id_number',
'phone',
'email',
'source_userid',
'manager_source_userid',
'remarks'
]) {
assert.equal(sensitiveField in option, false, `不应返回 ${sensitiveField}`)
}
} finally {
;(prisma.person.findMany as any) = originalFindMany
;(prisma.person.count as any) = originalCount
}
})

View File

@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { updateUser, updateUserStatus } from '../userController'
const createResponse = (currentUserId: number) => {
const state = { status: 200, body: null as any }
const response = {
locals: { user: { id: currentUserId } },
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
return { response, state }
}
test('管理员不能停用或降级当前登录的自己', async () => {
const originalFindUnique = prisma.user.findUnique
const originalUpdate = prisma.user.update
let updateCount = 0
;(prisma.user.findUnique as any) = async () => ({
id: 7,
name: '管理员',
email: null,
phone: null,
role: 'admin',
status: 'active'
})
;(prisma.user.update as any) = async () => {
updateCount += 1
}
try {
const disable = createResponse(7)
await updateUserStatus({ params: { id: '7' }, body: { status: 'inactive' } } as any, disable.response as any)
assert.equal(disable.state.status, 422)
const demote = createResponse(7)
await updateUser({ params: { id: '7' }, body: { role: 'it' } } as any, demote.response as any)
assert.equal(demote.state.status, 422)
assert.equal(updateCount, 0)
} finally {
;(prisma.user.findUnique as any) = originalFindUnique
;(prisma.user.update as any) = originalUpdate
}
})
test('两个管理员并发停用时事务锁保证至少一个仍启用', async () => {
const originalFindUnique = prisma.user.findUnique
const originalTransaction = prisma.$transaction
const activeAdministrators = new Set([7, 8])
let queue = Promise.resolve()
;(prisma.user.findUnique as any) = async ({ where }: any) => ({
id: where.id,
username: `admin-${where.id}`,
name: `管理员${where.id}`,
email: null,
phone: null,
role: 'admin',
status: 'active',
person_id: null,
person: null
})
;(prisma.$transaction as any) = (callback: (tx: any) => Promise<any>) => {
const run = queue.then(() => callback({
$queryRawUnsafe: async () => [...activeAdministrators].map((id) => ({ id })),
user: {
update: async ({ where, data }: any) => {
if (data.status === 'inactive') activeAdministrators.delete(where.id)
return {
id: where.id,
username: `admin-${where.id}`,
name: `管理员${where.id}`,
email: null,
phone: null,
role: 'admin',
status: data.status,
person_id: null,
person: null
}
}
}
}))
queue = run.then(() => undefined, () => undefined)
return run
}
try {
const first = createResponse(99)
const second = createResponse(99)
await Promise.all([
updateUserStatus({ params: { id: '7' }, body: { status: 'inactive' } } as any, first.response as any),
updateUserStatus({ params: { id: '8' }, body: { status: 'inactive' } } as any, second.response as any)
])
const statuses = [first.state.status, second.state.status].sort()
assert.deepEqual(statuses, [200, 409])
assert.equal(activeAdministrators.size, 1)
} finally {
;(prisma.user.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
}
})
test('用户状态只接受 active 和 inactive', async () => {
const { response, state } = createResponse(99)
await updateUserStatus({ params: { id: '7' }, body: { status: 'disabled' } } as any, response as any)
assert.equal(state.status, 422)
assert.match(state.body.message, /active 或 inactive/)
})

View File

@ -3,10 +3,10 @@ import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma' import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response' import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController' import { logOperation } from './operationLogController'
import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue'
import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment' import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import { import {
borrowAssetTree,
finalizeNormalReturnState, finalizeNormalReturnState,
recordReturnToStorage, recordReturnToStorage,
transferAssetTree transferAssetTree
@ -21,83 +21,26 @@ const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => {
: notes || undefined : notes || undefined
} }
const getChildrenSnapshot = async (equipmentId: number) => {
const children = await prisma.equipment.findMany({
where: { parent_id: equipmentId },
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true
}
})
return children.length > 0 ? JSON.stringify(children) : undefined
}
const batchBorrowTransaction = async ( const batchBorrowTransaction = async (
equipment: any, equipmentId: number,
person: any, person: any,
expectedReturnTime?: string, expectedReturnTime?: string,
isLongTerm?: boolean, isLongTerm?: boolean,
notes?: string notes?: string
) => { ) => {
const childrenSnapshot = await getChildrenSnapshot(equipment.id)
const finalNotes = buildBorrowNotes(notes, isLongTerm) const finalNotes = buildBorrowNotes(notes, isLongTerm)
return prisma.$transaction(
return prisma.$transaction(async (tx) => { (tx) => borrowAssetTree(tx, {
await tx.borrowRecord.create({ rootEquipmentId: equipmentId,
data: { person,
equipment_id: equipment.id, expectedReturnTime: isLongTerm
equipment_code: equipment.equipment_code, ? null
person_id: person.id, : (expectedReturnTime ? new Date(expectedReturnTime) : null),
person_name: person.real_name, isLongTerm,
person_nickname: person.nickname, notes: finalNotes
person_identifier: person.job_number, }),
expected_return_time: isLongTerm ? null : (expectedReturnTime ? new Date(expectedReturnTime) : null), { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
is_long_term: Boolean(isLongTerm), )
notes: finalNotes,
status: '已借出',
children_snapshot: childrenSnapshot
}
})
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: nextStatus
})
const updated = await tx.equipment.updateMany({
where: {
id: equipment.id,
status: '可用',
remaining_quantity: equipment.remaining_quantity
},
data: {
remaining_quantity: nextRemainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
status: nextStatus
}
})
if (updated.count !== 1) {
throw new Error('资产库存已变化,请刷新后重试')
}
await syncMountedChildrenState(
tx,
equipment.id,
'已借出',
person.main_department_id ? { departmentId: person.main_department_id } : {}
)
})
} }
const batchReturnTransaction = async ( const batchReturnTransaction = async (
@ -182,7 +125,7 @@ export const batchBorrow = async (req: Request, res: Response) => {
} }
await batchBorrowTransaction( await batchBorrowTransaction(
equipment, equipment.id,
person, person,
expected_return_time, expected_return_time,
is_long_term, is_long_term,

View File

@ -1,24 +1,24 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma' import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response' import { successResponse, errorResponse } from '../utils/response'
import { import {
calculateEquipmentValues, getDisplayEquipmentValue
getDisplayEquipmentValue,
syncMountedChildrenState
} from '../utils/assetValue' } from '../utils/assetValue'
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly' import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
import { import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
assertReturnStorageDepartmentContract,
resolveBorrowDepartmentUpdate
} from '../utils/borrowDepartment'
import { import {
buildParentAssetFirstOrderBy, buildParentAssetFirstOrderBy,
sortParentAssetsFirst sortParentAssetsFirst
} from '../utils/equipmentSearchPriority' } from '../utils/equipmentSearchPriority'
import { logOperation } from './operationLogController' import { logOperation } from './operationLogController'
import { import {
buildRecursiveBorrowSnapshot,
borrowAssetTree,
finalizeBorrowAnomalyReturnState, finalizeBorrowAnomalyReturnState,
finalizeNormalReturnState, finalizeNormalReturnState,
loadAssetTree,
registerBorrowAnomalyAssetTree,
recordReturnToStorage recordReturnToStorage
} from '../services/assetMovementService' } from '../services/assetMovementService'
@ -206,93 +206,23 @@ const appendLongTermNotes = (notes?: string, isLongTerm?: boolean) => {
: notes || undefined : notes || undefined
} }
const getChildrenSnapshot = async (equipmentId: number) => {
const children = await prisma.equipment.findMany({
where: { parent_id: equipmentId },
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true
}
})
return {
children,
snapshot: children.length > 0 ? JSON.stringify(children) : undefined
}
}
const borrowEquipmentInTransaction = async ( const borrowEquipmentInTransaction = async (
equipment: any, equipmentId: number,
person: any, person: any,
expectedReturn: Date | null, expectedReturn: Date | null,
combinedNotes?: string, combinedNotes?: string,
isLongTerm?: boolean, isLongTerm?: boolean
childrenSnapshot?: string
) => { ) => {
return prisma.$transaction(async (tx) => { return prisma.$transaction(
const record = await tx.borrowRecord.create({ (tx) => borrowAssetTree(tx, {
data: { rootEquipmentId: equipmentId,
equipment_id: equipment.id, person,
equipment_code: equipment.equipment_code, expectedReturnTime: expectedReturn,
person_id: person.id, isLongTerm,
person_name: person.real_name, notes: combinedNotes
person_nickname: person.nickname, }),
person_identifier: person.job_number, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
expected_return_time: expectedReturn, )
is_long_term: Boolean(isLongTerm),
notes: combinedNotes,
status: '已借出',
children_snapshot: childrenSnapshot
}
})
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: nextStatus
})
const updateResult = await tx.equipment.updateMany({
where: {
id: equipment.id,
status: '可用',
remaining_quantity: equipment.remaining_quantity
},
data: {
remaining_quantity: nextRemainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
status: nextStatus,
...(departmentUpdateData || {})
}
})
if (updateResult.count !== 1) {
throw new Error('资产库存已变化,请刷新后重试')
}
await syncMountedChildrenState(
tx,
equipment.id,
'已借出',
departmentUpdateData ? { departmentId: departmentUpdateData.department_id } : {}
)
const updatedEquipment = await tx.equipment.findUnique({
where: { id: equipment.id },
include: equipmentInclude
})
return { record, updatedEquipment }
})
} }
const findActiveBorrowRecord = async ( const findActiveBorrowRecord = async (
@ -343,17 +273,11 @@ const getChildrenDiff = async (equipmentId: number, borrowRecord: any) => {
} }
const borrowedChildren = JSON.parse(borrowRecord.children_snapshot) as any[] const borrowedChildren = JSON.parse(borrowRecord.children_snapshot) as any[]
const currentChildren = await prisma.equipment.findMany({ const currentTree = await prisma.$transaction(
where: { parent_id: equipmentId }, (tx) => loadAssetTree(tx, equipmentId),
select: { { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
id: true, )
equipment_code: true, const currentChildren = buildRecursiveBorrowSnapshot(currentTree)
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true
}
})
const borrowedIds = new Set(borrowedChildren.map((child) => child.id)) const borrowedIds = new Set(borrowedChildren.map((child) => child.id))
const currentIds = new Set(currentChildren.map((child) => child.id)) const currentIds = new Set(currentChildren.map((child) => child.id))
@ -434,81 +358,26 @@ const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any, operate
} }
const registerBorrowAnomalyInTransaction = async ( const registerBorrowAnomalyInTransaction = async (
equipment: any, equipmentId: number,
person: any, person: any,
borrowTime: Date, borrowTime: Date,
notes?: string notes?: string
) => { ) => {
return prisma.$transaction(async (tx) => { const result = await prisma.$transaction(
const { children, snapshot } = await getChildrenSnapshot(equipment.id) (tx) => registerBorrowAnomalyAssetTree(tx, {
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id) rootEquipmentId: equipmentId,
const nextRemainingQuantity = Math.max(Number(equipment.total_quantity) - 1, 0) person,
const values = calculateEquipmentValues({ borrowTime,
unitPrice: Number(equipment.unit_price), notes
totalQuantity: Number(equipment.total_quantity), }),
remainingQuantity: nextRemainingQuantity, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
status: '已借出' )
}) const updatedEquipment = await prisma.equipment.findUnique({
where: { id: result.updatedRoot.id },
const record = await tx.borrowRecord.create({ include: equipmentInclude
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.job_number,
borrow_time: borrowTime,
notes: notes?.trim() || null,
status: '已借出',
children_snapshot: snapshot
}
})
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,
total_value: values.total_value,
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,
'已借出',
departmentUpdateData ? { departmentId: departmentUpdateData.department_id } : {}
)
return {
record,
updatedEquipment,
childrenCount: children.length
}
}) })
if (!updatedEquipment) throw new Error('资产不存在')
return { ...result, updatedEquipment }
} }
const mapBorrowRecord = (record: any) => ({ const mapBorrowRecord = (record: any) => ({
@ -524,10 +393,8 @@ const mapBorrowRecord = (record: any) => ({
nickname: record.person.nickname, nickname: record.person.nickname,
real_name: record.person.real_name, real_name: record.person.real_name,
job_number: record.person.job_number, job_number: record.person.job_number,
id_number: record.person.id_number,
department: record.person.mainDepartment?.name || record.person.department, department: record.person.mainDepartment?.name || record.person.department,
main_department: record.person.mainDepartment || null, main_department: record.person.mainDepartment || null,
phone: record.person.phone,
status: record.person.status status: record.person.status
} }
: { : {
@ -538,7 +405,6 @@ const mapBorrowRecord = (record: any) => ({
person_id: record.person_id, person_id: record.person_id,
person_name: record.person_name, person_name: record.person_name,
person_nickname: record.person_nickname, person_nickname: record.person_nickname,
person_identifier: record.person_identifier,
borrow_time: record.borrow_time, borrow_time: record.borrow_time,
expected_return_time: record.expected_return_time, expected_return_time: record.expected_return_time,
is_long_term: record.is_long_term, is_long_term: record.is_long_term,
@ -582,14 +448,12 @@ export const borrowEquipment = async (req: Request, res: Response) => {
const longTerm = Boolean(is_long_term) const longTerm = Boolean(is_long_term)
const expectedReturn = longTerm ? null : parseDate(expected_return_time) const expectedReturn = longTerm ? null : parseDate(expected_return_time)
const combinedNotes = appendLongTermNotes(notes, longTerm) const combinedNotes = appendLongTermNotes(notes, longTerm)
const { children, snapshot } = await getChildrenSnapshot(equipment.id) const { record: borrowRecord, childrenCount } = await borrowEquipmentInTransaction(
const { record: borrowRecord } = await borrowEquipmentInTransaction( equipment.id,
equipment,
person, person,
expectedReturn, expectedReturn,
combinedNotes, combinedNotes,
longTerm, longTerm
snapshot
) )
successResponse( successResponse(
@ -597,7 +461,7 @@ export const borrowEquipment = async (req: Request, res: Response) => {
{ {
id: borrowRecord.id, id: borrowRecord.id,
borrow_time: borrowRecord.borrow_time, borrow_time: borrowRecord.borrow_time,
children_count: children.length children_count: childrenCount
}, },
'借用成功' '借用成功'
) )
@ -608,7 +472,7 @@ export const borrowEquipment = async (req: Request, res: Response) => {
body: req.body, body: req.body,
user: res.locals.user user: res.locals.user
}) })
errorResponse(res, error.message || '借用资产失败') errorResponse(res, error.message || '借用资产失败', error.status || 500)
} }
} }
@ -638,14 +502,12 @@ export const scanBorrow = async (req: Request, res: Response) => {
const longTerm = Boolean(is_long_term) const longTerm = Boolean(is_long_term)
const expectedReturn = longTerm ? null : parseDate(expected_return_time) const expectedReturn = longTerm ? null : parseDate(expected_return_time)
const combinedNotes = appendLongTermNotes(notes, longTerm) const combinedNotes = appendLongTermNotes(notes, longTerm)
const { children, snapshot } = await getChildrenSnapshot(equipment.id) const { record: borrowRecord, updatedRoot, childrenCount } = await borrowEquipmentInTransaction(
const { record: borrowRecord, updatedEquipment } = await borrowEquipmentInTransaction( equipment.id,
equipment,
person, person,
expectedReturn, expectedReturn,
combinedNotes, combinedNotes,
longTerm, longTerm
snapshot
) )
successResponse( successResponse(
@ -656,11 +518,11 @@ export const scanBorrow = async (req: Request, res: Response) => {
equipment_name: equipment.equipment_name, equipment_name: equipment.equipment_name,
borrow_time: borrowRecord.borrow_time, borrow_time: borrowRecord.borrow_time,
status: '已借出', status: '已借出',
remaining_quantity: updatedEquipment?.remaining_quantity ?? 0, remaining_quantity: updatedRoot.remaining_quantity,
equipment_status: updatedEquipment?.status ?? '已借出', equipment_status: updatedRoot.status,
storage_location_id: equipment.storage_location_id, storage_location_id: equipment.storage_location_id,
storageLocation: equipment.storageLocation, storageLocation: equipment.storageLocation,
children_count: children.length children_count: childrenCount
}, },
'出库成功' '出库成功'
) )
@ -671,7 +533,7 @@ export const scanBorrow = async (req: Request, res: Response) => {
body: req.body, body: req.body,
user: res.locals.user user: res.locals.user
}) })
errorResponse(res, error.message || '出库失败') errorResponse(res, error.message || '出库失败', error.status || 500)
} }
} }
@ -721,14 +583,12 @@ export const scanBorrowBatch = async (req: Request, res: Response) => {
continue continue
} }
const { snapshot } = await getChildrenSnapshot(equipment.id)
await borrowEquipmentInTransaction( await borrowEquipmentInTransaction(
equipment, equipment.id,
person, person,
expectedReturn, expectedReturn,
combinedNotes, combinedNotes,
longTerm, longTerm
snapshot
) )
results.push({ equipment_code: code, status: 'success', message: '出库成功' }) results.push({ equipment_code: code, status: 'success', message: '出库成功' })
@ -1126,8 +986,7 @@ export const getBorrowRecordList = async (req: Request, res: Response) => {
filters.push({ filters.push({
OR: [ OR: [
{ person_name: { contains: kw } }, { person_name: { contains: kw } },
{ person_nickname: { contains: kw } }, { person_nickname: { contains: kw } }
{ person_identifier: { contains: kw } }
] ]
}) })
} }
@ -1250,7 +1109,6 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
id: true, id: true,
person_name: true, person_name: true,
person_nickname: true, person_nickname: true,
person_identifier: true,
person: { person: {
select: { select: {
department: true, department: true,
@ -1276,17 +1134,14 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
const list = sortParentAssetsFirst(equipmentList).map((equipment) => { const list = sortParentAssetsFirst(equipmentList).map((equipment) => {
const borrowerNames: string[] = [] const borrowerNames: string[] = []
const borrowerIdentifiers: string[] = []
const borrowerDepartments: string[] = [] const borrowerDepartments: string[] = []
for (const record of equipment.borrow_records) { for (const record of equipment.borrow_records) {
const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ') const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ')
const borrowerIdentifier = record.person_identifier || null
const borrowerDepartment = const borrowerDepartment =
record.person?.mainDepartment?.name || record.person?.department || null record.person?.mainDepartment?.name || record.person?.department || null
pushUnique(borrowerNames, borrowerName) pushUnique(borrowerNames, borrowerName)
pushUnique(borrowerIdentifiers, borrowerIdentifier)
pushUnique(borrowerDepartments, borrowerDepartment) pushUnique(borrowerDepartments, borrowerDepartment)
} }
@ -1304,7 +1159,6 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
storageLocation: equipment.storageLocation, storageLocation: equipment.storageLocation,
active_borrow_count: activeBorrowCount, active_borrow_count: activeBorrowCount,
borrower_names: borrowerNames, borrower_names: borrowerNames,
borrower_identifiers: borrowerIdentifiers,
borrower_departments: borrowerDepartments, borrower_departments: borrowerDepartments,
can_return: activeBorrowCount > 0, can_return: activeBorrowCount > 0,
can_handle_borrow_anomaly: anomalyMeta.canHandle, can_handle_borrow_anomaly: anomalyMeta.canHandle,
@ -1399,7 +1253,7 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => {
} }
const { record, updatedEquipment, childrenCount } = await registerBorrowAnomalyInTransaction( const { record, updatedEquipment, childrenCount } = await registerBorrowAnomalyInTransaction(
equipment, equipment.id,
person, person,
borrowTime, borrowTime,
typeof notes === 'string' ? notes : undefined typeof notes === 'string' ? notes : undefined
@ -1431,6 +1285,6 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => {
'已补登记借出记录' '已补登记借出记录'
) )
} catch (error: any) { } catch (error: any) {
errorResponse(res, error.message || '补登记借出失败') errorResponse(res, error.message || '补登记借出失败', error.status || 500)
} }
} }

View File

@ -202,6 +202,18 @@ const getCurrentUser = (res: Response) => ({
role: String(res.locals.user?.role || '') role: String(res.locals.user?.role || '')
}) })
const withHistoricalDisposalNotes = <T extends {
status: string
notes?: string | null
completion_notes?: string | null
}>(record: T) => ({
...record,
historical_notes:
record.status === 'completed' && record.notes && !record.completion_notes
? record.notes
: null
})
export const createDisposal = async (req: Request, res: Response) => { export const createDisposal = async (req: Request, res: Response) => {
try { try {
const { const {
@ -382,39 +394,48 @@ export const getDisposalList = async (req: Request, res: Response) => {
: currentUser.role === 'administrative' : currentUser.role === 'administrative'
? 'pending' ? 'pending'
: 'mine')) : 'mine'))
const where: Prisma.DisposalRecordWhereInput = {} const validScopes = ['all', 'mine', 'pending', 'processed']
if (!validScopes.includes(resolvedScope)) {
return errorResponse(res, '报废列表范围无效', 422)
}
const scopeConditions: Prisma.DisposalRecordWhereInput[] = [] const scopeConditions: Prisma.DisposalRecordWhereInput[] = []
if (currentUser.role === 'it') { if (currentUser.role === 'it') {
scopeConditions.push({ applied_by: currentUser.id }) scopeConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'mine') { } else if (currentUser.role === 'administrative') {
scopeConditions.push({ applied_by: currentUser.id }) if (resolvedScope === 'pending') {
} else if (resolvedScope === 'pending') { scopeConditions.push({ status: 'pending' })
scopeConditions.push({ status: 'pending' }) } else if (resolvedScope === 'processed') {
} else if (resolvedScope === 'processed') { scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
scopeConditions.push({ } else {
OR: [ return errorResponse(res, '行政仅可查看全部待审批和全部已处理报废流程', 403)
{ approved_by: currentUser.id }, }
{ completed_by: currentUser.id } } else if (currentUser.role === 'admin') {
] if (resolvedScope === 'mine') {
}) scopeConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'all' && currentUser.role !== 'admin') { } else if (resolvedScope === 'pending') {
return errorResponse(res, '只有管理员可以查看全部报废流程', 403) scopeConditions.push({ status: 'pending' })
} else if (!['all', 'mine', 'pending', 'processed'].includes(resolvedScope)) { } else if (resolvedScope === 'processed') {
return errorResponse(res, '报废列表范围无效', 422) scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
}
} else {
return errorResponse(res, '无权查看报废流程', 403)
} }
if (scopeConditions.length > 0) where.AND = scopeConditions if (status) scopeConditions.push({ status: String(status) })
if (status) where.status = String(status) if (equipment_id) scopeConditions.push({ equipment_id: Number(equipment_id) })
if (equipment_id) where.equipment_id = Number(equipment_id)
if (keyword) { if (keyword) {
const keywordWhere = [ const keywordWhere = [
{ equipment_code: { contains: String(keyword) } }, { equipment_code: { contains: String(keyword) } },
{ disposal_reason: { contains: String(keyword) } }, { disposal_reason: { contains: String(keyword) } },
{ items: { some: { equipment_code: { contains: String(keyword) } } } } { items: { some: { equipment_code: { contains: String(keyword) } } } }
] ]
where.AND = [...(Array.isArray(where.AND) ? where.AND : []), { OR: keywordWhere }] scopeConditions.push({ OR: keywordWhere })
} }
const where: Prisma.DisposalRecordWhereInput = scopeConditions.length > 0
? { AND: scopeConditions }
: {}
const skip = (Number(page) - 1) * Number(pageSize) const skip = (Number(page) - 1) * Number(pageSize)
const [list, total] = await Promise.all([ const [list, total] = await Promise.all([
@ -433,7 +454,11 @@ export const getDisposalList = async (req: Request, res: Response) => {
}), }),
prisma.disposalRecord.count({ where }) prisma.disposalRecord.count({ where })
]) ])
successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功') successResponse(
res,
{ list: list.map(withHistoricalDisposalNotes), total, page: Number(page), pageSize: Number(pageSize) },
'获取成功'
)
} catch (error: any) { } catch (error: any) {
errorResponse(res, error.message || '获取报废记录列表失败') errorResponse(res, error.message || '获取报废记录列表失败')
} }
@ -450,7 +475,7 @@ export const getDisposalDetail = async (req: Request, res: Response) => {
if (currentUser.role === 'it' && disposal.applied_by !== currentUser.id) { if (currentUser.role === 'it' && disposal.applied_by !== currentUser.id) {
return errorResponse(res, '无权查看该报废申请', 403) return errorResponse(res, '无权查看该报废申请', 403)
} }
successResponse(res, disposal, '获取成功') successResponse(res, withHistoricalDisposalNotes(disposal), '获取成功')
} catch (error: any) { } catch (error: any) {
errorResponse(res, error.message || '获取报废记录详情失败') errorResponse(res, error.message || '获取报废记录详情失败')
} }

View File

@ -76,7 +76,6 @@ const activeBorrowSelect = {
person_id: true, person_id: true,
person_name: true, person_name: true,
person_nickname: true, person_nickname: true,
person_identifier: true,
borrow_time: true, borrow_time: true,
status: true, status: true,
person: { person: {
@ -84,8 +83,7 @@ const activeBorrowSelect = {
id: true, id: true,
nickname: true, nickname: true,
real_name: true, real_name: true,
job_number: true, job_number: true
id_number: true
} }
} }
} }
@ -226,7 +224,6 @@ const mapCurrentBorrow = (borrowRecords: any[] | undefined) => {
person_id: record.person_id, person_id: record.person_id,
person_name: record.person_name, person_name: record.person_name,
person_nickname: record.person_nickname, person_nickname: record.person_nickname,
person_identifier: record.person_identifier,
borrow_time: record.borrow_time, borrow_time: record.borrow_time,
status: record.status, status: record.status,
person: record.person person: record.person
@ -234,8 +231,7 @@ const mapCurrentBorrow = (borrowRecords: any[] | undefined) => {
id: record.person.id, id: record.person.id,
nickname: record.person.nickname, nickname: record.person.nickname,
real_name: record.person.real_name, real_name: record.person.real_name,
job_number: record.person.job_number, job_number: record.person.job_number
id_number: record.person.id_number
} }
: null : null
} }

View File

@ -319,7 +319,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
where.OR = [ where.OR = [
{ person_name: { contains: String(person_name) } }, { person_name: { contains: String(person_name) } },
{ person_nickname: { contains: String(person_name) } }, { person_nickname: { contains: String(person_name) } },
{ person_identifier: { contains: String(person_name) } } { person: { is: { job_number: { contains: String(person_name) } } } }
] ]
} }
if (start_date || end_date) { if (start_date || end_date) {
@ -356,7 +356,6 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
{ header: '资产名称', key: 'equipment_name', width: 25 }, { header: '资产名称', key: 'equipment_name', width: 25 },
{ header: '使用者', key: 'person_name', width: 15 }, { header: '使用者', key: 'person_name', width: 15 },
{ header: '使用者昵称', key: 'person_nickname', width: 15 }, { header: '使用者昵称', key: 'person_nickname', width: 15 },
{ header: '借用时工号', key: 'person_identifier', width: 18 },
{ header: '当前工号', key: 'current_job_number', width: 18 }, { header: '当前工号', key: 'current_job_number', width: 18 },
{ header: '部门', key: 'department', width: 15 }, { header: '部门', key: 'department', width: 15 },
{ header: '借用时间', key: 'borrow_time', width: 20 }, { header: '借用时间', key: 'borrow_time', width: 20 },
@ -377,7 +376,6 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
equipment_name: record.equipment?.equipment_name || '', equipment_name: record.equipment?.equipment_name || '',
person_name: record.person_name, person_name: record.person_name,
person_nickname: record.person_nickname, person_nickname: record.person_nickname,
person_identifier: record.person_identifier || '',
current_job_number: record.person?.job_number || '', current_job_number: record.person?.job_number || '',
department: record.person?.mainDepartment?.name || record.person?.department || '', department: record.person?.mainDepartment?.name || record.person?.department || '',
borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '', borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '',

View File

@ -192,32 +192,39 @@ export const getMaintenanceDetail = async (req: Request, res: Response) => {
export const updateMaintenance = async (req: Request, res: Response) => { export const updateMaintenance = async (req: Request, res: Response) => {
try { try {
const { id } = req.params const { id } = req.params
const { repair_vendor, estimated_cost, expected_end_date, notes } = req.body const { repair_vendor, estimated_cost, expected_end_date } = req.body
const maintenanceId = Number(id)
const maintenance = await prisma.maintenanceRecord.findUnique({ if (!Number.isInteger(maintenanceId) || maintenanceId <= 0) {
where: { id: Number(id) } return errorResponse(res, '维修记录ID无效', 422)
})
if (!maintenance) {
return errorResponse(res, '维修记录不存在', 404)
} }
if (maintenance.status !== 'in_progress') { const result = await prisma.maintenanceRecord.updateMany({
return errorResponse(res, '只能更新进行中的维修记录') where: { id: maintenanceId, status: 'in_progress' },
}
const updated = await prisma.maintenanceRecord.update({
where: { id: Number(id) },
data: { data: {
repair_vendor, repair_vendor,
estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : undefined, estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : undefined,
expected_end_date: expected_end_date ? new Date(expected_end_date) : undefined expected_end_date: expected_end_date ? new Date(expected_end_date) : undefined
} }
}) })
if (result.count !== 1) {
const current = await prisma.maintenanceRecord.findUnique({
where: { id: maintenanceId },
select: { id: true, status: true }
})
if (!current) {
return errorResponse(res, '维修记录不存在', 404)
}
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
const updated = await prisma.maintenanceRecord.findUnique({
where: { id: maintenanceId }
})
successResponse(res, updated, '更新成功') successResponse(res, updated, '更新成功')
} catch (error: any) { } catch (error: any) {
errorResponse(res, error.message || '更新维修记录失败') errorResponse(res, error.message || '更新维修记录失败', error.status || 500)
} }
} }

View File

@ -97,6 +97,10 @@ const sanitizePerson = (person: any) => {
department_sync_bucket: person.department_sync_bucket, department_sync_bucket: person.department_sync_bucket,
manager_source_userid: person.manager_source_userid, manager_source_userid: person.manager_source_userid,
source_employee_status: person.source_employee_status, source_employee_status: person.source_employee_status,
dingtalk_ready: Boolean(
person.status === 'active' &&
person.source_userid?.trim()
),
last_synced_at: person.last_synced_at, last_synced_at: person.last_synced_at,
is_synced: Boolean(person.source_system), is_synced: Boolean(person.source_system),
created_at: person.created_at, created_at: person.created_at,
@ -104,6 +108,43 @@ const sanitizePerson = (person: any) => {
} }
} }
const sanitizeBusinessPerson = (person: any) => {
const departments = collectDepartments(person)
const mainDepartment = person.mainDepartment || departments[0] || null
return {
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
job_number: person.job_number,
department: mainDepartment?.name || person.department || null,
main_department: mainDepartment
? {
id: mainDepartment.id,
name: mainDepartment.name,
status: mainDepartment.status
}
: null,
departments: departments.map((department) => ({
id: department.id,
name: department.name,
status: department.status
})),
department_names: departments.map((department) => department.name),
position: person.position,
status: person.status,
dingtalk_ready: Boolean(
person.status === 'active' &&
person.source_userid?.trim()
)
}
}
const parsePositiveInteger = (value: unknown, fallback: number) => {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
const buildPersonWhere = (keyword?: unknown, status?: unknown) => { const buildPersonWhere = (keyword?: unknown, status?: unknown) => {
const where: any = {} const where: any = {}
@ -465,15 +506,30 @@ export const deletePerson = async (req: Request, res: Response) => {
export const getPersonOptions = async (req: Request, res: Response) => { export const getPersonOptions = async (req: Request, res: Response) => {
try { try {
const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true' const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true'
const persons = await prisma.person.findMany({ const page = parsePositiveInteger(req.query.page, 1)
where: includeInactive ? undefined : { status: 'active' }, const requestedPageSize = req.query.page_size ?? req.query.pageSize
const pageSize = Math.min(parsePositiveInteger(requestedPageSize, 50), 100)
const status = includeInactive ? undefined : (req.query.status || 'active')
const where = buildPersonWhere(req.query.keyword, status)
const [persons, total] = await Promise.all([
prisma.person.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: includeInactive orderBy: includeInactive
? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }] ? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }]
: [{ nickname: 'asc' }, { real_name: 'asc' }], : [{ nickname: 'asc' }, { real_name: 'asc' }],
include: personInclude include: personInclude
}) }),
prisma.person.count({ where })
])
successResponse(res, persons.map(sanitizePerson), '获取成功') successResponse(res, {
list: persons.map(sanitizeBusinessPerson),
total,
page,
page_size: pageSize
}, '获取成功')
} catch (error: any) { } catch (error: any) {
errorResponse(res, error.message || '获取使用者选项失败') errorResponse(res, error.message || '获取使用者选项失败')
} }

View File

@ -5,6 +5,7 @@ import {
createAssetLabelPrintJob as createAssetLabelPrintJobService, createAssetLabelPrintJob as createAssetLabelPrintJobService,
createPrintTerminalPairCode as createPrintTerminalPairCodeService, createPrintTerminalPairCode as createPrintTerminalPairCodeService,
heartbeatPrintTerminalAgent, heartbeatPrintTerminalAgent,
getDefaultPrintTerminalReadiness,
isPrintServiceError, isPrintServiceError,
listPrintJobs, listPrintJobs,
listPrintTerminals, listPrintTerminals,
@ -53,6 +54,15 @@ export const getPrintTerminals = async (req: Request, res: Response) => {
} }
} }
export const getPrintReadiness = async (_req: Request, res: Response) => {
try {
const terminal = await getDefaultPrintTerminalReadiness()
successResponse(res, { terminal }, '获取打印就绪状态成功')
} catch (error) {
handlePrintServiceError(res, error, '获取打印就绪状态失败')
}
}
export const createPrintTerminalPairCode = async (req: Request, res: Response) => { export const createPrintTerminalPairCode = async (req: Request, res: Response) => {
const userId = getCurrentUserId(res) const userId = getCurrentUserId(res)
if (!userId) { if (!userId) {
@ -142,9 +152,15 @@ export const cancelPrintJob = async (req: Request, res: Response) => {
if (!jobId) { if (!jobId) {
return errorResponse(res, '打印任务编号无效', 422) return errorResponse(res, '打印任务编号无效', 422)
} }
const userId = getCurrentUserId(res)
if (!userId) {
return errorResponse(res, '当前登录用户无效', 401)
}
const canManageAll = Array.isArray(res.locals.user?.permissions) &&
res.locals.user.permissions.includes('system.settings')
try { try {
await cancelQueuedPrintJob(jobId) await cancelQueuedPrintJob(jobId, userId, canManageAll)
successResponse(res, null, '打印任务已取消') successResponse(res, null, '打印任务已取消')
} catch (error) { } catch (error) {
handlePrintServiceError(res, error, '取消打印任务失败') handlePrintServiceError(res, error, '取消打印任务失败')

View File

@ -1,10 +1,12 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import type { Prisma } from '@prisma/client'
import prisma from '../utils/prisma' import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response' import { successResponse, errorResponse } from '../utils/response'
import { getPermissionsForRole, isUserRole } from '../utils/permissions' import { getPermissionsForRole, isUserRole } from '../utils/permissions'
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
const USER_STATUSES = ['active', 'inactive'] as const
class UserValidationError extends Error { class UserValidationError extends Error {
constructor(message: string, public readonly status = 422) { constructor(message: string, public readonly status = 422) {
@ -66,6 +68,38 @@ const normalizePersonId = (value: unknown) => {
return personId return personId
} }
const normalizeUserStatus = (value: unknown, fallback?: string) => {
const status = value === undefined || value === null || value === ''
? fallback
: String(value).trim()
if (!status || !USER_STATUSES.includes(status as (typeof USER_STATUSES)[number])) {
throw new UserValidationError('状态必须是 active 或 inactive')
}
return status
}
const getCurrentUserId = (res: Response) => {
const userId = Number((res as any).locals?.user?.id)
return Number.isInteger(userId) && userId > 0 ? userId : null
}
const lockActiveAdministrators = async (tx: Prisma.TransactionClient) => {
return tx.$queryRawUnsafe<Array<{ id: number }>>(
"SELECT id FROM `user` WHERE role = 'admin' AND status = 'active' ORDER BY id FOR UPDATE"
)
}
const assertActiveAdministratorRemains = async (
tx: Prisma.TransactionClient,
targetUserId: number
) => {
const activeAdministrators = await lockActiveAdministrators(tx)
const targetIsActiveAdministrator = activeAdministrators.some((item) => Number(item.id) === targetUserId)
if (targetIsActiveAdministrator && activeAdministrators.length <= 1) {
throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409)
}
}
const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => { const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => {
const person = await prisma.person.findUnique({ where: { id: personId } }) const person = await prisma.person.findUnique({ where: { id: personId } })
if (!person) { if (!person) {
@ -176,6 +210,7 @@ export const createUser = async (req: Request, res: Response) => {
if (!isUserRole(primaryRole)) { if (!isUserRole(primaryRole)) {
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422) return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
} }
const normalizedStatus = normalizeUserStatus(status, 'active')
const personId = normalizePersonId(person_id) const personId = normalizePersonId(person_id)
if (typeof personId === 'number') { if (typeof personId === 'number') {
@ -192,7 +227,7 @@ export const createUser = async (req: Request, res: Response) => {
email, email,
phone, phone,
role: primaryRole, role: primaryRole,
status: status || 'active', status: normalizedStatus,
person_id: personId ?? null person_id: personId ?? null
}, },
include: { person: { select: personSelect } } include: { person: { select: personSelect } }
@ -228,8 +263,18 @@ export const updateUser = async (req: Request, res: Response) => {
updateData.role = newRole updateData.role = newRole
} }
if (status) { if (status !== undefined) {
updateData.status = status updateData.status = normalizeUserStatus(status)
}
const currentUserId = getCurrentUserId(res)
if (currentUserId === user.id) {
if (newRole && newRole !== 'admin') {
return errorResponse(res, '不能降低当前登录管理员自己的角色', 422)
}
if (updateData.status && updateData.status !== 'active') {
return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
}
} }
const personId = normalizePersonId(person_id) const personId = normalizePersonId(person_id)
@ -240,11 +285,34 @@ export const updateUser = async (req: Request, res: Response) => {
updateData.person_id = null updateData.person_id = null
} }
const updated = await prisma.user.update({ const changesRoleOrStatus = Boolean(updateData.role || updateData.status)
where: { id: Number(id) },
data: updateData, const updated = changesRoleOrStatus
include: { person: { select: personSelect } } ? await prisma.$transaction(async (tx) => {
}) const activeAdministrators = await lockActiveAdministrators(tx)
const current = await tx.user.findUnique({ where: { id: user.id } })
if (!current) {
throw new UserValidationError('用户不存在', 404)
}
const finalRole = updateData.role || current.role
const finalStatus = updateData.status || current.status
const removesActiveAdministrator = activeAdministrators.some(
(item) => Number(item.id) === current.id
) && (finalRole !== 'admin' || finalStatus !== 'active')
if (removesActiveAdministrator && activeAdministrators.length <= 1) {
throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409)
}
return tx.user.update({
where: { id: current.id },
data: updateData,
include: { person: { select: personSelect } }
})
})
: await prisma.user.update({
where: { id: user.id },
data: updateData,
include: { person: { select: personSelect } }
})
successResponse(res, sanitizeUser(updated), '更新成功') successResponse(res, sanitizeUser(updated), '更新成功')
} catch (error: any) { } catch (error: any) {
@ -284,24 +352,33 @@ export const updateUserStatus = async (req: Request, res: Response) => {
const { id } = req.params const { id } = req.params
const { status } = req.body as { status?: string } const { status } = req.body as { status?: string }
if (!status) { const normalizedStatus = normalizeUserStatus(status)
return errorResponse(res, '状态不能为空', 422)
}
const user = await prisma.user.findUnique({ where: { id: Number(id) } }) const user = await prisma.user.findUnique({ where: { id: Number(id) } })
if (!user) { if (!user) {
return errorResponse(res, '用户不存在', 404) return errorResponse(res, '用户不存在', 404)
} }
const updated = await prisma.user.update({ if (getCurrentUserId(res) === user.id && normalizedStatus !== 'active') {
where: { id: Number(id) }, return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
data: { status }, }
include: { person: { select: personSelect } }
const updated = await prisma.$transaction(async (tx) => {
if (normalizedStatus !== 'active') {
await assertActiveAdministratorRemains(tx, user.id)
} else {
await lockActiveAdministrators(tx)
}
return tx.user.update({
where: { id: user.id },
data: { status: normalizedStatus },
include: { person: { select: personSelect } }
})
}) })
successResponse(res, sanitizeUser(updated), '状态更新成功') successResponse(res, sanitizeUser(updated), '状态更新成功')
} catch (error: any) { } catch (error: any) {
errorResponse(res, error.message || '更新用户状态失败') errorResponse(res, error.message || '更新用户状态失败', error.status || 500)
} }
} }
@ -311,6 +388,10 @@ export const deleteUser = async (req: Request, res: Response) => {
const { id } = req.params const { id } = req.params
const userId = Number(id) const userId = Number(id)
if (getCurrentUserId(res) === userId) {
return errorResponse(res, '不能删除当前登录账户', 422)
}
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
include: { include: {
@ -333,11 +414,6 @@ export const deleteUser = async (req: Request, res: Response) => {
return errorResponse(res, '用户不存在', 404) return errorResponse(res, '用户不存在', 404)
} }
// 不允许删除 admin 用户
if (user.username === 'admin') {
return errorResponse(res, '不能删除管理员账户')
}
if ( if (
user._count.site_configs > 0 || user._count.site_configs > 0 ||
user._count.mount_histories > 0 || user._count.mount_histories > 0 ||
@ -353,6 +429,7 @@ export const deleteUser = async (req: Request, res: Response) => {
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await assertActiveAdministratorRemains(tx, userId)
await tx.operationLog.deleteMany({ await tx.operationLog.deleteMany({
where: { user_id: userId } where: { user_id: userId }
}) })
@ -362,6 +439,6 @@ export const deleteUser = async (req: Request, res: Response) => {
successResponse(res, null, '删除成功') successResponse(res, null, '删除成功')
} catch (error: any) { } catch (error: any) {
errorResponse(res, error.message || '删除用户失败') errorResponse(res, error.message || '删除用户失败', error.status || 500)
} }
} }

View File

@ -1,7 +1,7 @@
import 'dotenv/config'
import path from 'path' import path from 'path'
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import dotenv from 'dotenv'
import session from 'express-session' import session from 'express-session'
import equipmentRoutes from './routes/equipment' import equipmentRoutes from './routes/equipment'
import borrowRoutes, { returnRouter } from './routes/borrow' import borrowRoutes, { returnRouter } from './routes/borrow'
@ -33,19 +33,18 @@ import printDownloadRoutes from './routes/printDownloads'
import myAssetRoutes from './routes/myAssets' import myAssetRoutes from './routes/myAssets'
import dingtalkRoutes from './routes/dingtalk' import dingtalkRoutes from './routes/dingtalk'
import { keycloak, memoryStore } from './utils/keycloak' import { keycloak, memoryStore } from './utils/keycloak'
import { protect, requirePermission } from './middleware/auth' import { protect } from './middleware/auth'
import { PERMISSIONS } from './utils/permissions'
import { startHrSyncScheduler } from './services/hrSyncService' import { startHrSyncScheduler } from './services/hrSyncService'
import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService' import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService'
import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService' import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService'
import { isCorsOriginAllowed } from './utils/corsPolicy' import { isCorsOriginAllowed } from './utils/corsPolicy'
import { unsafeOriginGuard } from './middleware/originGuard' import { unsafeOriginGuard } from './middleware/originGuard'
import { assertSupportedAuthMode, resolveSessionSecret } from './utils/authMode'
dotenv.config()
const app = express() const app = express()
const PORT = process.env.PORT || 3000 const PORT = process.env.PORT || 3000
const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secret' const AUTH_MODE = assertSupportedAuthMode()
const SESSION_SECRET = resolveSessionSecret()
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === undefined const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === undefined
? process.env.NODE_ENV === 'production' ? process.env.NODE_ENV === 'production'
: process.env.SESSION_COOKIE_SECURE === 'true' : process.env.SESSION_COOKIE_SECURE === 'true'
@ -73,7 +72,7 @@ app.use(
} }
}) })
) )
if (process.env.AUTH_MODE !== 'local') { if (AUTH_MODE === 'keycloak') {
app.use(keycloak.middleware({ logout: '/api/auth/logout', admin: '/keycloak' })) app.use(keycloak.middleware({ logout: '/api/auth/logout', admin: '/keycloak' }))
} }
@ -104,8 +103,8 @@ app.use('/api/sync', syncRoutes)
app.use('/api/notifications', notificationRoutes) app.use('/api/notifications', notificationRoutes)
app.use('/api/inventory', inventoryRoutes) app.use('/api/inventory', inventoryRoutes)
app.use('/api/public/inventory', publicInventoryRoutes) app.use('/api/public/inventory', publicInventoryRoutes)
app.use('/api/print-terminals', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), printTerminalRoutes) app.use('/api/print-terminals', protect(), printTerminalRoutes)
app.use('/api/print-jobs', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), printJobRoutes) app.use('/api/print-jobs', protect(), printJobRoutes)
app.use('/api/print-agent', printAgentRoutes) app.use('/api/print-agent', printAgentRoutes)
app.use('/api/my-assets', myAssetRoutes) app.use('/api/my-assets', myAssetRoutes)
app.use('/api/dingtalk', dingtalkRoutes) app.use('/api/dingtalk', dingtalkRoutes)

View File

@ -7,8 +7,9 @@ import {
} from '../utils/authIdentity' } from '../utils/authIdentity'
import { PERMISSIONS, type Permission } from '../utils/permissions' import { PERMISSIONS, type Permission } from '../utils/permissions'
import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService' import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService'
import { resolveAuthMode } from '../utils/authMode'
const isLocalAuth = () => process.env.AUTH_MODE === 'local' const isLocalAuth = () => resolveAuthMode() === 'local'
const hasRealBearerToken = (req: Request) => { const hasRealBearerToken = (req: Request) => {
const authorization = req.headers.authorization const authorization = req.headers.authorization

View File

@ -6,6 +6,7 @@ import bcrypt from 'bcryptjs'
import prisma from '../utils/prisma' import prisma from '../utils/prisma'
import { protect } from '../middleware/auth' import { protect } from '../middleware/auth'
import { resolveAuthUserById } from '../utils/authIdentity' import { resolveAuthUserById } from '../utils/authIdentity'
import { resolveAuthMode } from '../utils/authMode'
const router = Router() const router = Router()
@ -14,7 +15,7 @@ router.get('/user', protect(), (_req, res) => successResponse(res, res.locals.us
// Keycloak 配置信息(前端使用) // Keycloak 配置信息(前端使用)
router.get('/config', (req, res) => { router.get('/config', (req, res) => {
const authMode = process.env.AUTH_MODE === 'keycloak' ? 'keycloak' : 'local' const authMode = resolveAuthMode()
successResponse(res, { successResponse(res, {
authMode, authMode,
url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '', url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '',
@ -47,7 +48,7 @@ router.post('/logout', async (req, res) => {
// 本地登录模式(不跳转 Keycloak) // 本地登录模式(不跳转 Keycloak)
router.post('/local/login', async (req: Request, res: Response) => { router.post('/local/login', async (req: Request, res: Response) => {
try { try {
if (process.env.AUTH_MODE !== 'local') { if (resolveAuthMode() !== 'local') {
return errorResponse(res, '本地登录未启用', 400) return errorResponse(res, '本地登录未启用', 400)
} }
const { username, password } = req.body as { username?: string; password?: string } const { username, password } = req.body as { username?: string; password?: string }

View File

@ -15,9 +15,9 @@ const router = Router()
router.use(protect()) router.use(protect())
router.get('/', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonList) router.get('/', requirePermission(PERMISSIONS.USERS_MANAGE), getPersonList)
router.get('/options', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonOptions) router.get('/options', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonOptions)
router.get('/:id', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonDetail) router.get('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), getPersonDetail)
router.post('/', requirePermission(PERMISSIONS.USERS_MANAGE), createPerson) router.post('/', requirePermission(PERMISSIONS.USERS_MANAGE), createPerson)
router.put('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), updatePerson) router.put('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), updatePerson)

View File

@ -4,11 +4,13 @@ import {
createAssetLabelPrintJob, createAssetLabelPrintJob,
getPrintJobs getPrintJobs
} from '../controllers/printController' } from '../controllers/printController'
import { requirePermission } from '../middleware/auth'
import { PERMISSIONS } from '../utils/permissions'
const router = Router() const router = Router()
router.post('/asset-labels', createAssetLabelPrintJob) router.post('/asset-labels', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), createAssetLabelPrintJob)
router.get('/', getPrintJobs) router.get('/', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getPrintJobs)
router.patch('/:id/cancel', cancelPrintJob) router.patch('/:id/cancel', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), cancelPrintJob)
export default router export default router

View File

@ -1,16 +1,20 @@
import { Router } from 'express' import { Router } from 'express'
import { import {
createPrintTerminalPairCode, createPrintTerminalPairCode,
getPrintReadiness,
getPrintTerminals, getPrintTerminals,
setDefaultPrintTerminal, setDefaultPrintTerminal,
updatePrintTerminalStatus updatePrintTerminalStatus
} from '../controllers/printController' } from '../controllers/printController'
import { requirePermission } from '../middleware/auth'
import { PERMISSIONS } from '../utils/permissions'
const router = Router() const router = Router()
router.get('/', getPrintTerminals) router.get('/readiness', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), getPrintReadiness)
router.post('/pair-codes', createPrintTerminalPairCode) router.get('/', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getPrintTerminals)
router.patch('/:id/default', setDefaultPrintTerminal) router.post('/pair-codes', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), createPrintTerminalPairCode)
router.patch('/:id/status', updatePrintTerminalStatus) router.patch('/:id/default', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), setDefaultPrintTerminal)
router.patch('/:id/status', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), updatePrintTerminalStatus)
export default router export default router

View File

@ -2,7 +2,9 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { import {
borrowAssetTree,
finalizeBorrowAnomalyReturnState, finalizeBorrowAnomalyReturnState,
registerBorrowAnomalyAssetTree,
transferAssetTree transferAssetTree
} from '../assetMovementService' } from '../assetMovementService'
@ -153,3 +155,370 @@ test('异常归还在无活动借用时按锁后快照 CAS 恢复资产', async
assert.equal(updates[0].data.remaining_quantity, 1) assert.equal(updates[0].data.remaining_quantity, 1)
assert.equal(updates[0].data.department_id, null) assert.equal(updates[0].data.department_id, null)
}) })
const borrowPerson = {
id: 8,
real_name: '测试员工',
nickname: '员工',
job_number: 'E0008',
main_department_id: 9
}
const borrowRoot = {
...rootEquipment,
equipment_model: 'M720',
equipment_type: '主机'
}
test('借出时锁定递归资产树并同步根资产、配件部门及递归快照', async () => {
const child = {
...childEquipment,
equipment_model: '32G',
equipment_type: '内存条'
}
const grandchild = {
...child,
id: 22,
equipment_code: 'JS-RAM-0022',
equipment_name: '内存子配件',
equipment_model: '16G',
parent_id: child.id
}
const updates: any[] = []
let borrowRecordData: any = null
const childrenForParents = (parentIds: number[]) => {
if (parentIds.includes(borrowRoot.id)) return [child]
if (parentIds.includes(child.id)) return [grandchild]
return []
}
const client = {
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) return []
if (query.sql.includes('FROM borrow_record')) return []
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...borrowRoot }]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return childrenForParents(query.values as number[]).map((item) => ({ ...item }))
}
return []
},
equipment: {
findUnique: async () => ({ ...borrowRoot }),
findMany: async (args: any) => childrenForParents(args.where.parent_id.in).map((item) => ({ ...item })),
updateMany: async (args: any) => {
updates.push(args)
return { count: 1 }
}
},
borrowRecord: {
create: async ({ data }: any) => {
borrowRecordData = data
return { id: 701, borrow_time: now, ...data }
}
},
maintenanceRecord: { findFirst: async () => null },
disposalRecord: { findFirst: async () => null }
}
const result = await borrowAssetTree(client as any, {
rootEquipmentId: borrowRoot.id,
person: borrowPerson,
expectedReturnTime: null,
isLongTerm: true,
notes: '在职长期'
})
assert.equal(result.childrenCount, 2)
assert.equal(updates.length, 3)
assert.deepEqual(updates.map((item) => item.data.department_id), [9, 9, 9])
assert.equal(updates[0].data.status, '已借出')
assert.equal(updates[0].data.remaining_quantity, 0)
assert.deepEqual(updates.slice(1).map((item) => item.data.remaining_quantity), [0, 0])
const snapshot = JSON.parse(borrowRecordData.children_snapshot)
assert.deepEqual(snapshot.map((item: any) => ({ id: item.id, parent_id: item.parent_id, depth: item.depth })), [
{ id: 21, parent_id: 20, depth: 1 },
{ id: 22, parent_id: 21, depth: 2 }
])
})
test('借出初读后并发调拨部门时中止且不创建借用记录', async () => {
let transferredAfterInitialRead = false
let equipmentWriteCount = 0
let borrowCreateCount = 0
const client = {
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) {
transferredAfterInitialRead = true
return []
}
if (query.sql.includes('WHERE equipment.id = ?')) {
return [{ ...borrowRoot, department_id: transferredAfterInitialRead ? 12 : borrowRoot.department_id }]
}
if (query.sql.includes('WHERE equipment.parent_id IN')) return []
if (query.sql.includes('FROM borrow_record')) return []
return []
},
equipment: {
findUnique: async () => ({ ...borrowRoot }),
findMany: async () => [],
updateMany: async () => {
equipmentWriteCount += 1
return { count: 1 }
}
},
borrowRecord: {
create: async () => {
borrowCreateCount += 1
return { id: 1 }
}
}
}
await assert.rejects(
borrowAssetTree(client as any, {
rootEquipmentId: borrowRoot.id,
person: borrowPerson
}),
/资产树状态或挂载关系已变化/
)
assert.equal(equipmentWriteCount, 0)
assert.equal(borrowCreateCount, 0)
})
test('借出初读后并发挂载新配件时中止且不创建借用记录', async () => {
let mountedAfterInitialRead = false
let equipmentWriteCount = 0
let borrowCreateCount = 0
const newChild = {
...childEquipment,
equipment_model: '16G',
equipment_type: '内存条'
}
const client = {
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) {
mountedAfterInitialRead = true
return []
}
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...borrowRoot }]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return mountedAfterInitialRead && query.values.includes(borrowRoot.id) ? [{ ...newChild }] : []
}
if (query.sql.includes('FROM borrow_record')) return []
return []
},
equipment: {
findUnique: async () => ({ ...borrowRoot }),
findMany: async () => [],
updateMany: async () => {
equipmentWriteCount += 1
return { count: 1 }
}
},
borrowRecord: {
create: async () => {
borrowCreateCount += 1
return { id: 1 }
}
}
}
await assert.rejects(
borrowAssetTree(client as any, {
rootEquipmentId: borrowRoot.id,
person: borrowPerson
}),
/资产树状态或挂载关系已变化/
)
assert.equal(equipmentWriteCount, 0)
assert.equal(borrowCreateCount, 0)
})
test('同一资产并发重复借出时只有一个请求成功', async () => {
let state = { ...borrowRoot }
const records: any[] = []
let lockCount = 0
let releaseSecondLock!: () => void
const secondLockGate = new Promise<void>((resolve) => {
releaseSecondLock = resolve
})
const client = {
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) {
lockCount += 1
if (lockCount === 2) await secondLockGate
return []
}
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...state }]
if (query.sql.includes('WHERE equipment.parent_id IN')) return []
if (query.sql.includes('FROM borrow_record')) {
return records.map((record) => ({ id: record.id, equipment_id: record.equipment_id }))
}
return []
},
equipment: {
findUnique: async () => ({ ...state }),
findMany: async () => [],
updateMany: async (args: any) => {
state = { ...state, ...args.data }
releaseSecondLock()
return { count: 1 }
}
},
borrowRecord: {
create: async ({ data }: any) => {
const record = { id: records.length + 1, borrow_time: now, ...data }
records.push(record)
return record
}
},
maintenanceRecord: { findFirst: async () => null },
disposalRecord: { findFirst: async () => null }
}
const results = await Promise.allSettled([
borrowAssetTree(client as any, { rootEquipmentId: borrowRoot.id, person: borrowPerson }),
borrowAssetTree(client as any, { rootEquipmentId: borrowRoot.id, person: borrowPerson })
])
assert.equal(results.filter((item) => item.status === 'fulfilled').length, 1)
assert.equal(results.filter((item) => item.status === 'rejected').length, 1)
const rejected = results.find((item): item is PromiseRejectedResult => item.status === 'rejected')
assert.match(String(rejected?.reason?.message || ''), /资产树状态或挂载关系已变化/)
assert.equal(records.length, 1)
assert.equal(state.remaining_quantity, 0)
assert.equal(state.status, '已借出')
})
test('异常借用补登记锁定完整资产树并保存递归配件快照', async () => {
const anomalousRoot = {
...borrowRoot,
status: '已借出',
remaining_quantity: 0
}
const child = {
...childEquipment,
equipment_model: '32G',
equipment_type: '内存条',
status: '已借出',
remaining_quantity: 0
}
const grandchild = {
...child,
id: 22,
equipment_code: 'JS-SSD-0022',
equipment_name: '固态硬盘',
equipment_model: '1T',
equipment_type: '固态硬盘',
parent_id: child.id
}
const childrenForParents = (parentIds: number[]) => {
if (parentIds.includes(anomalousRoot.id)) return [child]
if (parentIds.includes(child.id)) return [grandchild]
return []
}
const updates: any[] = []
let borrowRecordData: any = null
const client = {
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) return []
if (query.sql.includes('FROM borrow_record')) return []
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...anomalousRoot }]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return childrenForParents(query.values as number[]).map((item) => ({ ...item }))
}
return []
},
equipment: {
findUnique: async () => ({ ...anomalousRoot }),
findMany: async (args: any) => childrenForParents(args.where.parent_id.in).map((item) => ({ ...item })),
updateMany: async (args: any) => {
updates.push(args)
return { count: 1 }
}
},
borrowRecord: {
create: async ({ data }: any) => {
borrowRecordData = data
return { id: 801, ...data }
}
},
maintenanceRecord: { findFirst: async () => null },
disposalRecord: { findFirst: async () => null }
}
const result = await registerBorrowAnomalyAssetTree(client as any, {
rootEquipmentId: anomalousRoot.id,
person: borrowPerson,
borrowTime: now,
notes: '补登记历史借出'
})
assert.equal(result.childrenCount, 2)
assert.equal(updates.length, 3)
assert.deepEqual(updates.map((item) => item.data.department_id), [9, 9, 9])
assert.deepEqual(updates.map((item) => item.data.remaining_quantity), [0, 0, 0])
const snapshot = JSON.parse(borrowRecordData.children_snapshot)
assert.deepEqual(snapshot.map((item: any) => ({ id: item.id, parent_id: item.parent_id, depth: item.depth })), [
{ id: 21, parent_id: 20, depth: 1 },
{ id: 22, parent_id: 21, depth: 2 }
])
})
test('异常借用补登记初读后并发挂载新配件时中止且不写入', async () => {
const anomalousRoot = {
...borrowRoot,
status: '已借出',
remaining_quantity: 0
}
const newChild = {
...childEquipment,
equipment_model: '16G',
equipment_type: '内存条',
status: '已借出',
remaining_quantity: 0
}
let mountedAfterInitialRead = false
let equipmentWriteCount = 0
let borrowCreateCount = 0
const client = {
$queryRaw: async (query: any) => {
if (query.sql.startsWith('SELECT id FROM equipment')) {
mountedAfterInitialRead = true
return []
}
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...anomalousRoot }]
if (query.sql.includes('WHERE equipment.parent_id IN')) {
return mountedAfterInitialRead && query.values.includes(anomalousRoot.id)
? [{ ...newChild }]
: []
}
if (query.sql.includes('FROM borrow_record')) return []
return []
},
equipment: {
findUnique: async () => ({ ...anomalousRoot }),
findMany: async () => [],
updateMany: async () => {
equipmentWriteCount += 1
return { count: 1 }
}
},
borrowRecord: {
create: async () => {
borrowCreateCount += 1
return { id: 1 }
}
}
}
await assert.rejects(
registerBorrowAnomalyAssetTree(client as any, {
rootEquipmentId: anomalousRoot.id,
person: borrowPerson,
borrowTime: now
}),
/资产树状态或挂载关系已变化/
)
assert.equal(equipmentWriteCount, 0)
assert.equal(borrowCreateCount, 0)
})

View File

@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { cancelQueuedPrintJob, PrintServiceError } from '../printService'
test('非管理员只能取消本人仍在排队的打印任务', async () => {
const originalFindUnique = prisma.printJob.findUnique
const originalUpdateMany = prisma.printJob.updateMany
let capturedWhere: any = null
;(prisma.printJob.findUnique as any) = async () => ({
id: 12,
status: 'queued',
requested_by: 7,
payload_json: '{}'
})
;(prisma.printJob.updateMany as any) = async ({ where }: any) => {
capturedWhere = where
return { count: 1 }
}
try {
await assert.rejects(
() => cancelQueuedPrintJob(12, 8, false),
(error: unknown) => error instanceof PrintServiceError && error.statusCode === 403
)
await cancelQueuedPrintJob(12, 7, false)
assert.equal(capturedWhere.requested_by, 7)
await cancelQueuedPrintJob(12, 99, true)
assert.equal('requested_by' in capturedWhere, false)
} finally {
;(prisma.printJob.findUnique as any) = originalFindUnique
;(prisma.printJob.updateMany as any) = originalUpdateMany
}
})
test('取消打印任务使用 queued 条件更新防止与打印终端领取竞争', async () => {
const originalFindUnique = prisma.printJob.findUnique
const originalUpdateMany = prisma.printJob.updateMany
;(prisma.printJob.findUnique as any) = async () => ({
id: 12,
status: 'queued',
requested_by: 7,
payload_json: '{}'
})
;(prisma.printJob.updateMany as any) = async () => ({ count: 0 })
try {
await assert.rejects(
() => cancelQueuedPrintJob(12, 7, false),
(error: unknown) => error instanceof PrintServiceError && error.statusCode === 409
)
} finally {
;(prisma.printJob.findUnique as any) = originalFindUnique
;(prisma.printJob.updateMany as any) = originalUpdateMany
}
})

View File

@ -7,6 +7,8 @@ export type AssetTreeItem = {
id: number id: number
equipment_code: string equipment_code: string
equipment_name: string equipment_name: string
equipment_model: string | null
equipment_type: string
parent_id: number | null parent_id: number | null
department_id: number | null department_id: number | null
storage_location_id: number | null storage_location_id: number | null
@ -22,6 +24,8 @@ const treeSelect = {
id: true, id: true,
equipment_code: true, equipment_code: true,
equipment_name: true, equipment_name: true,
equipment_model: true,
equipment_type: true,
parent_id: true, parent_id: true,
department_id: true, department_id: true,
storage_location_id: true, storage_location_id: true,
@ -64,7 +68,9 @@ const getAssetTreeFingerprint = (items: AssetTreeItem[]) => JSON.stringify(
department_id: item.department_id, department_id: item.department_id,
status: item.status, status: item.status,
mount_status: item.mount_status, mount_status: item.mount_status,
total_quantity: item.total_quantity,
remaining_quantity: item.remaining_quantity, remaining_quantity: item.remaining_quantity,
unit_price: item.unit_price.toString(),
updated_at: item.updated_at.getTime() updated_at: item.updated_at.getTime()
})) }))
.sort((left, right) => left.id - right.id) .sort((left, right) => left.id - right.id)
@ -82,6 +88,8 @@ const normalizeRawAssetTreeItem = (item: Record<string, unknown>): AssetTreeItem
id: Number(item.id), id: Number(item.id),
equipment_code: String(item.equipment_code || ''), equipment_code: String(item.equipment_code || ''),
equipment_name: String(item.equipment_name || ''), equipment_name: String(item.equipment_name || ''),
equipment_model: item.equipment_model === null ? null : String(item.equipment_model || ''),
equipment_type: String(item.equipment_type || ''),
parent_id: item.parent_id === null ? null : Number(item.parent_id), parent_id: item.parent_id === null ? null : Number(item.parent_id),
department_id: item.department_id === null ? null : Number(item.department_id), department_id: item.department_id === null ? null : Number(item.department_id),
storage_location_id: item.storage_location_id === null storage_location_id: item.storage_location_id === null
@ -150,6 +158,8 @@ const loadAssetTreeForUpdate = async (
equipment.id, equipment.id,
equipment.equipment_code, equipment.equipment_code,
equipment.equipment_name, equipment.equipment_name,
equipment.equipment_model,
equipment.equipment_type,
equipment.parent_id, equipment.parent_id,
equipment.department_id, equipment.department_id,
equipment.storage_location_id, equipment.storage_location_id,
@ -181,6 +191,8 @@ const loadAssetTreeForUpdate = async (
equipment.id, equipment.id,
equipment.equipment_code, equipment.equipment_code,
equipment.equipment_name, equipment.equipment_name,
equipment.equipment_model,
equipment.equipment_type,
equipment.parent_id, equipment.parent_id,
equipment.department_id, equipment.department_id,
equipment.storage_location_id, equipment.storage_location_id,
@ -225,6 +237,378 @@ export const loadAndLockAssetTree = async (
return afterLock return afterLock
} }
export type BorrowAssetSnapshotItem = {
id: number
equipment_code: string
equipment_name: string
equipment_model: string | null
equipment_type: string
unit_price: number
parent_id: number | null
parent_equipment_id: number | null
depth: number
status: string
department_id: number | null
}
export const buildRecursiveBorrowSnapshot = (
items: AssetTreeItem[]
): BorrowAssetSnapshotItem[] => {
if (items.length <= 1) return []
const depthById = new Map<number, number>([[items[0].id, 0]])
return items.slice(1).map((item) => {
const parentDepth = item.parent_id === null ? -1 : depthById.get(item.parent_id)
if (parentDepth === undefined || parentDepth < 0) {
throw new AssetTreeConflictError(`资产 ${item.equipment_code} 的父子关系不完整,请先检查资产关系`)
}
const depth = parentDepth + 1
depthById.set(item.id, depth)
return {
id: item.id,
equipment_code: item.equipment_code,
equipment_name: item.equipment_name,
equipment_model: item.equipment_model,
equipment_type: item.equipment_type,
unit_price: Number(item.unit_price),
parent_id: item.parent_id,
parent_equipment_id: item.parent_id,
depth,
status: item.status,
department_id: item.department_id
}
})
}
export const borrowAssetTree = async (
client: TransactionClient,
input: {
rootEquipmentId: number
person: {
id: number
real_name: string
nickname: string
job_number?: string | null
main_department_id?: number | null
}
expectedReturnTime?: Date | null
isLongTerm?: boolean
notes?: string | null
}
) => {
const items = await loadAndLockAssetTree(client, input.rootEquipmentId)
const root = items[0]
if (root.parent_id !== null) {
throw new AssetTreeConflictError('该子资产当前已挂载,请先卸载后再借用')
}
const blocked = items.find((item) => item.status !== '可用')
if (blocked) {
throw new AssetTreeConflictError(`资产 ${blocked.equipment_code} 当前为${blocked.status},不能借用`)
}
if (root.total_quantity <= 0 || root.remaining_quantity <= 0 ||
root.remaining_quantity > root.total_quantity) {
throw new AssetTreeConflictError('主资产库存与状态不一致,请先核查')
}
const invalidChildInventory = items.slice(1).find((item) =>
item.total_quantity <= 0 || item.remaining_quantity !== item.total_quantity
)
if (invalidChildInventory) {
throw new AssetTreeConflictError(
`配件 ${invalidChildInventory.equipment_code} 库存与挂载状态不一致,请先核查`
)
}
const inconsistentDepartment = items.slice(1).find(
(item) => item.department_id !== root.department_id
)
if (inconsistentDepartment) {
throw new AssetTreeConflictError(
`配件 ${inconsistentDepartment.equipment_code} 与主资产部门不一致,请先核查`
)
}
const equipmentIds = items.map((item) => item.id)
const activeBorrowRows = await client.$queryRaw<Array<{ id: number; equipment_id: number }>>(Prisma.sql`
SELECT id, equipment_id
FROM borrow_record
WHERE equipment_id IN (${Prisma.join(equipmentIds)}) AND status = ${'已借出'}
ORDER BY id
FOR UPDATE
`)
const rootActiveBorrowCount = activeBorrowRows.filter(
(row) => Number(row.equipment_id) === root.id
).length
const expectedRootBorrowCount = root.total_quantity - root.remaining_quantity
if (rootActiveBorrowCount !== expectedRootBorrowCount) {
throw new AssetTreeConflictError('主资产库存与未归还借用记录不一致,请先核查')
}
const occupiedChild = activeBorrowRows.find((row) => Number(row.equipment_id) !== root.id)
if (occupiedChild) {
throw new AssetTreeConflictError('资产树中的配件存在未归还借用记录,请先核查')
}
const [activeMaintenance, activeDisposal] = await Promise.all([
client.maintenanceRecord.findFirst({
where: { equipment_id: { in: equipmentIds }, status: 'in_progress' },
select: { id: true }
}),
client.disposalRecord.findFirst({
where: {
status: { in: ['pending', 'approved'] },
OR: [
{ equipment_id: { in: equipmentIds } },
{ items: { some: { equipment_id: { in: equipmentIds } } } }
]
},
select: { id: true }
})
])
if (activeMaintenance) {
throw new AssetTreeConflictError('资产树存在进行中的维修记录,不能借用')
}
if (activeDisposal) {
throw new AssetTreeConflictError('资产树存在进行中的报废申请,不能借用')
}
const targetDepartmentId = input.person.main_department_id ?? root.department_id
const nextRemainingQuantity = root.remaining_quantity - 1
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
const rootValues = calculateEquipmentValues({
unitPrice: Number(root.unit_price),
totalQuantity: root.total_quantity,
remainingQuantity: nextRemainingQuantity,
status: nextStatus
})
const updatedRoot = 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: nextRemainingQuantity,
total_value: rootValues.total_value,
borrowed_value: rootValues.borrowed_value,
available_value: rootValues.available_value,
status: nextStatus,
department_id: targetDepartmentId
}
})
if (updatedRoot.count !== 1) {
throw new AssetTreeConflictError('主资产库存、归属或挂载状态已变化,请刷新后重试')
}
for (const child of items.slice(1)) {
const childValues = calculateEquipmentValues({
unitPrice: Number(child.unit_price),
totalQuantity: child.total_quantity,
remainingQuantity: 0,
status: '已借出'
})
const updatedChild = await client.equipment.updateMany({
where: {
id: child.id,
parent_id: child.parent_id,
department_id: child.department_id,
status: child.status,
mount_status: child.mount_status,
total_quantity: child.total_quantity,
remaining_quantity: child.remaining_quantity,
unit_price: child.unit_price,
updated_at: child.updated_at
},
data: {
status: '已借出',
remaining_quantity: 0,
total_value: childValues.total_value,
borrowed_value: childValues.borrowed_value,
available_value: childValues.available_value,
department_id: targetDepartmentId
}
})
if (updatedChild.count !== 1) {
throw new AssetTreeConflictError(
`配件 ${child.equipment_code} 的状态、归属或挂载关系已变化,请刷新后重试`
)
}
}
const snapshotItems = buildRecursiveBorrowSnapshot(items)
const record = await client.borrowRecord.create({
data: {
equipment_id: root.id,
equipment_code: root.equipment_code,
person_id: input.person.id,
person_name: input.person.real_name,
person_nickname: input.person.nickname,
person_identifier: input.person.job_number || null,
expected_return_time: input.expectedReturnTime ?? null,
is_long_term: Boolean(input.isLongTerm),
notes: input.notes?.trim() || null,
status: '已借出',
children_snapshot: snapshotItems.length > 0 ? JSON.stringify(snapshotItems) : null
}
})
return {
record,
items,
snapshotItems,
childrenCount: snapshotItems.length,
updatedRoot: {
...root,
department_id: targetDepartmentId,
status: nextStatus,
remaining_quantity: nextRemainingQuantity
}
}
}
export const registerBorrowAnomalyAssetTree = async (
client: TransactionClient,
input: {
rootEquipmentId: number
person: {
id: number
real_name: string
nickname: string
job_number?: string | null
main_department_id?: number | null
}
borrowTime: Date
notes?: string | null
}
) => {
const items = await loadAndLockAssetTree(client, input.rootEquipmentId)
const root = items[0]
if (root.parent_id !== null) {
throw new AssetTreeConflictError('该资产当前作为已挂载子资产存在,请先处理所属父资产')
}
if (root.status !== '已借出' || root.total_quantity !== 1 || root.remaining_quantity !== 0) {
throw new AssetTreeConflictError('该资产当前不存在可补登记的借用单缺失问题')
}
const blockedChild = items.slice(1).find((item) =>
!['可用', '已借出'].includes(item.status) ||
item.total_quantity <= 0 ||
(item.status === '可用' && item.remaining_quantity !== item.total_quantity) ||
(item.status === '已借出' && item.remaining_quantity !== 0)
)
if (blockedChild) {
throw new AssetTreeConflictError(
`配件 ${blockedChild.equipment_code} 的状态或库存与主资产借用状态不一致,请先核查`
)
}
const equipmentIds = items.map((item) => item.id)
const activeBorrowRows = await client.$queryRaw<Array<{ id: number; equipment_id: number }>>(Prisma.sql`
SELECT id, equipment_id
FROM borrow_record
WHERE equipment_id IN (${Prisma.join(equipmentIds)}) AND status = ${'已借出'}
ORDER BY id
FOR UPDATE
`)
if (activeBorrowRows.length > 0) {
throw new AssetTreeConflictError('资产树中已存在未归还借用记录,不能重复补登记')
}
const [activeMaintenance, activeDisposal] = await Promise.all([
client.maintenanceRecord.findFirst({
where: { equipment_id: { in: equipmentIds }, status: 'in_progress' },
select: { id: true }
}),
client.disposalRecord.findFirst({
where: {
status: { in: ['pending', 'approved'] },
OR: [
{ equipment_id: { in: equipmentIds } },
{ items: { some: { equipment_id: { in: equipmentIds } } } }
]
},
select: { id: true }
})
])
if (activeMaintenance) {
throw new AssetTreeConflictError('资产树存在进行中的维修记录,不能补登记借用')
}
if (activeDisposal) {
throw new AssetTreeConflictError('资产树存在进行中的报废申请,不能补登记借用')
}
const targetDepartmentId = input.person.main_department_id ?? root.department_id
for (const item of items) {
const values = calculateEquipmentValues({
unitPrice: Number(item.unit_price),
totalQuantity: item.total_quantity,
remainingQuantity: 0,
status: '已借出'
})
const updated = 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,
total_quantity: item.total_quantity,
remaining_quantity: item.remaining_quantity,
unit_price: item.unit_price,
updated_at: item.updated_at
},
data: {
status: '已借出',
remaining_quantity: 0,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
department_id: targetDepartmentId
}
})
if (updated.count !== 1) {
throw new AssetTreeConflictError(
`资产 ${item.equipment_code} 的状态、归属或挂载关系已变化,请刷新后重试`
)
}
}
const snapshotItems = buildRecursiveBorrowSnapshot(items)
const record = await client.borrowRecord.create({
data: {
equipment_id: root.id,
equipment_code: root.equipment_code,
person_id: input.person.id,
person_name: input.person.real_name,
person_nickname: input.person.nickname,
person_identifier: input.person.job_number || null,
borrow_time: input.borrowTime,
notes: input.notes?.trim() || null,
status: '已借出',
children_snapshot: snapshotItems.length > 0 ? JSON.stringify(snapshotItems) : null
}
})
return {
record,
items,
snapshotItems,
childrenCount: snapshotItems.length,
updatedRoot: {
...root,
department_id: targetDepartmentId,
status: '已借出',
remaining_quantity: 0
}
}
}
const getDepartmentName = async (client: TransactionClient, departmentId: number | null) => { const getDepartmentName = async (client: TransactionClient, departmentId: number | null) => {
if (!departmentId) return null if (!departmentId) return null
const department = await client.department.findUnique({ const department = await client.department.findUnique({

View File

@ -10,6 +10,7 @@ import {
} from '../utils/inventoryScope' } from '../utils/inventoryScope'
import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft' import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft'
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle' import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
import { PUBLIC_ASSET_PERSON_SOURCE_USERID } from '../utils/systemReservedPerson'
type ScopeEntity = { type ScopeEntity = {
id: number id: number
@ -27,6 +28,14 @@ type FrozenParticipantDraft = {
department_name: string | null department_name: string | null
} }
const sanitizeInventoryParticipant = <T extends { person_source_userid?: string | null }>(participant: T) => {
const { person_source_userid: sourceUserId, ...safeParticipant } = participant
return {
...safeParticipant,
is_public_asset_person: sourceUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID
}
}
type FrozenItemDraft = { type FrozenItemDraft = {
participant_person_id: number | null participant_person_id: number | null
equipment_id: number equipment_id: number
@ -400,7 +409,7 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => {
unassigned_count: unassignedCount, unassigned_count: unassignedCount,
department_breakdown: buildDepartmentBreakdown(scope.items), department_breakdown: buildDepartmentBreakdown(scope.items),
asset_type_breakdown: buildAssetTypeBreakdown(scope.items), asset_type_breakdown: buildAssetTypeBreakdown(scope.items),
participants_sample: scope.participants.slice(0, 10), participants_sample: scope.participants.slice(0, 10).map(sanitizeInventoryParticipant),
items_sample: scope.items.slice(0, 10) items_sample: scope.items.slice(0, 10)
} }
} }
@ -787,7 +796,7 @@ export const getInventoryTaskDetail = async (taskId: number) => {
return { return {
...serializeTaskListItem(task), ...serializeTaskListItem(task),
...serializedScope, ...serializedScope,
participants: task.participants, participants: task.participants.map(sanitizeInventoryParticipant),
items: task.items, items: task.items,
recent_actions: task.actions, recent_actions: task.actions,
department_breakdown: departmentBreakdown, department_breakdown: departmentBreakdown,

View File

@ -1080,6 +1080,34 @@ export const listPrintTerminals = async () => {
) )
} }
export const getDefaultPrintTerminalReadiness = async () => {
await markStalePrintTerminalsOffline()
const terminals = await prisma.printTerminal.findMany({
where: { is_default: true },
orderBy: DEFAULT_TERMINAL_ORDER_BY
})
const preferred = resolvePreferredDefaultTerminal(terminals)
if (!preferred) return null
const mapped = mapTerminalRecord({
...preferred,
recentFailedJob: null
}, {
preferredDefaultTerminalId: preferred.id
})
return {
id: mapped.id,
name: mapped.name,
status: mapped.status,
is_default: true,
last_seen_at: mapped.last_seen_at,
selected_printer_name: mapped.selected_printer_name,
readiness: mapped.readiness
}
}
export const setDefaultPrintTerminal = async (terminalId: number) => { export const setDefaultPrintTerminal = async (terminalId: number) => {
const existing = await prisma.printTerminal.findUnique({ const existing = await prisma.printTerminal.findUnique({
where: { id: terminalId } where: { id: terminalId }
@ -1346,7 +1374,11 @@ export const listPrintJobs = async (
} }
} }
export const cancelQueuedPrintJob = async (jobId: number) => { export const cancelQueuedPrintJob = async (
jobId: number,
requestedBy: number,
allowAnyRequester: boolean = false
) => {
const existing = await prisma.printJob.findUnique({ const existing = await prisma.printJob.findUnique({
where: { id: jobId } where: { id: jobId }
}) })
@ -1359,12 +1391,23 @@ export const cancelQueuedPrintJob = async (jobId: number) => {
throw new PrintServiceError('只有排队中的任务可以取消', 422) throw new PrintServiceError('只有排队中的任务可以取消', 422)
} }
await prisma.printJob.update({ if (!allowAnyRequester && existing.requested_by !== requestedBy) {
where: { id: jobId }, throw new PrintServiceError('只能取消本人提交的排队任务', 403)
}
const cancelled = await prisma.printJob.updateMany({
where: {
id: jobId,
status: PRINT_JOB_STATUS.queued,
...(!allowAnyRequester ? { requested_by: requestedBy } : {})
},
data: { data: {
status: PRINT_JOB_STATUS.cancelled status: PRINT_JOB_STATUS.cancelled
} }
}) })
if (cancelled.count !== 1) {
throw new PrintServiceError('打印任务状态已变化,请刷新后重试', 409)
}
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json)) await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json))
} }

View File

@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
assertKeycloakConfiguration,
assertSupportedAuthMode,
resolveAuthMode,
resolveSessionSecret
} from '../authMode'
test('认证模式缺省时统一使用本地登录', () => {
assert.equal(resolveAuthMode(undefined), 'local')
assert.equal(resolveAuthMode(''), 'local')
assert.equal(resolveAuthMode('keycloak'), 'keycloak')
})
test('认证模式拒绝无法识别的部署值', () => {
assert.throws(() => assertSupportedAuthMode('oauth'), /仅支持 local 或 keycloak/)
})
test('生产环境拒绝缺失或默认 Session 密钥', () => {
assert.throws(() => resolveSessionSecret('', 'production'), /SESSION_SECRET/)
assert.throws(
() => resolveSessionSecret('change-me-session-secret', 'production'),
/SESSION_SECRET/
)
assert.equal(resolveSessionSecret('a-real-production-secret', 'production'), 'a-real-production-secret')
assert.equal(
resolveSessionSecret(undefined, 'development'),
'equipment-management-development-secret'
)
})
test('Keycloak 模式缺少前后端共同必需项时启动失败', () => {
assert.throws(
() => assertKeycloakConfiguration({ AUTH_MODE: 'keycloak' }),
/KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID/
)
assert.throws(
() => assertKeycloakConfiguration({
AUTH_MODE: 'keycloak',
KEYCLOAK_URL: 'not-a-url',
KEYCLOAK_REALM: 'equipment',
KEYCLOAK_CLIENT_ID: 'equipment-client'
}),
/http\/https/
)
assert.deepEqual(
assertKeycloakConfiguration({
AUTH_MODE: 'keycloak',
KEYCLOAK_URL: 'https://sso.example.com/',
KEYCLOAK_REALM: 'equipment',
KEYCLOAK_CLIENT_ID: 'equipment-client'
}),
{
serverUrl: 'https://sso.example.com',
realm: 'equipment',
clientId: 'equipment-client',
clientSecret: undefined
}
)
assert.equal(assertKeycloakConfiguration({ AUTH_MODE: 'local' }), null)
})

View File

@ -8,11 +8,13 @@ test('管理员拥有完整权限集合', () => {
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY)) assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY))
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_VIEW_OWN)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_VIEW_OWN))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
}) })
test('行政可以登记和审批但不能借还、调拨或挂载', () => { test('行政可以登记和审批但不能借还、调拨或挂载', () => {
const permissions = getPermissionsForRole('administrative') const permissions = getPermissionsForRole('administrative')
assert.ok(permissions.includes(PERMISSIONS.ASSETS_CREATE)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_CREATE))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_CORRECT)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_CORRECT))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_BORROW)) assert.ok(!permissions.includes(PERMISSIONS.ASSETS_BORROW))
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_TRANSFER)) assert.ok(!permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
@ -23,6 +25,7 @@ test('IT可以流转和发起报废但不能审批或管理用户', () => {
const permissions = getPermissionsForRole('it') const permissions = getPermissionsForRole('it')
assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY)) assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY))
assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
assert.ok(!permissions.includes(PERMISSIONS.USERS_MANAGE)) assert.ok(!permissions.includes(PERMISSIONS.USERS_MANAGE))

View File

@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import test from 'node:test'
test('历史报废纠正迁移只把旧驳回备注回填为驳回原因', () => {
const migrationPath = path.resolve(
__dirname,
'../../../prisma/migrations/20260716_mainline_stage1_safety/migration.sql'
)
const sql = readFileSync(migrationPath, 'utf8')
assert.match(sql, /SET\s+`reject_reason`\s*=\s*`notes`/i)
assert.match(sql, /WHERE\s+`status`\s*=\s*'rejected'/i)
assert.doesNotMatch(sql, /SET\s+`completion_notes`\s*=/i)
assert.doesNotMatch(sql, /WHERE\s+`status`\s*=\s*'completed'/i)
})
test('历史借用人标识只清理明确身份证格式且保护当前员工工号', () => {
const migrationPath = path.resolve(
__dirname,
'../../../prisma/migrations/20260716_mainline_stage1_safety/migration.sql'
)
const sql = readFileSync(migrationPath, 'utf8')
assert.match(sql, /SET\s+borrow_record\.`person_identifier`\s*=\s*NULL/i)
assert.match(sql, /\^\[1-9\]\[0-9\]\{14\}\$/)
assert.match(sql, /\^\[1-9\]\[0-9\]\{16\}\[0-9Xx\]\$/)
assert.match(sql, /borrow_record\.`person_identifier`\s*<>\s*person\.`job_number`/i)
})

View File

@ -0,0 +1,54 @@
export type AuthMode = 'local' | 'keycloak'
export const resolveAuthMode = (value: unknown = process.env.AUTH_MODE): AuthMode =>
String(value || '').trim().toLowerCase() === 'keycloak' ? 'keycloak' : 'local'
export const assertSupportedAuthMode = (value: unknown = process.env.AUTH_MODE) => {
const normalized = String(value || '').trim().toLowerCase()
if (normalized && normalized !== 'local' && normalized !== 'keycloak') {
throw new Error(`AUTH_MODE 仅支持 local 或 keycloak,当前值为 ${normalized}`)
}
return resolveAuthMode(normalized)
}
export const assertKeycloakConfiguration = (
env: Record<string, string | undefined> = process.env
) => {
if (assertSupportedAuthMode(env.AUTH_MODE) !== 'keycloak') return null
const requiredKeys = ['KEYCLOAK_URL', 'KEYCLOAK_REALM', 'KEYCLOAK_CLIENT_ID'] as const
const missingKeys = requiredKeys.filter((key) => !String(env[key] || '').trim())
if (missingKeys.length > 0) {
throw new Error(`Keycloak 认证缺少必要配置:${missingKeys.join(', ')}`)
}
const serverUrl = String(env.KEYCLOAK_URL).trim()
let parsedUrl: URL
try {
parsedUrl = new URL(serverUrl)
} catch {
throw new Error('KEYCLOAK_URL 必须是有效的 http/https 地址')
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('KEYCLOAK_URL 必须是有效的 http/https 地址')
}
return {
serverUrl: serverUrl.replace(/\/$/, ''),
realm: String(env.KEYCLOAK_REALM).trim(),
clientId: String(env.KEYCLOAK_CLIENT_ID).trim(),
clientSecret: String(env.KEYCLOAK_CLIENT_SECRET || '').trim() || undefined
}
}
export const resolveSessionSecret = (
value: unknown = process.env.SESSION_SECRET,
nodeEnv: unknown = process.env.NODE_ENV
) => {
const secret = String(value || '').trim()
const isProduction = String(nodeEnv || '').trim().toLowerCase() === 'production'
if (isProduction && (!secret || secret === 'change-me-session-secret')) {
throw new Error('生产环境必须配置独立且非默认的 SESSION_SECRET')
}
return secret || 'equipment-management-development-secret'
}

View File

@ -1,5 +1,6 @@
import session from 'express-session' import session from 'express-session'
import Keycloak from 'keycloak-connect' import Keycloak from 'keycloak-connect'
import { assertKeycloakConfiguration } from './authMode'
type KeycloakEnvConfig = { type KeycloakEnvConfig = {
serverUrl: string serverUrl: string
@ -8,19 +9,13 @@ type KeycloakEnvConfig = {
clientSecret?: string clientSecret?: string
} }
const requiredEnv = ['KEYCLOAK_URL', 'KEYCLOAK_REALM', 'KEYCLOAK_CLIENT_ID'] as const const validatedKeycloakConfig = assertKeycloakConfiguration()
requiredEnv.forEach((key) => {
if (!process.env[key]) {
console.warn(`[Keycloak] 环境变量 ${key} 未设置,Keycloak 认证可能无法正常工作。`)
}
})
const keycloakConfig: KeycloakEnvConfig = { const keycloakConfig: KeycloakEnvConfig = {
serverUrl: process.env.KEYCLOAK_URL || 'http://localhost:8080', serverUrl: validatedKeycloakConfig?.serverUrl || 'http://localhost:8080',
realm: process.env.KEYCLOAK_REALM || 'equipment-management', realm: validatedKeycloakConfig?.realm || 'equipment-management',
clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client', clientId: validatedKeycloakConfig?.clientId || 'equipment-client',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET clientSecret: validatedKeycloakConfig?.clientSecret
} }
const memoryStore = new session.MemoryStore() const memoryStore = new session.MemoryStore()

View File

@ -13,6 +13,7 @@ export const PERMISSIONS = {
ASSETS_TRANSFER: 'assets.transfer', ASSETS_TRANSFER: 'assets.transfer',
ASSETS_MAINTENANCE: 'assets.maintenance', ASSETS_MAINTENANCE: 'assets.maintenance',
ASSETS_MOUNT: 'assets.mount', ASSETS_MOUNT: 'assets.mount',
ASSETS_PRINT_LABELS: 'assets.print_labels',
DISPOSALS_APPLY: 'disposals.apply', DISPOSALS_APPLY: 'disposals.apply',
DISPOSALS_APPROVE: 'disposals.approve', DISPOSALS_APPROVE: 'disposals.approve',
DISPOSALS_COMPLETE: 'disposals.complete', DISPOSALS_COMPLETE: 'disposals.complete',
@ -35,6 +36,7 @@ const ROLE_PERMISSIONS: Record<UserRole, readonly Permission[]> = {
PERMISSIONS.ASSETS_CREATE, PERMISSIONS.ASSETS_CREATE,
PERMISSIONS.ASSETS_IMPORT, PERMISSIONS.ASSETS_IMPORT,
PERMISSIONS.ASSETS_CORRECT, PERMISSIONS.ASSETS_CORRECT,
PERMISSIONS.ASSETS_PRINT_LABELS,
PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_APPROVE,
PERMISSIONS.DISPOSALS_COMPLETE, PERMISSIONS.DISPOSALS_COMPLETE,
PERMISSIONS.INVENTORY_MANAGE, PERMISSIONS.INVENTORY_MANAGE,
@ -47,6 +49,7 @@ const ROLE_PERMISSIONS: Record<UserRole, readonly Permission[]> = {
PERMISSIONS.ASSETS_TRANSFER, PERMISSIONS.ASSETS_TRANSFER,
PERMISSIONS.ASSETS_MAINTENANCE, PERMISSIONS.ASSETS_MAINTENANCE,
PERMISSIONS.ASSETS_MOUNT, PERMISSIONS.ASSETS_MOUNT,
PERMISSIONS.ASSETS_PRINT_LABELS,
PERMISSIONS.DISPOSALS_APPLY, PERMISSIONS.DISPOSALS_APPLY,
PERMISSIONS.NOTIFICATIONS_VIEW, PERMISSIONS.NOTIFICATIONS_VIEW,
PERMISSIONS.REPORTS_VIEW PERMISSIONS.REPORTS_VIEW

234
deploy.sh
View File

@ -8,6 +8,7 @@ COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml"
PROJECT_NAME="equipment-management" PROJECT_NAME="equipment-management"
BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh" BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh"
DISPOSAL_PRECHECK_SQL="$PROJECT_DIR/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql" DISPOSAL_PRECHECK_SQL="$PROJECT_DIR/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql"
DISPOSAL_MIGRATION_NAME="20260714_asset_permissions_disposal_dingtalk"
MYSQL_CONTAINER="equipment-mysql" MYSQL_CONTAINER="equipment-mysql"
SKIP_BACKUP="${SKIP_BACKUP:-0}" SKIP_BACKUP="${SKIP_BACKUP:-0}"
CONFIRM_ACTIVE_DISPOSALS_REVIEWED="${CONFIRM_ACTIVE_DISPOSALS_REVIEWED:-}" CONFIRM_ACTIVE_DISPOSALS_REVIEWED="${CONFIRM_ACTIVE_DISPOSALS_REVIEWED:-}"
@ -15,6 +16,10 @@ HEALTHCHECK_ATTEMPTS="${HEALTHCHECK_ATTEMPTS:-12}"
HEALTHCHECK_INTERVAL_SECONDS="${HEALTHCHECK_INTERVAL_SECONDS:-5}" HEALTHCHECK_INTERVAL_SECONDS="${HEALTHCHECK_INTERVAL_SECONDS:-5}"
HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}" HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}"
EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql") EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql")
DISPOSAL_PRECHECK_REQUIRED=0
INITIAL_DISPOSAL_FINGERPRINT=""
CURRENT_DISPOSAL_FINGERPRINT=""
CURRENT_ACTIVE_DISPOSAL_COUNT=0
get_container_status() { get_container_status() {
local container="$1" local container="$1"
@ -77,62 +82,72 @@ run_equipment_mysql() {
' sh "$@" ' sh "$@"
} }
run_disposal_migration_precheck() { get_disposal_migration_state() {
local mysql_status tree_anomaly_count active_rows active_count active_fingerprint local migrations_table_exists state
migrations_table_exists="$(
printf '%s\n' \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '_prisma_migrations';" |
run_equipment_mysql --batch --skip-column-names --raw
)"
migrations_table_exists="$(printf '%s' "$migrations_table_exists" | tr -d '[:space:]')"
if [ "$migrations_table_exists" = "0" ]; then
printf 'pending\n'
return 0
fi
if [ "$migrations_table_exists" != "1" ]; then
echo "无法确认 Prisma 迁移表状态。" >&2
return 1
fi
state="$(run_equipment_mysql --batch --skip-column-names --raw <<SQL
SELECT CASE
WHEN SUM(CASE WHEN finished_at IS NULL AND rolled_back_at IS NULL THEN 1 ELSE 0 END) > 0 THEN 'incomplete'
WHEN SUM(CASE WHEN finished_at IS NOT NULL AND rolled_back_at IS NULL THEN 1 ELSE 0 END) > 0 THEN 'applied'
ELSE 'pending'
END
FROM _prisma_migrations
WHERE migration_name = '$DISPOSAL_MIGRATION_NAME';
SQL
)"
state="$(printf '%s' "$state" | tr -d '[:space:]')"
case "$state" in
applied|pending|incomplete) printf '%s\n' "$state" ;;
*) echo "报废迁移状态不可解析: $state" >&2; return 1 ;;
esac
}
calculate_active_disposal_fingerprint() {
local display_rows="${1:-0}"
local snapshot_output missing_root_count tree_anomaly_count review_row_count
local actual_review_row_count review_rows active_fingerprint active_count
echo ">>> 执行报废流程迁移前只读核查..."
if [ ! -r "$DISPOSAL_PRECHECK_SQL" ]; then if [ ! -r "$DISPOSAL_PRECHECK_SQL" ]; then
echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2 echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2
return 1 return 1
fi fi
if ! snapshot_output="$(
mysql_status="$(get_container_status "$MYSQL_CONTAINER")" run_equipment_mysql --batch --skip-column-names --raw < "$DISPOSAL_PRECHECK_SQL"
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 )"; then
echo "无法确认未完成报废资产树是否存在环或超深层级,已阻止部署。" >&2 echo "报废流程迁移前一致性快照核查执行失败,已阻止部署。" >&2
return 1 return 1
fi fi
tree_anomaly_count="$(printf '%s' "$tree_anomaly_count" | tr -d '[:space:]')"
missing_root_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "MISSING_ROOT_COUNT" { print $2 }')"
tree_anomaly_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "TREE_ANOMALY_COUNT" { print $2 }')"
active_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "ACTIVE_DISPOSAL_COUNT" { print $2 }')"
review_row_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW_COUNT" { print $2 }')"
review_rows="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW" { sub(/^[^\t]*\t/, ""); print }')"
if ! [[ "$missing_root_count" =~ ^[0-9]+$ ]]; then
echo "缺失根资产数量不可解析,已阻止部署。" >&2
return 1
fi
if [ "$missing_root_count" -gt 0 ]; then
echo "检测到 $missing_root_count 条未完成报废单缺少根资产,已阻止部署。" >&2
return 1
fi
if ! [[ "$tree_anomaly_count" =~ ^[0-9]+$ ]]; then if ! [[ "$tree_anomaly_count" =~ ^[0-9]+$ ]]; then
echo "资产树异常数量不可解析,已阻止部署。" >&2 echo "资产树异常数量不可解析,已阻止部署。" >&2
return 1 return 1
@ -143,31 +158,81 @@ SQL
return 1 return 1
fi fi
if ! active_rows="$( if ! [[ "$active_count" =~ ^[0-9]+$ ]]; then
printf '%s\n' \ echo "未完成报废单数量不可解析,已阻止部署。" >&2
"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;" | return 1
run_equipment_mysql --batch --skip-column-names --raw fi
)"; then if ! [[ "$review_row_count" =~ ^[0-9]+$ ]]; then
echo "无法读取未完成报废单指纹数据,已阻止部署。" >&2 echo "人工核对行数量不可解析,已阻止部署。" >&2
return 1
fi
actual_review_row_count="$(
printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW" { count += 1 } END { print count + 0 }'
)"
if [ "$actual_review_row_count" -ne "$review_row_count" ] || \
{ [ "$active_count" -gt 0 ] && [ "$review_row_count" -lt "$active_count" ]; }; then
echo "一致性快照的统计与人工核对行不一致,已阻止部署。" >&2
return 1 return 1
fi fi
active_count="$(printf '%s\n' "$active_rows" | awk 'NF { count += 1 } END { print count + 0 }')"
if [ "$active_count" -gt 0 ]; then if [ "$display_rows" = "1" ]; then
if ! command -v sha256sum >/dev/null 2>&1; then echo ">>> 同一一致性快照中的未完成报废单及完整资产树:"
echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2 if [ "$review_row_count" -gt 0 ]; then
return 1 printf '%s\n' "$review_rows"
else
echo " (无未完成报废单)"
fi fi
active_fingerprint="$(printf '%s\n' "$active_rows" | sha256sum | awk '{ print $1 }')" fi
if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then
echo "未完成报废单指纹不可解析,已阻止部署。" >&2 if ! command -v sha256sum >/dev/null 2>&1; then
echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2
return 1
fi
# 人工展示与指纹使用同一个 snapshot_output 中的 REVIEW_ROW,避免二次查询产生漂移。
active_fingerprint="$(printf '%s\n' "$review_rows" | sha256sum | awk '{ print $1 }')"
if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then
echo "未完成报废单指纹不可解析,已阻止部署。" >&2
return 1
fi
CURRENT_ACTIVE_DISPOSAL_COUNT="$active_count"
CURRENT_DISPOSAL_FINGERPRINT="$active_fingerprint"
}
run_disposal_migration_precheck() {
local mysql_status migration_state
mysql_status="$(get_container_status "$MYSQL_CONTAINER")"
if [ "$mysql_status" != "running" ]; then
echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2
return 1
fi
migration_state="$(get_disposal_migration_state)"
case "$migration_state" in
applied)
echo ">>> 报废流程迁移已应用,跳过一次性迁移前核查。"
return 0
;;
incomplete)
echo "检测到未完成且未回滚的报废流程迁移记录,已阻止部署;请先恢复 Prisma 迁移状态。" >&2
return 1 return 1
fi ;;
echo "检测到 $active_count 条未完成报废单。" >&2 pending)
echo "本次未完成报废单指纹: $active_fingerprint" >&2 DISPOSAL_PRECHECK_REQUIRED=1
if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]; then ;;
esac
echo ">>> 执行报废流程迁移前只读核查..."
calculate_active_disposal_fingerprint 1
INITIAL_DISPOSAL_FINGERPRINT="$CURRENT_DISPOSAL_FINGERPRINT"
if [ "$CURRENT_ACTIVE_DISPOSAL_COUNT" -gt 0 ]; then
echo "检测到 $CURRENT_ACTIVE_DISPOSAL_COUNT 条未完成报废单。" >&2
echo "本次完整资产树指纹: $CURRENT_DISPOSAL_FINGERPRINT" >&2
if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$CURRENT_DISPOSAL_FINGERPRINT" ]; then
echo "已默认阻止部署。请业务负责人逐单核对上方资产树。" >&2 echo "已默认阻止部署。请业务负责人逐单核对上方资产树。" >&2
echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$active_fingerprint。" >&2 echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$CURRENT_DISPOSAL_FINGERPRINT。" >&2
return 1 return 1
fi fi
echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。" echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。"
@ -176,6 +241,39 @@ SQL
fi fi
} }
recheck_disposal_migration_precheck() {
local migration_state
if [ "$DISPOSAL_PRECHECK_REQUIRED" != "1" ]; then
return 0
fi
echo ">>> 镜像构建后复核报废资产树是否变化..."
migration_state="$(get_disposal_migration_state)"
case "$migration_state" in
applied)
echo "报废流程迁移已由其他发布成功应用,允许继续。"
return 0
;;
incomplete)
echo "构建期间出现未完成的报废流程迁移记录,已阻止更新容器。" >&2
return 1
;;
esac
calculate_active_disposal_fingerprint
if [ "$CURRENT_DISPOSAL_FINGERPRINT" != "$INITIAL_DISPOSAL_FINGERPRINT" ]; then
echo "镜像构建期间未完成报废单或完整资产树发生变化,已阻止更新容器。" >&2
echo "构建前指纹: $INITIAL_DISPOSAL_FINGERPRINT" >&2
echo "当前指纹: $CURRENT_DISPOSAL_FINGERPRINT" >&2
return 1
fi
echo "报废单及完整资产树指纹未变化。"
}
if [ "${DEPLOY_SH_FUNCTIONS_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
if docker compose version >/dev/null 2>&1; then if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE") COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE")
elif command -v docker-compose >/dev/null 2>&1; then elif command -v docker-compose >/dev/null 2>&1; then
@ -228,6 +326,8 @@ run_disposal_migration_precheck
echo ">>> 先构建固定资产系统镜像..." echo ">>> 先构建固定资产系统镜像..."
"${COMPOSE_CMD[@]}" build "${COMPOSE_CMD[@]}" build
recheck_disposal_migration_precheck
echo ">>> 镜像构建成功,开始更新固定资产系统容器..." echo ">>> 镜像构建成功,开始更新固定资产系统容器..."
"${COMPOSE_CMD[@]}" up -d --remove-orphans "${COMPOSE_CMD[@]}" up -d --remove-orphans

View File

@ -33,10 +33,15 @@ services:
- "3000:3000" - "3000:3000"
environment: environment:
DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4 DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4
SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret} NODE_ENV: production
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
AUTH_MODE: ${AUTH_MODE:-local} AUTH_MODE: ${AUTH_MODE:-local}
KEYCLOAK_URL: ${KEYCLOAK_URL:-}
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-}
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-}
PORT: ${PORT:-3000} PORT: ${PORT:-3000}
SQLSERVER_HOST: ${SQLSERVER_HOST:-} SQLSERVER_HOST: ${SQLSERVER_HOST:-}
SQLSERVER_PORT: ${SQLSERVER_PORT:-1433} SQLSERVER_PORT: ${SQLSERVER_PORT:-1433}
@ -79,7 +84,10 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
VITE_AUTH_MODE: ${VITE_AUTH_MODE:-local} VITE_AUTH_MODE: ${AUTH_MODE:-local}
VITE_KEYCLOAK_URL: ${KEYCLOAK_URL:-}
VITE_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-}
VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-}
container_name: equipment-frontend container_name: equipment-frontend
restart: always restart: always
ports: ports:

View File

@ -5,10 +5,16 @@ WORKDIR /app
# 构建参数 # 构建参数
ARG VITE_API_BASE_URL=http://localhost:3000/api ARG VITE_API_BASE_URL=http://localhost:3000/api
ARG VITE_AUTH_MODE=local ARG VITE_AUTH_MODE=local
ARG VITE_KEYCLOAK_URL=
ARG VITE_KEYCLOAK_REALM=equipment-management
ARG VITE_KEYCLOAK_CLIENT_ID=equipment-client
# 设置环境变量 # 设置环境变量
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_AUTH_MODE=$VITE_AUTH_MODE ENV VITE_AUTH_MODE=$VITE_AUTH_MODE
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL
ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
# 安装依赖 # 安装依赖
COPY package*.json ./ COPY package*.json ./

View File

@ -54,7 +54,10 @@ const byRealName = filterManualReturnRecordCandidates(sampleRecords as any, '周
assert.equal(byRealName[0].id, 102, '姓名搜索应支持') assert.equal(byRealName[0].id, 102, '姓名搜索应支持')
const byIdentifier = filterManualReturnRecordCandidates(sampleRecords as any, 'A1001') const byIdentifier = filterManualReturnRecordCandidates(sampleRecords as any, 'A1001')
assert.equal(byIdentifier[0].id, 101, '工号搜索应支持') assert.equal(byIdentifier.length, 0, '历史身份证或旧标识不应作为业务搜索条件')
const byPhone = filterManualReturnRecordCandidates(sampleRecords as any, '13800000001')
assert.equal(byPhone.length, 0, '手机号不应作为业务搜索条件')
const byDepartment = filterManualReturnRecordCandidates(sampleRecords as any, '设计') const byDepartment = filterManualReturnRecordCandidates(sampleRecords as any, '设计')
assert.equal(byDepartment[0].id, 101, '部门搜索应支持') assert.equal(byDepartment[0].id, 101, '部门搜索应支持')

View File

@ -29,7 +29,6 @@ export interface ReturnSearchAssetCandidate {
storageLocation?: StorageLocation | null storageLocation?: StorageLocation | null
active_borrow_count: number active_borrow_count: number
borrower_names?: string[] borrower_names?: string[]
borrower_identifiers?: string[]
borrower_departments?: string[] borrower_departments?: string[]
can_return?: boolean can_return?: boolean
can_handle_borrow_anomaly?: boolean can_handle_borrow_anomaly?: boolean

View File

@ -1,6 +1,37 @@
import api from './index' import api from './index'
import type { ApiResponse, PaginatedResponse, Person } from '@/types' import type { ApiResponse, PaginatedResponse, Person } from '@/types'
export type PersonOption = Pick<
Person,
| 'id'
| 'nickname'
| 'real_name'
| 'job_number'
| 'department'
| 'main_department'
| 'departments'
| 'department_names'
| 'position'
| 'status'
> & {
dingtalk_ready: boolean
}
export interface PersonOptionListData {
list: PersonOption[]
total: number
page: number
page_size: number
}
export interface PersonOptionListParams {
page?: number
pageSize?: number
keyword?: string
status?: 'active' | 'inactive'
includeInactive?: boolean
}
export const getPersonList = async (params: { export const getPersonList = async (params: {
page?: number page?: number
pageSize?: number pageSize?: number
@ -62,10 +93,14 @@ export const deletePerson = async (id: number) => {
return response.data return response.data
} }
export const getPersonOptions = async (params?: { includeInactive?: boolean }) => { export const getPersonOptions = async (params: PersonOptionListParams = {}) => {
const response = await api.get<ApiResponse<Person[]>>('/persons/options', { const response = await api.get<ApiResponse<PersonOptionListData>>('/persons/options', {
params: { params: {
include_inactive: params?.includeInactive ? 'true' : undefined page: params.page,
page_size: params.pageSize,
keyword: params.keyword,
status: params.status,
include_inactive: params.includeInactive ? 'true' : undefined
} }
}) })
return response.data return response.data

View File

@ -326,6 +326,32 @@ export const getPrintTerminals = async () => {
return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal) return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal)
} }
export const getPrintTerminalReadiness = async (): Promise<PrintTerminal | null> => {
const response = await api.get<ApiResponse<unknown>>('/print-terminals/readiness')
const data = toRecord(response.data.data)
const rawTerminal = toRecord(data.default_terminal ?? data.terminal ?? data)
const terminalId = toNumber(rawTerminal.id ?? rawTerminal.terminal_id, 0)
if (!terminalId) {
return null
}
const readinessValue = rawTerminal.readiness ?? data.readiness ?? {
is_ready: rawTerminal.is_ready ?? data.is_ready,
reason: rawTerminal.readiness_reason ?? data.readiness_reason,
message: rawTerminal.readiness_message ?? data.readiness_message
}
return normalizePrintTerminal({
...rawTerminal,
id: terminalId,
name: rawTerminal.name ?? rawTerminal.terminal_name,
is_default: true,
selected_printer_name: rawTerminal.selected_printer_name ?? rawTerminal.printer_name,
readiness: readinessValue
})
}
export const createAssetLabelPrintJob = async ( export const createAssetLabelPrintJob = async (
payload: CreateAssetLabelPrintJobPayload payload: CreateAssetLabelPrintJobPayload
): Promise<CreateAssetLabelPrintJobResult> => { ): Promise<CreateAssetLabelPrintJobResult> => {

View File

@ -11,6 +11,7 @@ export const PERMISSIONS = {
ASSETS_TRANSFER: 'assets.transfer', ASSETS_TRANSFER: 'assets.transfer',
ASSETS_MAINTENANCE: 'assets.maintenance', ASSETS_MAINTENANCE: 'assets.maintenance',
ASSETS_MOUNT: 'assets.mount', ASSETS_MOUNT: 'assets.mount',
ASSETS_PRINT_LABELS: 'assets.print_labels',
DISPOSALS_APPLY: 'disposals.apply', DISPOSALS_APPLY: 'disposals.apply',
DISPOSALS_APPROVE: 'disposals.approve', DISPOSALS_APPROVE: 'disposals.approve',
DISPOSALS_COMPLETE: 'disposals.complete', DISPOSALS_COMPLETE: 'disposals.complete',

View File

@ -46,6 +46,29 @@
<p v-if="terminalError" class="text-sm text-status-warning">{{ terminalError }}</p> <p v-if="terminalError" class="text-sm text-status-warning">{{ terminalError }}</p>
</div> </div>
<div v-if="latestRemoteJob" class="panel flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-semibold text-text-primary">本次远程打印任务 #{{ latestRemoteJob.id }}</p>
<span class="badge" :class="getJobStatusBadgeClass(latestRemoteJob.status)">
{{ getJobStatusText(latestRemoteJob.status) }}
</span>
</div>
<p class="mt-1 text-sm text-text-secondary">
{{ latestRemoteJob.target_terminal_name || defaultTerminal?.name || '默认打印电脑' }} · {{ equipmentList.length }} 张标签
</p>
</div>
<button
v-if="latestRemoteJob.status === 'queued'"
type="button"
class="btn-secondary text-sm"
:disabled="cancelSubmitting"
@click="handleCancelRemoteJob"
>
{{ cancelSubmitting ? '取消中...' : '取消本次任务' }}
</button>
</div>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div <div
v-for="equipment in equipmentList" v-for="equipment in equipmentList"
@ -91,8 +114,8 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { getEquipmentBatchForBarcode } from '@/api/barcode' import { getEquipmentBatchForBarcode } from '@/api/barcode'
import { createAssetLabelPrintJob, getPrintTerminals } from '@/api/print' import { cancelQueuedPrintJob, createAssetLabelPrintJob, getPrintTerminalReadiness } from '@/api/print'
import type { PrintTerminal } from '@/types' import type { PrintJob, PrintTerminal } from '@/types'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { import {
buildLocalBatchPrintHtml, buildLocalBatchPrintHtml,
@ -149,8 +172,10 @@ const previewImages = ref<Record<number, string>>({})
const loading = ref(false) const loading = ref(false)
const terminalLoading = ref(false) const terminalLoading = ref(false)
const remoteSubmitting = ref(false) const remoteSubmitting = ref(false)
const cancelSubmitting = ref(false)
const defaultTerminal = ref<PrintTerminal | null>(null) const defaultTerminal = ref<PrintTerminal | null>(null)
const terminalError = ref<string | null>(null) const terminalError = ref<string | null>(null)
const latestRemoteJob = ref<PrintJob | null>(null)
const DISPLAY_FONT = '"Arial Black", "Bahnschrift", "Segoe UI", sans-serif' const DISPLAY_FONT = '"Arial Black", "Bahnschrift", "Segoe UI", sans-serif'
const BODY_FONT = '"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif' const BODY_FONT = '"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif'
@ -183,24 +208,7 @@ const getProfileText = (terminal?: PrintTerminal | null) => {
return terminal.readiness.message return terminal.readiness.message
} }
const profile = terminal.label_profile return terminal.readiness?.is_ready ? '60 × 40 已就绪' : '待管理员检查配置'
if (!profile) {
return '未完成 60x40 配置'
}
if (profile.message) {
return profile.message
}
if (profile.is_ready === true || profile.configured === true) {
return profile.paper_size ? `${profile.paper_size} 已就绪` : '标签配置已完成'
}
if (profile.paper_size) {
return `${profile.paper_size} 待确认`
}
return '未完成 60x40 配置'
} }
const remoteStatusBadgeClass = computed(() => { const remoteStatusBadgeClass = computed(() => {
@ -225,7 +233,6 @@ const remoteStatusText = computed(() => {
const remotePrinterName = computed(() => { const remotePrinterName = computed(() => {
return ( return (
defaultTerminal.value?.selected_printer_name || defaultTerminal.value?.selected_printer_name ||
defaultTerminal.value?.printer_snapshot?.[0]?.name ||
'未选择打印机' '未选择打印机'
) )
}) })
@ -240,11 +247,11 @@ const remoteStatusDescription = computed(() => {
} }
if (terminalError.value) { if (terminalError.value) {
return '暂时无法读取办公室打印状态,建议先刷新系统设置里的打印终端。' return '暂时无法读取办公室打印状态,请联系管理员检查打印终端。'
} }
if (!defaultTerminal.value) { if (!defaultTerminal.value) {
return '当前还没有设置默认打印电脑,请先到系统设置里的“打印终端”完成配对。' return '当前还没有设置默认打印电脑,请联系管理员完成终端配置。'
} }
if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) { if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) {
@ -268,6 +275,23 @@ const remoteActionDisabled = computed(() => {
return defaultTerminal.value.status === 'disabled' return defaultTerminal.value.status === 'disabled'
}) })
const getJobStatusText = (status?: string | null) => {
if (status === 'queued') return '排队中'
if (status === 'claimed') return '已领取'
if (status === 'printing') return '打印中'
if (status === 'completed') return '已完成'
if (status === 'failed') return '失败'
if (status === 'cancelled') return '已取消'
return status || '未知'
}
const getJobStatusBadgeClass = (status?: string | null) => {
if (status === 'completed') return 'badge-success'
if (status === 'failed') return 'badge-error'
if (status === 'queued' || status === 'claimed' || status === 'printing') return 'badge-warning'
return 'badge-neutral'
}
const loadImage = (src: string) => const loadImage = (src: string) =>
new Promise<HTMLImageElement>((resolve, reject) => { new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image() const image = new Image()
@ -677,8 +701,7 @@ const fetchDefaultTerminal = async () => {
terminalError.value = null terminalError.value = null
try { try {
const terminals = await getPrintTerminals() defaultTerminal.value = await getPrintTerminalReadiness()
defaultTerminal.value = terminals.find((item) => item.is_default) || null
} catch (error: any) { } catch (error: any) {
console.error('获取打印终端失败:', error) console.error('获取打印终端失败:', error)
terminalError.value = error?.message || '获取打印终端失败' terminalError.value = error?.message || '获取打印终端失败'
@ -717,7 +740,7 @@ const handleRemotePrint = async () => {
} }
if (!defaultTerminal.value) { if (!defaultTerminal.value) {
alert('当前还没有配置默认打印电脑,请先到系统设置里的“打印终端”完成配对') alert('当前还没有配置默认打印电脑,请联系管理员处理')
return return
} }
@ -744,6 +767,7 @@ const handleRemotePrint = async () => {
rendered_labels: renderedLabels, rendered_labels: renderedLabels,
target_terminal_id: targetTerminalId target_terminal_id: targetTerminalId
}) })
latestRemoteJob.value = result.job
const targetTerminalName = result.job.target_terminal_name || defaultTerminal.value.name const targetTerminalName = result.job.target_terminal_name || defaultTerminal.value.name
alert(result.queue_notice || `标签已发送到 ${targetTerminalName}`) alert(result.queue_notice || `标签已发送到 ${targetTerminalName}`)
@ -757,6 +781,23 @@ const handleRemotePrint = async () => {
} }
} }
const handleCancelRemoteJob = async () => {
const job = latestRemoteJob.value
if (!job || job.status !== 'queued') return
if (!window.confirm(`确定要取消本次打印任务 #${job.id} 吗?`)) return
cancelSubmitting.value = true
try {
await cancelQueuedPrintJob(job.id)
latestRemoteJob.value = { ...job, status: 'cancelled' }
alert('本次打印任务已取消')
} catch (error: any) {
alert(error?.message || '取消打印任务失败')
} finally {
cancelSubmitting.value = false
}
}
const handlePrint = async () => { const handlePrint = async () => {
if (equipmentList.value.length === 0) { if (equipmentList.value.length === 0) {
alert('没有可打印的二维码') alert('没有可打印的二维码')

View File

@ -90,7 +90,7 @@
v-model.trim="personKeyword" v-model.trim="personKeyword"
type="text" type="text"
class="input" class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索" placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
@keyup.enter="handlePersonKeywordSubmit" @keyup.enter="handlePersonKeywordSubmit"
/> />
<p class="text-xs text-text-tertiary"> <p class="text-xs text-text-tertiary">
@ -99,7 +99,7 @@
<div v-if="showPersonSearchResults" class="space-y-2"> <div v-if="showPersonSearchResults" class="space-y-2">
<div class="flex items-center justify-between gap-3 px-1"> <div class="flex items-center justify-between gap-3 px-1">
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p> <p class="text-xs text-text-tertiary">支持花名、姓名、工号和部门搜索。</p>
<p class="text-xs text-text-tertiary whitespace-nowrap"> <p class="text-xs text-text-tertiary whitespace-nowrap">
{{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }} {{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }}
</p> </p>
@ -129,7 +129,6 @@
</div> </div>
<p class="mt-1 text-xs text-text-secondary"> <p class="mt-1 text-xs text-text-secondary">
{{ person.department || '未填写部门' }} {{ person.department || '未填写部门' }}
<span v-if="person.phone"> · {{ person.phone }}</span>
<span v-if="person.job_number"> · {{ person.job_number }}</span> <span v-if="person.job_number"> · {{ person.job_number }}</span>
</p> </p>
</div> </div>
@ -205,13 +204,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { getPersonOptions } from '@/api/person' import { getPersonOptions, type PersonOption } from '@/api/person'
import { import {
registerBorrowAnomaly, registerBorrowAnomaly,
resolveBorrowAnomalyAsReturned resolveBorrowAnomalyAsReturned
} from '@/api/borrow' } from '@/api/borrow'
import type { Person } from '@/types'
import { import {
buildManualBorrowPersonDisplay, buildManualBorrowPersonDisplay,
filterManualBorrowPersons, filterManualBorrowPersons,
@ -246,7 +244,7 @@ const emit = defineEmits<{
}>() }>()
const mode = ref<'mark-returned' | 'register-borrow'>('mark-returned') const mode = ref<'mark-returned' | 'register-borrow'>('mark-returned')
const persons = ref<Person[]>([]) const persons = ref<PersonOption[]>([])
const personsLoading = ref(false) const personsLoading = ref(false)
const selectedPersonId = ref<number | null>(null) const selectedPersonId = ref<number | null>(null)
const personKeyword = ref('') const personKeyword = ref('')
@ -254,6 +252,8 @@ const borrowDate = ref('')
const notes = ref('') const notes = ref('')
const submitting = ref(false) const submitting = ref(false)
const feedback = ref<{ type: 'warning' | 'error'; message: string } | null>(null) const feedback = ref<{ type: 'warning' | 'error'; message: string } | null>(null)
let personSearchTimer: number | null = null
let latestPersonRequestId = 0
const selectedPerson = computed(() => { const selectedPerson = computed(() => {
if (selectedPersonId.value === null) { if (selectedPersonId.value === null) {
@ -285,21 +285,37 @@ const resetForm = () => {
feedback.value = null feedback.value = null
} }
const ensurePersonsLoaded = async () => { const fetchPersons = async (keyword = '') => {
if (persons.value.length > 0 || personsLoading.value) { const requestId = ++latestPersonRequestId
return
}
personsLoading.value = true personsLoading.value = true
try { try {
const response = await getPersonOptions({ includeInactive: true }) const response = await getPersonOptions({
persons.value = response.data || [] page: 1,
pageSize: 20,
keyword: keyword || undefined,
includeInactive: true
})
if (requestId === latestPersonRequestId) {
persons.value = response.data?.list || []
}
} catch (error: any) {
if (requestId === latestPersonRequestId) {
persons.value = []
feedback.value = {
type: 'error',
message: error?.message || '使用者搜索失败,请稍后重试。'
}
}
} finally { } finally {
personsLoading.value = false if (requestId === latestPersonRequestId) {
personsLoading.value = false
}
} }
} }
const selectPerson = (person: Person) => { const ensurePersonsLoaded = async () => fetchPersons(personKeyword.value.trim())
const selectPerson = (person: PersonOption) => {
selectedPersonId.value = person.id selectedPersonId.value = person.id
personKeyword.value = buildManualBorrowPersonDisplay(person) personKeyword.value = buildManualBorrowPersonDisplay(person)
} }
@ -412,6 +428,29 @@ watch(
if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) { if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) {
selectedPersonId.value = null selectedPersonId.value = null
} }
if (!props.modelValue) {
return
}
if (personSearchTimer) {
window.clearTimeout(personSearchTimer)
}
if (shouldKeepCurrentSelection) {
personsLoading.value = false
return
}
personSearchTimer = window.setTimeout(() => {
fetchPersons(value.trim())
}, 300)
} }
) )
onUnmounted(() => {
if (personSearchTimer) {
window.clearTimeout(personSearchTimer)
}
})
</script> </script>

View File

@ -0,0 +1,231 @@
<template>
<div class="space-y-3">
<div
v-if="modelValue"
class="flex items-start justify-between gap-3 rounded-lg border border-border bg-bg-tertiary px-4 py-3"
>
<div class="min-w-0">
<p class="text-xs text-text-tertiary">当前绑定</p>
<p class="mt-1 truncate text-sm font-medium text-text-primary">{{ selectedLabel }}</p>
<p v-if="selectedMeta" class="mt-1 text-xs text-text-secondary">{{ selectedMeta }}</p>
</div>
<button type="button" class="btn-secondary shrink-0" @click="clearSelection">
{{ clearLabel }}
</button>
</div>
<div class="field-stack">
<input
v-model="keyword"
type="search"
class="input"
autocomplete="off"
placeholder="输入花名、姓名、工号或部门搜索"
/>
<p class="field-hint">输入关键词后才查询花名册,每次加载 {{ PAGE_SIZE }} 人。</p>
</div>
<div
v-if="normalizedKeyword"
class="overflow-hidden rounded-lg border border-border bg-bg-secondary"
>
<div v-if="loading && results.length === 0" class="px-4 py-5 text-center text-sm text-text-secondary">
正在搜索员工...
</div>
<div v-else-if="errorMessage && results.length === 0" class="px-4 py-4 text-sm text-status-error">
<p>{{ errorMessage }}</p>
<button type="button" class="btn-secondary mt-3" @click="search(true)">重新搜索</button>
</div>
<div v-else-if="hasSearched && results.length === 0" class="px-4 py-5 text-center text-sm text-text-secondary">
没有找到匹配的员工
</div>
<div v-else-if="results.length > 0" class="divide-y divide-border">
<button
v-for="person in results"
:key="person.id"
type="button"
class="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-bg-tertiary disabled:cursor-not-allowed disabled:opacity-55"
:disabled="!isSelectable(person)"
@click="selectPerson(person)"
>
<span class="min-w-0">
<span class="block truncate text-sm font-medium text-text-primary">{{ formatName(person) }}</span>
<span class="mt-1 block truncate text-xs text-text-secondary">{{ formatMeta(person) }}</span>
</span>
<span :class="['badge shrink-0', isSelectable(person) ? 'badge-success' : 'badge-neutral']">
{{ isSelectable(person) ? '可绑定' : getUnavailableReason(person) }}
</span>
</button>
<div class="flex items-center justify-between gap-3 px-4 py-3">
<p class="text-xs text-text-tertiary">已加载 {{ results.length }} / {{ total }} 人</p>
<button
v-if="hasMore"
type="button"
class="btn-secondary"
:disabled="loading"
@click="search(false)"
>
{{ loading ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { getPersonOptions, type PersonOption } from '@/api/person'
const PAGE_SIZE = 20
const props = withDefaults(defineProps<{
modelValue: string
selectedPerson?: PersonOption | null
clearLabel?: string
}>(), {
selectedPerson: null,
clearLabel: '取消绑定'
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'update:selectedPerson', value: PersonOption | null): void
}>()
const keyword = ref('')
const results = ref<PersonOption[]>([])
const page = ref(0)
const total = ref(0)
const loading = ref(false)
const hasSearched = ref(false)
const errorMessage = ref('')
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let requestSequence = 0
const normalizedKeyword = computed(() => keyword.value.trim())
const hasMore = computed(() => results.value.length < total.value)
const formatName = (person: PersonOption) => {
if (person.nickname && person.real_name && person.nickname !== person.real_name) {
return `${person.nickname}(${person.real_name})`
}
return person.nickname || person.real_name || `员工 #${person.id}`
}
const formatMeta = (person: PersonOption) => {
const departments = Array.isArray(person.department_names)
? person.department_names.filter(Boolean)
: []
const department = departments.join(' / ') || person.main_department?.name || person.department
const parts = [department, person.job_number ? `工号 ${person.job_number}` : ''].filter(Boolean)
return parts.join(' · ') || (person.status === 'active' ? '在职员工' : '已停用员工')
}
const selectedLabel = computed(() => {
if (props.selectedPerson?.id === Number(props.modelValue)) {
return formatName(props.selectedPerson)
}
return `员工 #${props.modelValue}`
})
const selectedMeta = computed(() => {
if (props.selectedPerson?.id !== Number(props.modelValue)) return ''
return formatMeta(props.selectedPerson)
})
const isSelectable = (person: PersonOption) =>
person.status === 'active' && person.dingtalk_ready
const getUnavailableReason = (person: PersonOption) => {
if (person.status !== 'active') return '已停用'
return '钉钉身份不可用'
}
const resetResults = () => {
requestSequence += 1
results.value = []
page.value = 0
total.value = 0
loading.value = false
hasSearched.value = false
errorMessage.value = ''
}
const search = async (reset: boolean) => {
const searchKeyword = normalizedKeyword.value
if (!searchKeyword || loading.value) return
const targetPage = reset ? 1 : page.value + 1
const requestId = ++requestSequence
if (reset) {
results.value = []
page.value = 0
total.value = 0
}
loading.value = true
errorMessage.value = ''
try {
const response = await getPersonOptions({
page: targetPage,
pageSize: PAGE_SIZE,
keyword: searchKeyword,
includeInactive: true
})
if (requestId !== requestSequence || searchKeyword !== normalizedKeyword.value) return
const list = response.data?.list || []
const merged = reset ? list : [...results.value, ...list]
results.value = Array.from(new Map(merged.map((person) => [person.id, person])).values())
page.value = response.data?.page || targetPage
total.value = response.data?.total || 0
hasSearched.value = true
} catch (error) {
if (requestId !== requestSequence) return
console.error('搜索员工失败', error)
errorMessage.value = '员工搜索失败,请稍后重试。'
hasSearched.value = true
} finally {
if (requestId === requestSequence) {
loading.value = false
}
}
}
const selectPerson = (person: PersonOption) => {
if (!isSelectable(person)) return
emit('update:modelValue', String(person.id))
emit('update:selectedPerson', person)
keyword.value = ''
resetResults()
}
const clearSelection = () => {
emit('update:modelValue', '')
emit('update:selectedPerson', null)
}
watch(normalizedKeyword, (value) => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
resetResults()
if (!value) return
debounceTimer = setTimeout(() => {
debounceTimer = null
search(true)
}, 300)
})
onBeforeUnmount(() => {
if (debounceTimer) clearTimeout(debounceTimer)
requestSequence += 1
})
</script>

View File

@ -198,7 +198,7 @@
v-model.trim="personKeyword" v-model.trim="personKeyword"
type="text" type="text"
class="input" class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索" placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
@keyup.enter="handlePersonKeywordSubmit" @keyup.enter="handlePersonKeywordSubmit"
/> />
<p class="text-xs text-text-tertiary"> <p class="text-xs text-text-tertiary">
@ -206,7 +206,7 @@
</p> </p>
<div v-if="showPersonSearchResults" class="space-y-2"> <div v-if="showPersonSearchResults" class="space-y-2">
<div class="flex items-center justify-between gap-3 px-1"> <div class="flex items-center justify-between gap-3 px-1">
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p> <p class="text-xs text-text-tertiary">支持花名、姓名、工号和部门搜索。</p>
<p class="text-xs text-text-tertiary whitespace-nowrap"> <p class="text-xs text-text-tertiary whitespace-nowrap">
{{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }} {{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }}
</p> </p>
@ -225,7 +225,6 @@
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p> <p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
<p class="mt-1 text-xs text-text-secondary"> <p class="mt-1 text-xs text-text-secondary">
{{ person.department || '未填写部门' }} {{ person.department || '未填写部门' }}
<span v-if="person.phone"> · {{ person.phone }}</span>
<span v-if="person.job_number"> · {{ person.job_number }}</span> <span v-if="person.job_number"> · {{ person.job_number }}</span>
</p> </p>
</div> </div>
@ -320,9 +319,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { getEquipmentList, scanEquipment } from '@/api/equipment' import { getEquipmentList, scanEquipment } from '@/api/equipment'
import { scanBorrow } from '@/api/borrow' import { scanBorrow } from '@/api/borrow'
import { getPersonList } from '@/api/person' import { getPersonOptions, type PersonOption } from '@/api/person'
import { getChildren } from '@/api/mount' import { getChildren } from '@/api/mount'
import type { Equipment, Person } from '@/types' import type { Equipment } from '@/types'
import type { ChildEquipment } from '@/api/mount' import type { ChildEquipment } from '@/api/mount'
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format' import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
import CameraScannerModal from '@/components/CameraScannerModal.vue' import CameraScannerModal from '@/components/CameraScannerModal.vue'
@ -366,7 +365,7 @@ const manualSearchResults = ref<Equipment[]>([])
const manualSearchLoading = ref(false) const manualSearchLoading = ref(false)
const personKeyword = ref('') const personKeyword = ref('')
const selectedPersonId = ref<number | null>(null) const selectedPersonId = ref<number | null>(null)
const persons = ref<Person[]>([]) const persons = ref<PersonOption[]>([])
let equipmentSearchTimer: number | null = null let equipmentSearchTimer: number | null = null
let personSearchTimer: number | null = null let personSearchTimer: number | null = null
let latestEquipmentRequestId = 0 let latestEquipmentRequestId = 0
@ -517,7 +516,7 @@ const selectManualSearchResult = async (equipment: Equipment) => {
await handleScan(equipment.equipment_code) await handleScan(equipment.equipment_code)
} }
const selectPerson = (person: Person) => { const selectPerson = (person: PersonOption) => {
selectedPersonId.value = person.id selectedPersonId.value = person.id
personKeyword.value = buildManualBorrowPersonDisplay(person) personKeyword.value = buildManualBorrowPersonDisplay(person)
} }
@ -625,14 +624,14 @@ const fetchPersons = async (keyword = '') => {
const requestId = ++latestPersonRequestId const requestId = ++latestPersonRequestId
personSearchLoading.value = true personSearchLoading.value = true
try { try {
const response = await getPersonList({ const response = await getPersonOptions({
page: 1, page: 1,
pageSize: keyword ? 20 : 100, pageSize: 20,
keyword: keyword || undefined, keyword: keyword || undefined,
status: 'active' status: 'active'
}) })
if (response.data && requestId === latestPersonRequestId) { if (response.data && requestId === latestPersonRequestId) {
persons.value = response.data.list persons.value = response.data.list || []
} }
} catch (error) { } catch (error) {
console.error('获取使用者列表失败', error) console.error('获取使用者列表失败', error)

View File

@ -210,7 +210,6 @@
</p> </p>
<p class="mt-1 text-xs text-text-secondary"> <p class="mt-1 text-xs text-text-secondary">
{{ record.person?.department || '未填写部门' }} {{ record.person?.department || '未填写部门' }}
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span> <span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
<span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span> <span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
</p> </p>

View File

@ -5,7 +5,6 @@ export interface ManualBorrowSearchPerson {
job_number?: string | null job_number?: string | null
department?: string | null department?: string | null
department_names?: string[] department_names?: string[]
phone?: string | null
status: string status: string
} }
@ -66,8 +65,7 @@ export const filterManualBorrowPersons = <T extends ManualBorrowSearchPerson>(
includesKeyword(person.nickname, normalizedKeyword) || includesKeyword(person.nickname, normalizedKeyword) ||
includesKeyword(person.real_name, normalizedKeyword) || includesKeyword(person.real_name, normalizedKeyword) ||
includesKeyword(person.job_number, normalizedKeyword) || includesKeyword(person.job_number, normalizedKeyword) ||
includesKeyword(buildDepartmentText(person), normalizedKeyword) || includesKeyword(buildDepartmentText(person), normalizedKeyword)
includesKeyword(person.phone, normalizedKeyword)
) )
}) })

View File

@ -3,7 +3,6 @@ export interface ManualReturnRecordPerson {
real_name?: string | null real_name?: string | null
department?: string | null department?: string | null
department_names?: string[] department_names?: string[]
phone?: string | null
status?: string | null status?: string | null
} }
@ -11,7 +10,6 @@ export interface ManualReturnRecordCandidate {
id: number id: number
person_name: string person_name: string
person_nickname: string person_nickname: string
person_identifier?: string | null
person?: ManualReturnRecordPerson | null person?: ManualReturnRecordPerson | null
} }
@ -33,17 +31,13 @@ const buildDepartmentText = (record: ManualReturnRecordCandidate) => {
const getMatchScore = (record: ManualReturnRecordCandidate, keyword: string) => { const getMatchScore = (record: ManualReturnRecordCandidate, keyword: string) => {
const nickname = normalize(record.person_nickname) const nickname = normalize(record.person_nickname)
const realName = normalize(record.person_name) const realName = normalize(record.person_name)
const identifier = normalize(record.person_identifier)
if (nickname === keyword) return 0 if (nickname === keyword) return 0
if (realName === keyword) return 1 if (realName === keyword) return 1
if (identifier === keyword) return 2 if (nickname.startsWith(keyword)) return 2
if (nickname.startsWith(keyword)) return 3 if (realName.startsWith(keyword)) return 3
if (realName.startsWith(keyword)) return 4 if (nickname.includes(keyword)) return 4
if (identifier.startsWith(keyword)) return 5 if (realName.includes(keyword)) return 5
if (nickname.includes(keyword)) return 6 return 6
if (realName.includes(keyword)) return 7
return 8
} }
export const filterManualReturnRecordCandidates = <T extends ManualReturnRecordCandidate>( export const filterManualReturnRecordCandidates = <T extends ManualReturnRecordCandidate>(
@ -60,9 +54,7 @@ export const filterManualReturnRecordCandidates = <T extends ManualReturnRecordC
return ( return (
includesKeyword(record.person_nickname, normalizedKeyword) || includesKeyword(record.person_nickname, normalizedKeyword) ||
includesKeyword(record.person_name, normalizedKeyword) || includesKeyword(record.person_name, normalizedKeyword) ||
includesKeyword(record.person_identifier, normalizedKeyword) || includesKeyword(buildDepartmentText(record), normalizedKeyword)
includesKeyword(buildDepartmentText(record), normalizedKeyword) ||
includesKeyword(record.person?.phone, normalizedKeyword)
) )
}) })
@ -98,8 +90,7 @@ export const findPreferredManualReturnRecordCandidate = <T extends ManualReturnR
const exactMatch = records.find((record) => { const exactMatch = records.find((record) => {
return ( return (
normalize(record.person_nickname) === normalizedKeyword || normalize(record.person_nickname) === normalizedKeyword ||
normalize(record.person_name) === normalizedKeyword || normalize(record.person_name) === normalizedKeyword
normalize(record.person_identifier) === normalizedKeyword
) )
}) })
@ -123,7 +114,6 @@ export const isManualReturnSelectedRecordKeyword = <T extends ManualReturnRecord
return [ return [
normalize(record.person_nickname), normalize(record.person_nickname),
normalize(record.person_name), normalize(record.person_name),
normalize(record.person_identifier),
displayName displayName
] ]
.filter(Boolean) .filter(Boolean)

View File

@ -6,7 +6,6 @@ export interface ManualReturnAssetCandidate {
equipment_type?: string | null equipment_type?: string | null
active_borrow_count: number active_borrow_count: number
borrower_names?: string[] borrower_names?: string[]
borrower_identifiers?: string[]
borrower_departments?: string[] borrower_departments?: string[]
can_return?: boolean can_return?: boolean
can_handle_borrow_anomaly?: boolean can_handle_borrow_anomaly?: boolean

View File

@ -528,7 +528,7 @@ const canViewDisposals = computed(() => authStore.canAny([
PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_APPROVE,
PERMISSIONS.DISPOSALS_COMPLETE PERMISSIONS.DISPOSALS_COMPLETE
])) ]))
const canPrintLabels = computed(() => canCreate.value || canCorrect.value) const canPrintLabels = computed(() => authStore.can(PERMISSIONS.ASSETS_PRINT_LABELS))
const canOverrideCorrectionRestrictions = computed(() => authStore.can(PERMISSIONS.SYSTEM_SETTINGS)) const canOverrideCorrectionRestrictions = computed(() => authStore.can(PERMISSIONS.SYSTEM_SETTINGS))
const hasActiveMaintenance = computed(() => maintenanceRecords.value.some((record) => record.status === 'in_progress')) 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 hasActiveDisposal = computed(() => disposalRecords.value.some((record) => record.status === 'pending' || record.status === 'approved'))

View File

@ -500,7 +500,7 @@
v-model.trim="borrowPersonKeyword" v-model.trim="borrowPersonKeyword"
type="text" type="text"
class="input" class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索" placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
@keyup.enter="handleBorrowPersonKeywordSubmit" @keyup.enter="handleBorrowPersonKeywordSubmit"
/> />
<p class="text-xs text-text-tertiary">输入关键词搜索,点选即可;如果只匹配到 1 人,按回车也能直接选中。</p> <p class="text-xs text-text-tertiary">输入关键词搜索,点选即可;如果只匹配到 1 人,按回车也能直接选中。</p>
@ -508,7 +508,7 @@
<!-- 搜索结果 --> <!-- 搜索结果 -->
<div v-if="showBorrowPersonResults" class="mt-2 space-y-2"> <div v-if="showBorrowPersonResults" class="mt-2 space-y-2">
<div class="flex items-center justify-between gap-3 px-1"> <div class="flex items-center justify-between gap-3 px-1">
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p> <p class="text-xs text-text-tertiary">支持花名、姓名、工号和部门搜索。</p>
<p class="text-xs text-text-tertiary whitespace-nowrap"> <p class="text-xs text-text-tertiary whitespace-nowrap">
{{ borrowPersonLoading ? '搜索中...' : `共 ${filteredBorrowPersons.length} 条` }} {{ borrowPersonLoading ? '搜索中...' : `共 ${filteredBorrowPersons.length} 条` }}
</p> </p>
@ -528,7 +528,6 @@
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p> <p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
<p class="mt-0.5 text-xs text-text-secondary"> <p class="mt-0.5 text-xs text-text-secondary">
{{ person.department || '未填写部门' }} {{ person.department || '未填写部门' }}
<span v-if="person.phone"> · {{ person.phone }}</span>
<span v-if="person.job_number"> · {{ person.job_number }}</span> <span v-if="person.job_number"> · {{ person.job_number }}</span>
</p> </p>
</div> </div>
@ -664,7 +663,6 @@
<p class="text-sm font-medium text-text-primary">{{ record.person_nickname }} · {{ record.person_name }}</p> <p class="text-sm font-medium text-text-primary">{{ record.person_nickname }} · {{ record.person_name }}</p>
<p class="mt-0.5 text-xs text-text-secondary"> <p class="mt-0.5 text-xs text-text-secondary">
{{ record.person?.department || '未填写部门' }} {{ record.person?.department || '未填写部门' }}
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span> <span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
<span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span> <span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
</p> </p>
@ -839,7 +837,7 @@ import { PERMISSIONS } from '@/auth/permissions'
import { ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline' import { ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import { getEquipmentList, deleteEquipment, scanEquipment } from '@/api/equipment' import { getEquipmentList, deleteEquipment, scanEquipment } from '@/api/equipment'
import { getPersonOptions, getPersonList } from '@/api/person' import { getPersonOptions, type PersonOption } from '@/api/person'
import { getDepartmentOptions } from '@/api/department' import { getDepartmentOptions } from '@/api/department'
import { getAssetTypeOptions } from '@/api/assetType' import { getAssetTypeOptions } from '@/api/assetType'
import { getStorageLocationOptions } from '@/api/storageLocation' import { getStorageLocationOptions } from '@/api/storageLocation'
@ -854,7 +852,7 @@ import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
import CameraScannerModal from '@/components/CameraScannerModal.vue' import CameraScannerModal from '@/components/CameraScannerModal.vue'
import MountHistory from '@/components/MountHistory.vue' import MountHistory from '@/components/MountHistory.vue'
import MountManager from '@/components/MountManager.vue' import MountManager from '@/components/MountManager.vue'
import type { Equipment, Person, Department, AssetType, StorageLocation, BorrowRecord } from '@/types' import type { Equipment, Department, AssetType, StorageLocation, BorrowRecord } from '@/types'
import type { ReturnSearchAssetCandidate } from '@/api/borrow' import type { ReturnSearchAssetCandidate } from '@/api/borrow'
// 导入搜索辅助函数 // 导入搜索辅助函数
@ -885,7 +883,7 @@ const canMount = computed(() => authStore.can(PERMISSIONS.ASSETS_MOUNT))
const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE)) const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE))
const canApplyDisposal = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY)) const canApplyDisposal = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY))
const canExport = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW)) const canExport = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW))
const canPrintLabels = computed(() => canCreate.value || canCorrect.value) const canPrintLabels = computed(() => authStore.can(PERMISSIONS.ASSETS_PRINT_LABELS))
const canSelectAssets = computed(() => canBorrow.value || canTransfer.value || canPrintLabels.value) const canSelectAssets = computed(() => canBorrow.value || canTransfer.value || canPrintLabels.value)
const list = ref<Equipment[]>([]) const list = ref<Equipment[]>([])
const total = ref(0) const total = ref(0)
@ -895,7 +893,6 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.v
const selectedIds = ref<number[]>([]) const selectedIds = ref<number[]>([])
const expandedIds = ref(new Set<number>()) const expandedIds = ref(new Set<number>())
const persons = ref<Person[]>([])
const departments = ref<Department[]>([]) const departments = ref<Department[]>([])
const assetTypes = ref<AssetType[]>([]) const assetTypes = ref<AssetType[]>([])
const storageLocations = ref<StorageLocation[]>([]) const storageLocations = ref<StorageLocation[]>([])
@ -956,7 +953,7 @@ const borrowForm = ref({
const borrowPersonKeyword = ref('') const borrowPersonKeyword = ref('')
const borrowSelectedPersonId = ref<number | null>(null) const borrowSelectedPersonId = ref<number | null>(null)
const borrowPersonLoading = ref(false) const borrowPersonLoading = ref(false)
const borrowPersons = ref<Person[]>([]) const borrowPersons = ref<PersonOption[]>([])
const borrowSubmitting = ref(false) const borrowSubmitting = ref(false)
let borrowPersonSearchTimer: number | null = null let borrowPersonSearchTimer: number | null = null
let latestBorrowPersonRequestId = 0 let latestBorrowPersonRequestId = 0
@ -1117,14 +1114,14 @@ const fetchBorrowPersons = async (keyword = '') => {
const requestId = ++latestBorrowPersonRequestId const requestId = ++latestBorrowPersonRequestId
borrowPersonLoading.value = true borrowPersonLoading.value = true
try { try {
const response = await getPersonList({ const response = await getPersonOptions({
page: 1, page: 1,
pageSize: keyword ? 20 : 100, pageSize: 20,
keyword: keyword || undefined, keyword: keyword || undefined,
status: 'active' status: 'active'
}) })
if (response.data && requestId === latestBorrowPersonRequestId) { if (response.data && requestId === latestBorrowPersonRequestId) {
borrowPersons.value = response.data.list borrowPersons.value = response.data.list || []
} }
} catch (error) { } catch (error) {
console.error('获取使用者列表失败', error) console.error('获取使用者列表失败', error)
@ -1135,7 +1132,7 @@ const fetchBorrowPersons = async (keyword = '') => {
} }
} }
const selectBorrowPerson = (person: Person) => { const selectBorrowPerson = (person: PersonOption) => {
borrowSelectedPersonId.value = person.id borrowSelectedPersonId.value = person.id
borrowPersonKeyword.value = buildManualBorrowPersonDisplay(person) borrowPersonKeyword.value = buildManualBorrowPersonDisplay(person)
} }
@ -1396,13 +1393,11 @@ const fetchData = async () => {
} }
const fetchOptions = async () => { const fetchOptions = async () => {
const [p, d, t, s] = await Promise.all([ const [d, t, s] = await Promise.all([
getPersonOptions(),
getDepartmentOptions(), getDepartmentOptions(),
getAssetTypeOptions(), getAssetTypeOptions(),
getStorageLocationOptions() getStorageLocationOptions()
]) ])
if (p.data) persons.value = p.data
if (d.data) departments.value = d.data if (d.data) departments.value = d.data
if (t.data) assetTypes.value = t.data if (t.data) assetTypes.value = t.data
if (s.data) storageLocations.value = s.data if (s.data) storageLocations.value = s.data
@ -1868,7 +1863,7 @@ const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?:
return `${nickname}(${realName})` return `${nickname}(${realName})`
} }
return nickname || realName || currentBorrow.person_identifier || '使用者待补充' return nickname || realName || '使用者待补充'
} }
const getCurrentBorrowerMeta = (equipment: { const getCurrentBorrowerMeta = (equipment: {

View File

@ -441,6 +441,20 @@
<span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待分配</span> <span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待分配</span>
</div> </div>
<div class="mb-4 max-w-xl field-stack">
<label class="field-label">搜索责任人</label>
<input
v-model.trim="personOptionKeyword"
type="text"
class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门"
@input="schedulePersonOptionSearch"
/>
<p class="field-hint">
{{ personOptionLoading ? '正在搜索...' : personOptionError || `已加载 ${personOptions.length} / ${personOptionTotal} 人` }}
</p>
</div>
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="item in unassignedItems" v-for="item in unassignedItems"
@ -521,9 +535,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getPersonOptions } from '@/api/person' import { getPersonOptions, type PersonOption } from '@/api/person'
import { import {
assignInventoryTaskUnassignedItem, assignInventoryTaskUnassignedItem,
closeInventoryTask, closeInventoryTask,
@ -537,8 +551,7 @@ import type {
InventoryTaskAction, InventoryTaskAction,
InventoryTaskDetail, InventoryTaskDetail,
InventoryTaskItem, InventoryTaskItem,
InventoryTaskParticipant, InventoryTaskParticipant
Person
} from '@/types' } from '@/types'
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format' import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset' import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
@ -558,7 +571,11 @@ const router = useRouter()
const task = ref<InventoryTaskDetail | null>(null) const task = ref<InventoryTaskDetail | null>(null)
const unassignedItems = ref<InventoryTaskItem[]>([]) const unassignedItems = ref<InventoryTaskItem[]>([])
const personOptions = ref<Person[]>([]) const personOptions = ref<PersonOption[]>([])
const personOptionKeyword = ref('')
const personOptionTotal = ref(0)
const personOptionLoading = ref(false)
const personOptionError = ref('')
const loading = ref(false) const loading = ref(false)
const closing = ref(false) const closing = ref(false)
const deleting = ref(false) const deleting = ref(false)
@ -568,6 +585,8 @@ const itemFilter = ref<ItemFilter>('all')
const participantFilter = ref('all') const participantFilter = ref('all')
const itemKeyword = ref('') const itemKeyword = ref('')
const adminScanCode = ref('') const adminScanCode = ref('')
let personOptionSearchTimer: number | null = null
let latestPersonOptionRequestId = 0
const showAdminScanner = ref(false) const showAdminScanner = ref(false)
const adminLastHandledItem = ref<InventoryTaskItem | null>(null) const adminLastHandledItem = ref<InventoryTaskItem | null>(null)
const assignPersonIdMap = reactive<Record<number, string>>({}) const assignPersonIdMap = reactive<Record<number, string>>({})
@ -729,17 +748,45 @@ const resolveActionDetail = (action: InventoryTaskAction) => {
} }
} }
const loadPersonOptions = async () => { const loadPersonOptions = async (keyword = '') => {
const requestId = ++latestPersonOptionRequestId
personOptionLoading.value = true
personOptionError.value = ''
try { try {
const response = await getPersonOptions() const response = await getPersonOptions({
personOptions.value = response.data page: 1,
.filter((person) => person.status === 'active') pageSize: 50,
keyword: keyword || undefined,
status: 'active'
})
if (requestId !== latestPersonOptionRequestId) return
personOptions.value = (response.data?.list || [])
.slice()
.sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN')) .sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'))
} catch (error) { personOptionTotal.value = response.data?.total || 0
} catch (error: any) {
console.error('获取人员列表失败', error) console.error('获取人员列表失败', error)
if (requestId === latestPersonOptionRequestId) {
personOptions.value = []
personOptionTotal.value = 0
personOptionError.value = error?.message || '人员搜索失败'
}
} finally {
if (requestId === latestPersonOptionRequestId) {
personOptionLoading.value = false
}
} }
} }
const schedulePersonOptionSearch = () => {
if (personOptionSearchTimer) {
window.clearTimeout(personOptionSearchTimer)
}
personOptionSearchTimer = window.setTimeout(() => {
loadPersonOptions(personOptionKeyword.value.trim())
}, 300)
}
const loadTask = async () => { const loadTask = async () => {
if (!Number.isInteger(taskId.value) || taskId.value <= 0) { if (!Number.isInteger(taskId.value) || taskId.value <= 0) {
router.push('/inventory') router.push('/inventory')
@ -875,4 +922,10 @@ watch(
onMounted(() => { onMounted(() => {
loadTask() loadTask()
}) })
onUnmounted(() => {
if (personOptionSearchTimer) {
window.clearTimeout(personOptionSearchTimer)
}
})
</script> </script>

View File

@ -2,6 +2,7 @@ const PUBLIC_ASSET_SOURCE_USERID = '__public_asset__'
const PUBLIC_ASSET_NAME = '公共资产' const PUBLIC_ASSET_NAME = '公共资产'
type PublicAssetLike = { type PublicAssetLike = {
is_public_asset_person?: boolean
person_source_userid?: string | null person_source_userid?: string | null
person_nickname?: string | null person_nickname?: string | null
person_name?: string | null person_name?: string | null
@ -19,6 +20,10 @@ export const isInventoryPublicAssetPerson = (value?: PublicAssetLike | null) =>
return false return false
} }
if (value.is_public_asset_person) {
return true
}
if (normalizeText(value.person_source_userid) === PUBLIC_ASSET_SOURCE_USERID) { if (normalizeText(value.person_source_userid) === PUBLIC_ASSET_SOURCE_USERID) {
return true return true
} }

View File

@ -365,7 +365,6 @@
</p> </p>
<p class="mt-1 text-xs text-text-secondary"> <p class="mt-1 text-xs text-text-secondary">
{{ record.person?.department || '未填写部门' }} {{ record.person?.department || '未填写部门' }}
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span> <span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
</p> </p>
</div> </div>

View File

@ -195,17 +195,11 @@
</div> </div>
<div class="field-stack"> <div class="field-stack">
<label class="field-label">绑定员工</label> <label class="field-label">绑定员工</label>
<select v-model="newUser.person_id" class="select"> <PersonBindingPicker
<option value="">暂不绑定</option> v-model="newUser.person_id"
<option v-model:selected-person="newUserSelectedPerson"
v-for="person in personOptions" clear-label="暂不绑定"
:key="person.id" />
:value="String(person.id)"
:disabled="!isPersonDingTalkReady(person)"
>
{{ formatPersonOption(person) }}
</option>
</select>
<p class="field-hint">仅可绑定在职且已同步钉钉身份的员工,不会按姓名自动关联。</p> <p class="field-hint">仅可绑定在职且已同步钉钉身份的员工,不会按姓名自动关联。</p>
</div> </div>
<div class="field-stack"> <div class="field-stack">
@ -261,17 +255,12 @@
</div> </div>
<div class="field-stack"> <div class="field-stack">
<label class="field-label">绑定员工</label> <label class="field-label">绑定员工</label>
<select v-model="editingUserDraft.person_id" class="select"> <PersonBindingPicker
<option value="">解除绑定</option> :key="String(editingUser.id)"
<option v-model="editingUserDraft.person_id"
v-for="person in personOptions" v-model:selected-person="editingUserSelectedPerson"
:key="person.id" clear-label="解除绑定"
:value="String(person.id)" />
:disabled="!isPersonDingTalkReady(person)"
>
{{ formatPersonOption(person) }}
</option>
</select>
</div> </div>
<div class="field-grid"> <div class="field-grid">
<div class="field-stack"> <div class="field-stack">
@ -305,7 +294,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { XMarkIcon } from '@heroicons/vue/24/outline' import { XMarkIcon } from '@heroicons/vue/24/outline'
import type { Person, User, UserRole } from '@/types' import type { User, UserRole } from '@/types'
import { import {
createUser, createUser,
getUserList, getUserList,
@ -315,11 +304,11 @@ import {
deleteUser deleteUser
} from '@/api/users' } from '@/api/users'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { getPersonOptions } from '@/api/person' import type { PersonOption } from '@/api/person'
import { getRoleLabel } from '@/auth/permissions' import { getRoleLabel } from '@/auth/permissions'
import PersonBindingPicker from '@/components/PersonBindingPicker.vue'
const users = ref<(User & { status: 'active' | 'inactive'; last_login_at?: string | null })[]>([]) const users = ref<(User & { status: 'active' | 'inactive'; last_login_at?: string | null })[]>([])
const personOptions = ref<Person[]>([])
const listLoading = ref(false) const listLoading = ref(false)
const createLoading = ref(false) const createLoading = ref(false)
const updateLoading = ref(false) const updateLoading = ref(false)
@ -327,6 +316,8 @@ const showCreateUser = ref(false)
const createUserTrigger = ref<HTMLButtonElement | null>(null) const createUserTrigger = ref<HTMLButtonElement | null>(null)
const createDialog = ref<HTMLFormElement | null>(null) const createDialog = ref<HTMLFormElement | null>(null)
const createUsernameInput = ref<HTMLInputElement | null>(null) const createUsernameInput = ref<HTMLInputElement | null>(null)
const newUserSelectedPerson = ref<PersonOption | null>(null)
const editingUserSelectedPerson = ref<PersonOption | null>(null)
const newUser = reactive({ const newUser = reactive({
username: '', username: '',
@ -367,15 +358,6 @@ const fetchUsers = async () => {
} }
} }
const fetchPersonOptions = async () => {
try {
const response = await getPersonOptions({ includeInactive: true })
personOptions.value = response.data || []
} catch (error) {
console.error('获取员工选项失败', error)
}
}
const resetNewForm = () => { const resetNewForm = () => {
newUser.username = '' newUser.username = ''
newUser.name = '' newUser.name = ''
@ -384,6 +366,7 @@ const resetNewForm = () => {
newUser.email = '' newUser.email = ''
newUser.phone = '' newUser.phone = ''
newUser.person_id = '' newUser.person_id = ''
newUserSelectedPerson.value = null
} }
const openCreate = async () => { const openCreate = async () => {
@ -459,10 +442,20 @@ const openEdit = (user: User & { status: 'active' | 'inactive' }) => {
editingUserDraft.phone = user.phone || '' editingUserDraft.phone = user.phone || ''
editingUserDraft.status = user.status editingUserDraft.status = user.status
editingUserDraft.person_id = user.person_id ? String(user.person_id) : '' editingUserDraft.person_id = user.person_id ? String(user.person_id) : ''
editingUserSelectedPerson.value = user.person
? {
id: user.person.id,
nickname: user.person.nickname,
real_name: user.person.real_name,
status: user.person.status,
dingtalk_ready: user.dingtalk_ready !== false
}
: null
} }
const closeEdit = () => { const closeEdit = () => {
editingUser.value = null editingUser.value = null
editingUserSelectedPerson.value = null
} }
const submitEdit = async () => { const submitEdit = async () => {
@ -534,19 +527,6 @@ const getUserRoleBadgeClass = (role: UserRole) => {
return classes[role] 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' ? ' · 已停用' : ''
const dingtalkMissing = person.source_userid?.trim() ? '' : ' · 未同步钉钉身份'
return `${name}${jobNumber}${inactive}${dingtalkMissing}`
}
const isPersonDingTalkReady = (person: Person) =>
person.status === 'active' && Boolean(person.source_userid?.trim())
const getBoundPersonLabel = (user: User) => { const getBoundPersonLabel = (user: User) => {
if (user.person) { if (user.person) {
const name = user.person.nickname && user.person.nickname !== user.person.real_name const name = user.person.nickname && user.person.nickname !== user.person.real_name
@ -555,14 +535,12 @@ const getBoundPersonLabel = (user: User) => {
return user.dingtalk_ready === false ? `${name} · 钉钉身份不可用` : name return user.dingtalk_ready === false ? `${name} · 钉钉身份不可用` : name
} }
if (user.person_id) { if (user.person_id) {
const person = personOptions.value.find((item) => item.id === user.person_id) return `员工 #${user.person_id}`
return person ? formatPersonOption(person) : `员工 #${user.person_id}`
} }
return '未绑定' return '未绑定'
} }
onMounted(() => { onMounted(() => {
fetchUsers() fetchUsers()
fetchPersonOptions()
}) })
</script> </script>

View File

@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
TARGET_SCRIPT="${REPO_ROOT}/deploy.sh"
DEPLOY_SH_FUNCTIONS_ONLY=1 source "${TARGET_SCRIPT}"
MOCK_MIGRATION_STATE="pending"
MOCK_ACTIVE_COUNT="0"
MOCK_CANONICAL_ROWS=""
get_container_status() {
printf 'running\n'
}
run_equipment_mysql() {
local sql review_count
sql="$(cat)"
case "${sql}" in
*information_schema.tables*) printf '1\n' ;;
*_prisma_migrations*) printf '%s\n' "${MOCK_MIGRATION_STATE}" ;;
*"START TRANSACTION WITH CONSISTENT SNAPSHOT"*)
review_count="$(printf '%b\n' "${MOCK_CANONICAL_ROWS}" | awk 'NF { count += 1 } END { print count + 0 }')"
printf 'MISSING_ROOT_COUNT\t0\n'
printf 'TREE_ANOMALY_COUNT\t0\n'
printf 'ACTIVE_DISPOSAL_COUNT\t%s\n' "${MOCK_ACTIVE_COUNT}"
printf 'REVIEW_ROW_COUNT\t%s\n' "${review_count}"
if [ "${review_count}" -gt 0 ]; then
printf '%b\n' "${MOCK_CANONICAL_ROWS}" | awk 'NF { print "REVIEW_ROW\t" $0 }'
fi
;;
*) printf '' ;;
esac
}
assert_equal() {
local expected="$1"
local actual="$2"
if [ "${expected}" != "${actual}" ]; then
echo "Expected '${expected}', got '${actual}'" >&2
return 1
fi
}
assert_not_equal() {
local left="$1"
local right="$2"
if [ "${left}" = "${right}" ]; then
echo "Expected values to differ: '${left}'" >&2
return 1
fi
}
MOCK_MIGRATION_STATE="applied"
assert_equal "applied" "$(get_disposal_migration_state)"
DISPOSAL_PRECHECK_SQL="${REPO_ROOT}/does-not-exist.sql"
DISPOSAL_PRECHECK_REQUIRED=0
run_disposal_migration_precheck >/dev/null
assert_equal "0" "${DISPOSAL_PRECHECK_REQUIRED}"
MOCK_MIGRATION_STATE="incomplete"
assert_equal "incomplete" "$(get_disposal_migration_state)"
if run_disposal_migration_precheck >/dev/null 2>&1; then
echo "Incomplete migration must block deployment" >&2
exit 1
fi
MOCK_MIGRATION_STATE="pending"
assert_equal "pending" "$(get_disposal_migration_state)"
DISPOSAL_PRECHECK_SQL="${REPO_ROOT}/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql"
MOCK_ACTIVE_COUNT="1"
MOCK_CANONICAL_ROWS="D|1|pending|10\nT|1|0|10|AVAILABLE|NULL|2026-07-16T10:00:00.000"
calculate_active_disposal_fingerprint
first_fingerprint="${CURRENT_DISPOSAL_FINGERPRINT}"
MOCK_CANONICAL_ROWS="D|1|pending|10\nT|1|0|10|BORROWED|NULL|2026-07-16T10:01:00.000"
calculate_active_disposal_fingerprint
second_fingerprint="${CURRENT_DISPOSAL_FINGERPRINT}"
assert_not_equal "${first_fingerprint}" "${second_fingerprint}"
for required_field in \
"'parent_id'" \
"'current_status'" \
"'department_id'" \
"'storage_location_id'" \
"'equipment_updated_at'"; do
if ! grep -q "${required_field}" "${DISPOSAL_PRECHECK_SQL}"; then
echo "Fingerprint is missing ${required_field}" >&2
exit 1
fi
done