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
CORS_ALLOWED_ORIGINS=http://localhost:5173
AUTH_MODE=local
KEYCLOAK_URL=
KEYCLOAK_REALM=equipment-management
KEYCLOAK_CLIENT_ID=equipment-client
KEYCLOAK_CLIENT_SECRET=
PORT=3000
VITE_API_BASE_URL=/api
VITE_AUTH_MODE=local
SQLSERVER_HOST=
SQLSERVER_PORT=1433
SQLSERVER_DATABASE=

View File

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

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 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 precheckSql = fs.readFileSync(precheckPath, 'utf8')
test('标准部署在 build/up 前必须通过报废迁移预检', () => {
const gateCall = deployScript.lastIndexOf('\nrun_disposal_migration_precheck\n')
@ -32,16 +37,19 @@ test('预检只连接固定资产 MySQL,不在命令行暴露密码', () => {
})
test('无法确认或存在未核对报废单时默认阻断,仅允许本次显式确认', () => {
assert.ok(
deployScript.includes(
"SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;"
),
'预检必须稳定读取待审批和已审批报废单的指纹字段'
)
assert.match(deployScript, /printf '%s\\n' "\$active_rows" \| sha256sum/)
assert.match(precheckSql, /SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;/)
assert.match(precheckSql, /START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;/)
assert.match(precheckSql, /'ACTIVE_DISPOSAL_COUNT'/)
assert.match(precheckSql, /'REVIEW_ROW'/)
assert.match(precheckSql, /COMMIT;/)
assert.match(
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, /已默认阻止部署/)
})
@ -49,11 +57,41 @@ test('无法确认或存在未核对报废单时默认阻断,仅允许本次
test('未完成报废资产树存在环或深度达到 100 时禁止 override', () => {
const anomalyBlock = deployScript.indexOf('if [ "$tree_anomaly_count" -gt 0 ]')
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(overrideCheck > anomalyBlock, '资产树异常阻断必须早于指纹 override')
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,
buildDisposalCompletedDingTalkContent,
buildDisposalRejectedDingTalkContent,
createDisposal
createDisposal,
getDisposalDetail,
getDisposalList
} from '../disposalController'
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
}
})
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
let capturedWhere: any = null
let capturedArgs: any = null
;(prisma.equipment.findMany as any) = async (args: any) => {
capturedArgs = args
capturedWhere = args.where
return []
}
@ -66,6 +68,11 @@ test('资产列表搜索不应再按借用人身份证号或历史 person_identi
false,
'资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中'
)
assert.equal(
JSON.stringify(capturedArgs).includes('person_identifier'),
false,
'资产接口也不应读取历史 person_identifier 后再返回给行政或 IT'
)
assert.equal(
serializedWhere.includes('job_number'),
true,

View File

@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { completeMaintenance, createMaintenance } from '../maintenanceController'
import { completeMaintenance, createMaintenance, updateMaintenance } from '../maintenanceController'
const createMockResponse = () => {
const responseBody = { statusCode: 200, body: null as any }
@ -129,3 +129,65 @@ test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', asyn
;(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 { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue'
import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import {
borrowAssetTree,
finalizeNormalReturnState,
recordReturnToStorage,
transferAssetTree
@ -21,83 +21,26 @@ const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => {
: 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 (
equipment: any,
equipmentId: number,
person: any,
expectedReturnTime?: string,
isLongTerm?: boolean,
notes?: string
) => {
const childrenSnapshot = await getChildrenSnapshot(equipment.id)
const finalNotes = buildBorrowNotes(notes, isLongTerm)
return prisma.$transaction(async (tx) => {
await tx.borrowRecord.create({
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,
expected_return_time: isLongTerm ? null : (expectedReturnTime ? new Date(expectedReturnTime) : null),
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 } : {}
)
})
return prisma.$transaction(
(tx) => borrowAssetTree(tx, {
rootEquipmentId: equipmentId,
person,
expectedReturnTime: isLongTerm
? null
: (expectedReturnTime ? new Date(expectedReturnTime) : null),
isLongTerm,
notes: finalNotes
}),
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
)
}
const batchReturnTransaction = async (
@ -182,7 +125,7 @@ export const batchBorrow = async (req: Request, res: Response) => {
}
await batchBorrowTransaction(
equipment,
equipment.id,
person,
expected_return_time,
is_long_term,

View File

@ -1,24 +1,24 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import {
calculateEquipmentValues,
getDisplayEquipmentValue,
syncMountedChildrenState
getDisplayEquipmentValue
} from '../utils/assetValue'
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
import {
assertReturnStorageDepartmentContract,
resolveBorrowDepartmentUpdate
} from '../utils/borrowDepartment'
import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
import {
buildParentAssetFirstOrderBy,
sortParentAssetsFirst
} from '../utils/equipmentSearchPriority'
import { logOperation } from './operationLogController'
import {
buildRecursiveBorrowSnapshot,
borrowAssetTree,
finalizeBorrowAnomalyReturnState,
finalizeNormalReturnState,
loadAssetTree,
registerBorrowAnomalyAssetTree,
recordReturnToStorage
} from '../services/assetMovementService'
@ -206,93 +206,23 @@ const appendLongTermNotes = (notes?: string, isLongTerm?: boolean) => {
: 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 (
equipment: any,
equipmentId: number,
person: any,
expectedReturn: Date | null,
combinedNotes?: string,
isLongTerm?: boolean,
childrenSnapshot?: string
isLongTerm?: boolean
) => {
return prisma.$transaction(async (tx) => {
const record = await tx.borrowRecord.create({
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,
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 }
})
return prisma.$transaction(
(tx) => borrowAssetTree(tx, {
rootEquipmentId: equipmentId,
person,
expectedReturnTime: expectedReturn,
isLongTerm,
notes: combinedNotes
}),
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
)
}
const findActiveBorrowRecord = async (
@ -343,17 +273,11 @@ const getChildrenDiff = async (equipmentId: number, borrowRecord: any) => {
}
const borrowedChildren = JSON.parse(borrowRecord.children_snapshot) as any[]
const currentChildren = 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
}
})
const currentTree = await prisma.$transaction(
(tx) => loadAssetTree(tx, equipmentId),
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
)
const currentChildren = buildRecursiveBorrowSnapshot(currentTree)
const borrowedIds = new Set(borrowedChildren.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 (
equipment: any,
equipmentId: number,
person: any,
borrowTime: Date,
notes?: string
) => {
return prisma.$transaction(async (tx) => {
const { children, snapshot } = await getChildrenSnapshot(equipment.id)
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
const nextRemainingQuantity = Math.max(Number(equipment.total_quantity) - 1, 0)
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: '已借出'
})
const record = await tx.borrowRecord.create({
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
}
const result = await prisma.$transaction(
(tx) => registerBorrowAnomalyAssetTree(tx, {
rootEquipmentId: equipmentId,
person,
borrowTime,
notes
}),
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
)
const updatedEquipment = await prisma.equipment.findUnique({
where: { id: result.updatedRoot.id },
include: equipmentInclude
})
if (!updatedEquipment) throw new Error('资产不存在')
return { ...result, updatedEquipment }
}
const mapBorrowRecord = (record: any) => ({
@ -524,10 +393,8 @@ const mapBorrowRecord = (record: any) => ({
nickname: record.person.nickname,
real_name: record.person.real_name,
job_number: record.person.job_number,
id_number: record.person.id_number,
department: record.person.mainDepartment?.name || record.person.department,
main_department: record.person.mainDepartment || null,
phone: record.person.phone,
status: record.person.status
}
: {
@ -538,7 +405,6 @@ const mapBorrowRecord = (record: any) => ({
person_id: record.person_id,
person_name: record.person_name,
person_nickname: record.person_nickname,
person_identifier: record.person_identifier,
borrow_time: record.borrow_time,
expected_return_time: record.expected_return_time,
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 expectedReturn = longTerm ? null : parseDate(expected_return_time)
const combinedNotes = appendLongTermNotes(notes, longTerm)
const { children, snapshot } = await getChildrenSnapshot(equipment.id)
const { record: borrowRecord } = await borrowEquipmentInTransaction(
equipment,
const { record: borrowRecord, childrenCount } = await borrowEquipmentInTransaction(
equipment.id,
person,
expectedReturn,
combinedNotes,
longTerm,
snapshot
longTerm
)
successResponse(
@ -597,7 +461,7 @@ export const borrowEquipment = async (req: Request, res: Response) => {
{
id: borrowRecord.id,
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,
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 expectedReturn = longTerm ? null : parseDate(expected_return_time)
const combinedNotes = appendLongTermNotes(notes, longTerm)
const { children, snapshot } = await getChildrenSnapshot(equipment.id)
const { record: borrowRecord, updatedEquipment } = await borrowEquipmentInTransaction(
equipment,
const { record: borrowRecord, updatedRoot, childrenCount } = await borrowEquipmentInTransaction(
equipment.id,
person,
expectedReturn,
combinedNotes,
longTerm,
snapshot
longTerm
)
successResponse(
@ -656,11 +518,11 @@ export const scanBorrow = async (req: Request, res: Response) => {
equipment_name: equipment.equipment_name,
borrow_time: borrowRecord.borrow_time,
status: '已借出',
remaining_quantity: updatedEquipment?.remaining_quantity ?? 0,
equipment_status: updatedEquipment?.status ?? '已借出',
remaining_quantity: updatedRoot.remaining_quantity,
equipment_status: updatedRoot.status,
storage_location_id: equipment.storage_location_id,
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,
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
}
const { snapshot } = await getChildrenSnapshot(equipment.id)
await borrowEquipmentInTransaction(
equipment,
equipment.id,
person,
expectedReturn,
combinedNotes,
longTerm,
snapshot
longTerm
)
results.push({ equipment_code: code, status: 'success', message: '出库成功' })
@ -1126,8 +986,7 @@ export const getBorrowRecordList = async (req: Request, res: Response) => {
filters.push({
OR: [
{ person_name: { contains: kw } },
{ person_nickname: { contains: kw } },
{ person_identifier: { contains: kw } }
{ person_nickname: { contains: kw } }
]
})
}
@ -1250,7 +1109,6 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
id: true,
person_name: true,
person_nickname: true,
person_identifier: true,
person: {
select: {
department: true,
@ -1276,17 +1134,14 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
const list = sortParentAssetsFirst(equipmentList).map((equipment) => {
const borrowerNames: string[] = []
const borrowerIdentifiers: string[] = []
const borrowerDepartments: string[] = []
for (const record of equipment.borrow_records) {
const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ')
const borrowerIdentifier = record.person_identifier || null
const borrowerDepartment =
record.person?.mainDepartment?.name || record.person?.department || null
pushUnique(borrowerNames, borrowerName)
pushUnique(borrowerIdentifiers, borrowerIdentifier)
pushUnique(borrowerDepartments, borrowerDepartment)
}
@ -1304,7 +1159,6 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
storageLocation: equipment.storageLocation,
active_borrow_count: activeBorrowCount,
borrower_names: borrowerNames,
borrower_identifiers: borrowerIdentifiers,
borrower_departments: borrowerDepartments,
can_return: activeBorrowCount > 0,
can_handle_borrow_anomaly: anomalyMeta.canHandle,
@ -1399,7 +1253,7 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => {
}
const { record, updatedEquipment, childrenCount } = await registerBorrowAnomalyInTransaction(
equipment,
equipment.id,
person,
borrowTime,
typeof notes === 'string' ? notes : undefined
@ -1431,6 +1285,6 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => {
'已补登记借出记录'
)
} 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 || '')
})
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) => {
try {
const {
@ -382,39 +394,48 @@ export const getDisposalList = async (req: Request, res: Response) => {
: currentUser.role === 'administrative'
? 'pending'
: 'mine'))
const where: Prisma.DisposalRecordWhereInput = {}
const validScopes = ['all', 'mine', 'pending', 'processed']
if (!validScopes.includes(resolvedScope)) {
return errorResponse(res, '报废列表范围无效', 422)
}
const scopeConditions: Prisma.DisposalRecordWhereInput[] = []
if (currentUser.role === 'it') {
scopeConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'mine') {
scopeConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'pending') {
scopeConditions.push({ status: 'pending' })
} else if (resolvedScope === 'processed') {
scopeConditions.push({
OR: [
{ approved_by: currentUser.id },
{ completed_by: currentUser.id }
]
})
} else if (resolvedScope === 'all' && currentUser.role !== 'admin') {
return errorResponse(res, '只有管理员可以查看全部报废流程', 403)
} else if (!['all', 'mine', 'pending', 'processed'].includes(resolvedScope)) {
return errorResponse(res, '报废列表范围无效', 422)
} else if (currentUser.role === 'administrative') {
if (resolvedScope === 'pending') {
scopeConditions.push({ status: 'pending' })
} else if (resolvedScope === 'processed') {
scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
} else {
return errorResponse(res, '行政仅可查看全部待审批和全部已处理报废流程', 403)
}
} else if (currentUser.role === 'admin') {
if (resolvedScope === 'mine') {
scopeConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'pending') {
scopeConditions.push({ status: 'pending' })
} else if (resolvedScope === 'processed') {
scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
}
} else {
return errorResponse(res, '无权查看报废流程', 403)
}
if (scopeConditions.length > 0) where.AND = scopeConditions
if (status) where.status = String(status)
if (equipment_id) where.equipment_id = Number(equipment_id)
if (status) scopeConditions.push({ status: String(status) })
if (equipment_id) scopeConditions.push({ equipment_id: Number(equipment_id) })
if (keyword) {
const keywordWhere = [
{ equipment_code: { contains: String(keyword) } },
{ disposal_reason: { contains: String(keyword) } },
{ items: { some: { equipment_code: { contains: String(keyword) } } } }
]
where.AND = [...(Array.isArray(where.AND) ? where.AND : []), { OR: keywordWhere }]
scopeConditions.push({ OR: keywordWhere })
}
const where: Prisma.DisposalRecordWhereInput = scopeConditions.length > 0
? { AND: scopeConditions }
: {}
const skip = (Number(page) - 1) * Number(pageSize)
const [list, total] = await Promise.all([
@ -433,7 +454,11 @@ export const getDisposalList = async (req: Request, res: Response) => {
}),
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) {
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) {
return errorResponse(res, '无权查看该报废申请', 403)
}
successResponse(res, disposal, '获取成功')
successResponse(res, withHistoricalDisposalNotes(disposal), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取报废记录详情失败')
}

View File

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

View File

@ -319,7 +319,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
where.OR = [
{ person_name: { 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) {
@ -356,7 +356,6 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
{ header: '资产名称', key: 'equipment_name', width: 25 },
{ header: '使用者', key: 'person_name', width: 15 },
{ header: '使用者昵称', key: 'person_nickname', width: 15 },
{ header: '借用时工号', key: 'person_identifier', width: 18 },
{ header: '当前工号', key: 'current_job_number', width: 18 },
{ header: '部门', key: 'department', width: 15 },
{ header: '借用时间', key: 'borrow_time', width: 20 },
@ -377,7 +376,6 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
equipment_name: record.equipment?.equipment_name || '',
person_name: record.person_name,
person_nickname: record.person_nickname,
person_identifier: record.person_identifier || '',
current_job_number: record.person?.job_number || '',
department: record.person?.mainDepartment?.name || record.person?.department || '',
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) => {
try {
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({
where: { id: Number(id) }
})
if (!maintenance) {
return errorResponse(res, '维修记录不存在', 404)
if (!Number.isInteger(maintenanceId) || maintenanceId <= 0) {
return errorResponse(res, '维修记录ID无效', 422)
}
if (maintenance.status !== 'in_progress') {
return errorResponse(res, '只能更新进行中的维修记录')
}
const updated = await prisma.maintenanceRecord.update({
where: { id: Number(id) },
const result = await prisma.maintenanceRecord.updateMany({
where: { id: maintenanceId, status: 'in_progress' },
data: {
repair_vendor,
estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : 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, '更新成功')
} 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,
manager_source_userid: person.manager_source_userid,
source_employee_status: person.source_employee_status,
dingtalk_ready: Boolean(
person.status === 'active' &&
person.source_userid?.trim()
),
last_synced_at: person.last_synced_at,
is_synced: Boolean(person.source_system),
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 where: any = {}
@ -465,15 +506,30 @@ export const deletePerson = async (req: Request, res: Response) => {
export const getPersonOptions = async (req: Request, res: Response) => {
try {
const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true'
const persons = await prisma.person.findMany({
where: includeInactive ? undefined : { status: 'active' },
const page = parsePositiveInteger(req.query.page, 1)
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
? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }]
: [{ nickname: 'asc' }, { real_name: 'asc' }],
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) {
errorResponse(res, error.message || '获取使用者选项失败')
}

View File

@ -5,6 +5,7 @@ import {
createAssetLabelPrintJob as createAssetLabelPrintJobService,
createPrintTerminalPairCode as createPrintTerminalPairCodeService,
heartbeatPrintTerminalAgent,
getDefaultPrintTerminalReadiness,
isPrintServiceError,
listPrintJobs,
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) => {
const userId = getCurrentUserId(res)
if (!userId) {
@ -142,9 +152,15 @@ export const cancelPrintJob = async (req: Request, res: Response) => {
if (!jobId) {
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 {
await cancelQueuedPrintJob(jobId)
await cancelQueuedPrintJob(jobId, userId, canManageAll)
successResponse(res, null, '打印任务已取消')
} catch (error) {
handlePrintServiceError(res, error, '取消打印任务失败')

View File

@ -1,10 +1,12 @@
import { Request, Response } from 'express'
import bcrypt from 'bcryptjs'
import type { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { getPermissionsForRole, isUserRole } from '../utils/permissions'
const SALT_ROUNDS = 10
const USER_STATUSES = ['active', 'inactive'] as const
class UserValidationError extends Error {
constructor(message: string, public readonly status = 422) {
@ -66,6 +68,38 @@ const normalizePersonId = (value: unknown) => {
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 person = await prisma.person.findUnique({ where: { id: personId } })
if (!person) {
@ -176,6 +210,7 @@ export const createUser = async (req: Request, res: Response) => {
if (!isUserRole(primaryRole)) {
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
}
const normalizedStatus = normalizeUserStatus(status, 'active')
const personId = normalizePersonId(person_id)
if (typeof personId === 'number') {
@ -192,7 +227,7 @@ export const createUser = async (req: Request, res: Response) => {
email,
phone,
role: primaryRole,
status: status || 'active',
status: normalizedStatus,
person_id: personId ?? null
},
include: { person: { select: personSelect } }
@ -228,8 +263,18 @@ export const updateUser = async (req: Request, res: Response) => {
updateData.role = newRole
}
if (status) {
updateData.status = status
if (status !== undefined) {
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)
@ -240,11 +285,34 @@ export const updateUser = async (req: Request, res: Response) => {
updateData.person_id = null
}
const updated = await prisma.user.update({
where: { id: Number(id) },
data: updateData,
include: { person: { select: personSelect } }
})
const changesRoleOrStatus = Boolean(updateData.role || updateData.status)
const updated = changesRoleOrStatus
? 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), '更新成功')
} catch (error: any) {
@ -284,24 +352,33 @@ export const updateUserStatus = async (req: Request, res: Response) => {
const { id } = req.params
const { status } = req.body as { status?: string }
if (!status) {
return errorResponse(res, '状态不能为空', 422)
}
const normalizedStatus = normalizeUserStatus(status)
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
if (!user) {
return errorResponse(res, '用户不存在', 404)
}
const updated = await prisma.user.update({
where: { id: Number(id) },
data: { status },
include: { person: { select: personSelect } }
if (getCurrentUserId(res) === user.id && normalizedStatus !== 'active') {
return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
}
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), '状态更新成功')
} 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 userId = Number(id)
if (getCurrentUserId(res) === userId) {
return errorResponse(res, '不能删除当前登录账户', 422)
}
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
@ -333,11 +414,6 @@ export const deleteUser = async (req: Request, res: Response) => {
return errorResponse(res, '用户不存在', 404)
}
// 不允许删除 admin 用户
if (user.username === 'admin') {
return errorResponse(res, '不能删除管理员账户')
}
if (
user._count.site_configs > 0 ||
user._count.mount_histories > 0 ||
@ -353,6 +429,7 @@ export const deleteUser = async (req: Request, res: Response) => {
}
await prisma.$transaction(async (tx) => {
await assertActiveAdministratorRemains(tx, userId)
await tx.operationLog.deleteMany({
where: { user_id: userId }
})
@ -362,6 +439,6 @@ export const deleteUser = async (req: Request, res: Response) => {
successResponse(res, null, '删除成功')
} catch (error: any) {
errorResponse(res, error.message || '删除用户失败')
errorResponse(res, error.message || '删除用户失败', error.status || 500)
}
}

View File

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

View File

@ -7,8 +7,9 @@ import {
} from '../utils/authIdentity'
import { PERMISSIONS, type Permission } from '../utils/permissions'
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 authorization = req.headers.authorization

View File

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

View File

@ -15,9 +15,9 @@ const router = Router()
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('/:id', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonDetail)
router.get('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), getPersonDetail)
router.post('/', requirePermission(PERMISSIONS.USERS_MANAGE), createPerson)
router.put('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), updatePerson)

View File

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

View File

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

View File

@ -2,7 +2,9 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import {
borrowAssetTree,
finalizeBorrowAnomalyReturnState,
registerBorrowAnomalyAssetTree,
transferAssetTree
} from '../assetMovementService'
@ -153,3 +155,370 @@ test('异常归还在无活动借用时按锁后快照 CAS 恢复资产', async
assert.equal(updates[0].data.remaining_quantity, 1)
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
equipment_code: string
equipment_name: string
equipment_model: string | null
equipment_type: string
parent_id: number | null
department_id: number | null
storage_location_id: number | null
@ -22,6 +24,8 @@ const treeSelect = {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
parent_id: true,
department_id: true,
storage_location_id: true,
@ -64,7 +68,9 @@ const getAssetTreeFingerprint = (items: AssetTreeItem[]) => JSON.stringify(
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.toString(),
updated_at: item.updated_at.getTime()
}))
.sort((left, right) => left.id - right.id)
@ -82,6 +88,8 @@ const normalizeRawAssetTreeItem = (item: Record<string, unknown>): AssetTreeItem
id: Number(item.id),
equipment_code: String(item.equipment_code || ''),
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),
department_id: item.department_id === null ? null : Number(item.department_id),
storage_location_id: item.storage_location_id === null
@ -150,6 +158,8 @@ const loadAssetTreeForUpdate = async (
equipment.id,
equipment.equipment_code,
equipment.equipment_name,
equipment.equipment_model,
equipment.equipment_type,
equipment.parent_id,
equipment.department_id,
equipment.storage_location_id,
@ -181,6 +191,8 @@ const loadAssetTreeForUpdate = async (
equipment.id,
equipment.equipment_code,
equipment.equipment_name,
equipment.equipment_model,
equipment.equipment_type,
equipment.parent_id,
equipment.department_id,
equipment.storage_location_id,
@ -225,6 +237,378 @@ export const loadAndLockAssetTree = async (
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) => {
if (!departmentId) return null
const department = await client.department.findUnique({

View File

@ -10,6 +10,7 @@ import {
} from '../utils/inventoryScope'
import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft'
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
import { PUBLIC_ASSET_PERSON_SOURCE_USERID } from '../utils/systemReservedPerson'
type ScopeEntity = {
id: number
@ -27,6 +28,14 @@ type FrozenParticipantDraft = {
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 = {
participant_person_id: number | null
equipment_id: number
@ -400,7 +409,7 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => {
unassigned_count: unassignedCount,
department_breakdown: buildDepartmentBreakdown(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)
}
}
@ -787,7 +796,7 @@ export const getInventoryTaskDetail = async (taskId: number) => {
return {
...serializeTaskListItem(task),
...serializedScope,
participants: task.participants,
participants: task.participants.map(sanitizeInventoryParticipant),
items: task.items,
recent_actions: task.actions,
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) => {
const existing = await prisma.printTerminal.findUnique({
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({
where: { id: jobId }
})
@ -1359,12 +1391,23 @@ export const cancelQueuedPrintJob = async (jobId: number) => {
throw new PrintServiceError('只有排队中的任务可以取消', 422)
}
await prisma.printJob.update({
where: { id: jobId },
if (!allowAnyRequester && existing.requested_by !== requestedBy) {
throw new PrintServiceError('只能取消本人提交的排队任务', 403)
}
const cancelled = await prisma.printJob.updateMany({
where: {
id: jobId,
status: PRINT_JOB_STATUS.queued,
...(!allowAnyRequester ? { requested_by: requestedBy } : {})
},
data: {
status: PRINT_JOB_STATUS.cancelled
}
})
if (cancelled.count !== 1) {
throw new PrintServiceError('打印任务状态已变化,请刷新后重试', 409)
}
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_APPROVE))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_VIEW_OWN))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
})
test('行政可以登记和审批但不能借还、调拨或挂载', () => {
const permissions = getPermissionsForRole('administrative')
assert.ok(permissions.includes(PERMISSIONS.ASSETS_CREATE))
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.ASSETS_BORROW))
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
@ -23,6 +25,7 @@ test('IT可以流转和发起报废但不能审批或管理用户', () => {
const permissions = getPermissionsForRole('it')
assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY))
assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
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 Keycloak from 'keycloak-connect'
import { assertKeycloakConfiguration } from './authMode'
type KeycloakEnvConfig = {
serverUrl: string
@ -8,19 +9,13 @@ type KeycloakEnvConfig = {
clientSecret?: string
}
const requiredEnv = ['KEYCLOAK_URL', 'KEYCLOAK_REALM', 'KEYCLOAK_CLIENT_ID'] as const
requiredEnv.forEach((key) => {
if (!process.env[key]) {
console.warn(`[Keycloak] 环境变量 ${key} 未设置,Keycloak 认证可能无法正常工作。`)
}
})
const validatedKeycloakConfig = assertKeycloakConfiguration()
const keycloakConfig: KeycloakEnvConfig = {
serverUrl: process.env.KEYCLOAK_URL || 'http://localhost:8080',
realm: process.env.KEYCLOAK_REALM || 'equipment-management',
clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET
serverUrl: validatedKeycloakConfig?.serverUrl || 'http://localhost:8080',
realm: validatedKeycloakConfig?.realm || 'equipment-management',
clientId: validatedKeycloakConfig?.clientId || 'equipment-client',
clientSecret: validatedKeycloakConfig?.clientSecret
}
const memoryStore = new session.MemoryStore()

View File

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

234
deploy.sh
View File

@ -8,6 +8,7 @@ COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml"
PROJECT_NAME="equipment-management"
BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh"
DISPOSAL_PRECHECK_SQL="$PROJECT_DIR/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql"
DISPOSAL_MIGRATION_NAME="20260714_asset_permissions_disposal_dingtalk"
MYSQL_CONTAINER="equipment-mysql"
SKIP_BACKUP="${SKIP_BACKUP:-0}"
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_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}"
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() {
local container="$1"
@ -77,62 +82,72 @@ run_equipment_mysql() {
' sh "$@"
}
run_disposal_migration_precheck() {
local mysql_status tree_anomaly_count active_rows active_count active_fingerprint
get_disposal_migration_state() {
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
echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2
return 1
fi
mysql_status="$(get_container_status "$MYSQL_CONTAINER")"
if [ "$mysql_status" != "running" ]; then
echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2
return 1
fi
if ! run_equipment_mysql < "$DISPOSAL_PRECHECK_SQL"; then
echo "报废流程迁移前核查执行失败,已阻止部署。" >&2
return 1
fi
if ! tree_anomaly_count="$(run_equipment_mysql --batch --skip-column-names --raw <<'SQL'
WITH RECURSIVE active_disposal_tree AS (
SELECT
disposal.id AS disposal_id,
equipment.id AS equipment_id,
equipment.parent_id,
0 AS depth,
CAST(CONCAT('/', equipment.id, '/') AS CHAR(4096)) AS visited_path,
0 AS cycle_detected
FROM disposal_record disposal
INNER JOIN equipment equipment ON equipment.id = disposal.equipment_id
WHERE disposal.status IN ('pending', 'approved')
UNION ALL
SELECT
tree.disposal_id,
child.id,
child.parent_id,
tree.depth + 1,
CONCAT(tree.visited_path, child.id, '/'),
LOCATE(CONCAT('/', child.id, '/'), tree.visited_path) > 0
FROM active_disposal_tree tree
INNER JOIN equipment child ON child.parent_id = tree.equipment_id
WHERE tree.depth < 100
AND tree.cycle_detected = 0
)
SELECT COUNT(*)
FROM active_disposal_tree
WHERE cycle_detected = 1 OR depth >= 100;
SQL
if ! snapshot_output="$(
run_equipment_mysql --batch --skip-column-names --raw < "$DISPOSAL_PRECHECK_SQL"
)"; then
echo "无法确认未完成报废资产树是否存在环或超深层级,已阻止部署。" >&2
echo "报废流程迁移前一致性快照核查执行失败,已阻止部署。" >&2
return 1
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
echo "资产树异常数量不可解析,已阻止部署。" >&2
return 1
@ -143,31 +158,81 @@ SQL
return 1
fi
if ! active_rows="$(
printf '%s\n' \
"SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;" |
run_equipment_mysql --batch --skip-column-names --raw
)"; then
echo "无法读取未完成报废单指纹数据,已阻止部署。" >&2
if ! [[ "$active_count" =~ ^[0-9]+$ ]]; then
echo "未完成报废单数量不可解析,已阻止部署。" >&2
return 1
fi
if ! [[ "$review_row_count" =~ ^[0-9]+$ ]]; then
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
fi
active_count="$(printf '%s\n' "$active_rows" | awk 'NF { count += 1 } END { print count + 0 }')"
if [ "$active_count" -gt 0 ]; then
if ! command -v sha256sum >/dev/null 2>&1; then
echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2
return 1
if [ "$display_rows" = "1" ]; then
echo ">>> 同一一致性快照中的未完成报废单及完整资产树:"
if [ "$review_row_count" -gt 0 ]; then
printf '%s\n' "$review_rows"
else
echo " (无未完成报废单)"
fi
active_fingerprint="$(printf '%s\n' "$active_rows" | sha256sum | awk '{ print $1 }')"
if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then
echo "未完成报废单指纹不可解析,已阻止部署。" >&2
fi
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
fi
echo "检测到 $active_count 条未完成报废单。" >&2
echo "本次未完成报废单指纹: $active_fingerprint" >&2
if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]; then
;;
pending)
DISPOSAL_PRECHECK_REQUIRED=1
;;
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 "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$active_fingerprint。" >&2
echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$CURRENT_DISPOSAL_FINGERPRINT。" >&2
return 1
fi
echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。"
@ -176,6 +241,39 @@ SQL
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
COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE")
elif command -v docker-compose >/dev/null 2>&1; then
@ -228,6 +326,8 @@ run_disposal_migration_precheck
echo ">>> 先构建固定资产系统镜像..."
"${COMPOSE_CMD[@]}" build
recheck_disposal_migration_precheck
echo ">>> 镜像构建成功,开始更新固定资产系统容器..."
"${COMPOSE_CMD[@]}" up -d --remove-orphans

View File

@ -33,10 +33,15 @@ services:
- "3000:3000"
environment:
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}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
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}
SQLSERVER_HOST: ${SQLSERVER_HOST:-}
SQLSERVER_PORT: ${SQLSERVER_PORT:-1433}
@ -79,7 +84,10 @@ services:
dockerfile: Dockerfile
args:
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
restart: always
ports:

View File

@ -5,10 +5,16 @@ WORKDIR /app
# 构建参数
ARG VITE_API_BASE_URL=http://localhost:3000/api
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_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 ./

View File

@ -54,7 +54,10 @@ const byRealName = filterManualReturnRecordCandidates(sampleRecords as any, '周
assert.equal(byRealName[0].id, 102, '姓名搜索应支持')
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, '设计')
assert.equal(byDepartment[0].id, 101, '部门搜索应支持')

View File

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

View File

@ -1,6 +1,37 @@
import api from './index'
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: {
page?: number
pageSize?: number
@ -62,10 +93,14 @@ export const deletePerson = async (id: number) => {
return response.data
}
export const getPersonOptions = async (params?: { includeInactive?: boolean }) => {
const response = await api.get<ApiResponse<Person[]>>('/persons/options', {
export const getPersonOptions = async (params: PersonOptionListParams = {}) => {
const response = await api.get<ApiResponse<PersonOptionListData>>('/persons/options', {
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

View File

@ -326,6 +326,32 @@ export const getPrintTerminals = async () => {
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 (
payload: CreateAssetLabelPrintJobPayload
): Promise<CreateAssetLabelPrintJobResult> => {

View File

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

View File

@ -46,6 +46,29 @@
<p v-if="terminalError" class="text-sm text-status-warning">{{ terminalError }}</p>
</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
v-for="equipment in equipmentList"
@ -91,8 +114,8 @@
import { computed, onMounted, ref } from 'vue'
import QRCode from 'qrcode'
import { getEquipmentBatchForBarcode } from '@/api/barcode'
import { createAssetLabelPrintJob, getPrintTerminals } from '@/api/print'
import type { PrintTerminal } from '@/types'
import { cancelQueuedPrintJob, createAssetLabelPrintJob, getPrintTerminalReadiness } from '@/api/print'
import type { PrintJob, PrintTerminal } from '@/types'
import { formatDateTime } from '@/utils/format'
import {
buildLocalBatchPrintHtml,
@ -149,8 +172,10 @@ const previewImages = ref<Record<number, string>>({})
const loading = ref(false)
const terminalLoading = ref(false)
const remoteSubmitting = ref(false)
const cancelSubmitting = ref(false)
const defaultTerminal = ref<PrintTerminal | 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 BODY_FONT = '"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif'
@ -183,24 +208,7 @@ const getProfileText = (terminal?: PrintTerminal | null) => {
return terminal.readiness.message
}
const profile = terminal.label_profile
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 配置'
return terminal.readiness?.is_ready ? '60 × 40 已就绪' : '待管理员检查配置'
}
const remoteStatusBadgeClass = computed(() => {
@ -225,7 +233,6 @@ const remoteStatusText = computed(() => {
const remotePrinterName = computed(() => {
return (
defaultTerminal.value?.selected_printer_name ||
defaultTerminal.value?.printer_snapshot?.[0]?.name ||
'未选择打印机'
)
})
@ -240,11 +247,11 @@ const remoteStatusDescription = computed(() => {
}
if (terminalError.value) {
return '暂时无法读取办公室打印状态,建议先刷新系统设置里的打印终端。'
return '暂时无法读取办公室打印状态,请联系管理员检查打印终端。'
}
if (!defaultTerminal.value) {
return '当前还没有设置默认打印电脑,请先到系统设置里的“打印终端”完成配对。'
return '当前还没有设置默认打印电脑,请联系管理员完成终端配置。'
}
if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) {
@ -268,6 +275,23 @@ const remoteActionDisabled = computed(() => {
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) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
@ -677,8 +701,7 @@ const fetchDefaultTerminal = async () => {
terminalError.value = null
try {
const terminals = await getPrintTerminals()
defaultTerminal.value = terminals.find((item) => item.is_default) || null
defaultTerminal.value = await getPrintTerminalReadiness()
} catch (error: any) {
console.error('获取打印终端失败:', error)
terminalError.value = error?.message || '获取打印终端失败'
@ -717,7 +740,7 @@ const handleRemotePrint = async () => {
}
if (!defaultTerminal.value) {
alert('当前还没有配置默认打印电脑,请先到系统设置里的“打印终端”完成配对')
alert('当前还没有配置默认打印电脑,请联系管理员处理')
return
}
@ -744,6 +767,7 @@ const handleRemotePrint = async () => {
rendered_labels: renderedLabels,
target_terminal_id: targetTerminalId
})
latestRemoteJob.value = result.job
const targetTerminalName = result.job.target_terminal_name || defaultTerminal.value.name
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 () => {
if (equipmentList.value.length === 0) {
alert('没有可打印的二维码')

View File

@ -90,7 +90,7 @@
v-model.trim="personKeyword"
type="text"
class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
@keyup.enter="handlePersonKeywordSubmit"
/>
<p class="text-xs text-text-tertiary">
@ -99,7 +99,7 @@
<div v-if="showPersonSearchResults" class="space-y-2">
<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">
{{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }}
</p>
@ -129,7 +129,6 @@
</div>
<p class="mt-1 text-xs text-text-secondary">
{{ person.department || '未填写部门' }}
<span v-if="person.phone"> · {{ person.phone }}</span>
<span v-if="person.job_number"> · {{ person.job_number }}</span>
</p>
</div>
@ -205,13 +204,12 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getPersonOptions } from '@/api/person'
import { computed, onUnmounted, ref, watch } from 'vue'
import { getPersonOptions, type PersonOption } from '@/api/person'
import {
registerBorrowAnomaly,
resolveBorrowAnomalyAsReturned
} from '@/api/borrow'
import type { Person } from '@/types'
import {
buildManualBorrowPersonDisplay,
filterManualBorrowPersons,
@ -246,7 +244,7 @@ const emit = defineEmits<{
}>()
const mode = ref<'mark-returned' | 'register-borrow'>('mark-returned')
const persons = ref<Person[]>([])
const persons = ref<PersonOption[]>([])
const personsLoading = ref(false)
const selectedPersonId = ref<number | null>(null)
const personKeyword = ref('')
@ -254,6 +252,8 @@ const borrowDate = ref('')
const notes = ref('')
const submitting = ref(false)
const feedback = ref<{ type: 'warning' | 'error'; message: string } | null>(null)
let personSearchTimer: number | null = null
let latestPersonRequestId = 0
const selectedPerson = computed(() => {
if (selectedPersonId.value === null) {
@ -285,21 +285,37 @@ const resetForm = () => {
feedback.value = null
}
const ensurePersonsLoaded = async () => {
if (persons.value.length > 0 || personsLoading.value) {
return
}
const fetchPersons = async (keyword = '') => {
const requestId = ++latestPersonRequestId
personsLoading.value = true
try {
const response = await getPersonOptions({ includeInactive: true })
persons.value = response.data || []
const response = await getPersonOptions({
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 {
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
personKeyword.value = buildManualBorrowPersonDisplay(person)
}
@ -412,6 +428,29 @@ watch(
if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) {
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>

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

View File

@ -210,7 +210,6 @@
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ 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> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
</p>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -441,6 +441,20 @@
<span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待分配</span>
</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
v-for="item in unassignedItems"
@ -521,9 +535,9 @@
</template>
<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 { getPersonOptions } from '@/api/person'
import { getPersonOptions, type PersonOption } from '@/api/person'
import {
assignInventoryTaskUnassignedItem,
closeInventoryTask,
@ -537,8 +551,7 @@ import type {
InventoryTaskAction,
InventoryTaskDetail,
InventoryTaskItem,
InventoryTaskParticipant,
Person
InventoryTaskParticipant
} from '@/types'
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
@ -558,7 +571,11 @@ const router = useRouter()
const task = ref<InventoryTaskDetail | null>(null)
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 closing = ref(false)
const deleting = ref(false)
@ -568,6 +585,8 @@ const itemFilter = ref<ItemFilter>('all')
const participantFilter = ref('all')
const itemKeyword = ref('')
const adminScanCode = ref('')
let personOptionSearchTimer: number | null = null
let latestPersonOptionRequestId = 0
const showAdminScanner = ref(false)
const adminLastHandledItem = ref<InventoryTaskItem | null>(null)
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 {
const response = await getPersonOptions()
personOptions.value = response.data
.filter((person) => person.status === 'active')
const response = await getPersonOptions({
page: 1,
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'))
} catch (error) {
personOptionTotal.value = response.data?.total || 0
} catch (error: any) {
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 () => {
if (!Number.isInteger(taskId.value) || taskId.value <= 0) {
router.push('/inventory')
@ -875,4 +922,10 @@ watch(
onMounted(() => {
loadTask()
})
onUnmounted(() => {
if (personOptionSearchTimer) {
window.clearTimeout(personOptionSearchTimer)
}
})
</script>

View File

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

View File

@ -365,7 +365,6 @@
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ 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>
</p>
</div>

View File

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