fix: 收紧资产流转安全与敏感数据权限
This commit is contained in:
parent
6a38680aa7
commit
920fa1289d
@ -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=
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`
|
||||
);
|
||||
@ -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'"),
|
||||
'未完成记录必须优先阻断,不能被旧的已完成记录掩盖'
|
||||
)
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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/)
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 || '获取报废记录详情失败')
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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') : '',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 || '获取使用者选项失败')
|
||||
}
|
||||
|
||||
@ -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, '取消打印任务失败')
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
62
backend/src/utils/__tests__/authMode.test.ts
Normal file
62
backend/src/utils/__tests__/authMode.test.ts
Normal 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)
|
||||
})
|
||||
@ -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))
|
||||
|
||||
30
backend/src/utils/__tests__/stage1Migration.test.ts
Normal file
30
backend/src/utils/__tests__/stage1Migration.test.ts
Normal 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)
|
||||
})
|
||||
54
backend/src/utils/authMode.ts
Normal file
54
backend/src/utils/authMode.ts
Normal 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'
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
234
deploy.sh
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 ./
|
||||
|
||||
@ -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, '部门搜索应支持')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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('没有可打印的二维码')
|
||||
|
||||
@ -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>
|
||||
|
||||
231
frontend/src/components/PersonBindingPicker.vue
Normal file
231
frontend/src/components/PersonBindingPicker.vue
Normal 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>
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
95
scripts/tests/deploy-disposal-precheck-test.sh
Normal file
95
scripts/tests/deploy-disposal-precheck-test.sh
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user