fix: 收紧资产流转安全与敏感数据权限
This commit is contained in:
parent
6a38680aa7
commit
920fa1289d
@ -4,9 +4,12 @@ SESSION_SECRET=change-me-session-secret
|
|||||||
SESSION_COOKIE_SECURE=false
|
SESSION_COOKIE_SECURE=false
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
AUTH_MODE=local
|
AUTH_MODE=local
|
||||||
|
KEYCLOAK_URL=
|
||||||
|
KEYCLOAK_REALM=equipment-management
|
||||||
|
KEYCLOAK_CLIENT_ID=equipment-client
|
||||||
|
KEYCLOAK_CLIENT_SECRET=
|
||||||
PORT=3000
|
PORT=3000
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
VITE_AUTH_MODE=local
|
|
||||||
SQLSERVER_HOST=
|
SQLSERVER_HOST=
|
||||||
SQLSERVER_PORT=1433
|
SQLSERVER_PORT=1433
|
||||||
SQLSERVER_DATABASE=
|
SQLSERVER_DATABASE=
|
||||||
|
|||||||
@ -1,32 +1,42 @@
|
|||||||
-- 只读核查:在执行迁移前,列出所有未完成的历史报废单。
|
-- 迁移前只读核查必须在一个一致性快照中同时生成:
|
||||||
-- 如查询有结果,应先由业务负责人确认当前挂载树是否就是本次报废范围。
|
-- 1. 未完成报废单数量;2. 缺失根资产/资产树异常数量;3. 供人工核对及计算指纹的完整资产树。
|
||||||
SELECT
|
-- deploy.sh 会展示并哈希同一批 REVIEW_ROW,禁止把展示与指纹拆成两次查询。
|
||||||
disposal.`id` AS `disposal_id`,
|
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
|
||||||
disposal.`status`,
|
START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;
|
||||||
disposal.`equipment_id` AS `root_equipment_id`,
|
|
||||||
disposal.`equipment_code` AS `root_equipment_code`,
|
|
||||||
disposal.`applied_at`,
|
|
||||||
disposal.`applied_by`
|
|
||||||
FROM `disposal_record` disposal
|
|
||||||
WHERE disposal.`status` IN ('pending', 'approved')
|
|
||||||
ORDER BY disposal.`applied_at`, disposal.`id`;
|
|
||||||
|
|
||||||
-- 只读核查:展开未完成报废单的当前资产树。
|
WITH RECURSIVE `active_disposals` AS (
|
||||||
-- cycle_detected = 1 表示存在环;depth = 100 表示已达自动补录上限,两者都必须先修复数据。
|
SELECT
|
||||||
WITH RECURSIVE `active_disposal_tree` AS (
|
disposal.`id`,
|
||||||
|
disposal.`status`,
|
||||||
|
disposal.`equipment_id`,
|
||||||
|
disposal.`equipment_code`,
|
||||||
|
disposal.`applied_at`,
|
||||||
|
disposal.`applied_by`,
|
||||||
|
disposal.`created_at`,
|
||||||
|
disposal.`updated_at`
|
||||||
|
FROM `disposal_record` disposal
|
||||||
|
WHERE disposal.`status` IN ('pending', 'approved')
|
||||||
|
), `active_disposal_tree` AS (
|
||||||
SELECT
|
SELECT
|
||||||
disposal.`id` AS `disposal_id`,
|
disposal.`id` AS `disposal_id`,
|
||||||
equipment.`id` AS `equipment_id`,
|
equipment.`id` AS `equipment_id`,
|
||||||
equipment.`equipment_code`,
|
equipment.`equipment_code`,
|
||||||
equipment.`equipment_name`,
|
equipment.`equipment_name`,
|
||||||
|
equipment.`equipment_model`,
|
||||||
|
equipment.`equipment_type`,
|
||||||
equipment.`parent_id`,
|
equipment.`parent_id`,
|
||||||
|
parent.`equipment_code` AS `parent_equipment_code`,
|
||||||
equipment.`status` AS `current_status`,
|
equipment.`status` AS `current_status`,
|
||||||
|
equipment.`department_id`,
|
||||||
|
equipment.`storage_location_id`,
|
||||||
|
equipment.`mount_status`,
|
||||||
|
equipment.`updated_at` AS `equipment_updated_at`,
|
||||||
0 AS `depth`,
|
0 AS `depth`,
|
||||||
CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`,
|
CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`,
|
||||||
0 AS `cycle_detected`
|
0 AS `cycle_detected`
|
||||||
FROM `disposal_record` disposal
|
FROM `active_disposals` disposal
|
||||||
INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
|
INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
|
||||||
WHERE disposal.`status` IN ('pending', 'approved')
|
LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id`
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
@ -35,8 +45,15 @@ WITH RECURSIVE `active_disposal_tree` AS (
|
|||||||
child.`id`,
|
child.`id`,
|
||||||
child.`equipment_code`,
|
child.`equipment_code`,
|
||||||
child.`equipment_name`,
|
child.`equipment_name`,
|
||||||
|
child.`equipment_model`,
|
||||||
|
child.`equipment_type`,
|
||||||
child.`parent_id`,
|
child.`parent_id`,
|
||||||
|
tree.`equipment_code`,
|
||||||
child.`status`,
|
child.`status`,
|
||||||
|
child.`department_id`,
|
||||||
|
child.`storage_location_id`,
|
||||||
|
child.`mount_status`,
|
||||||
|
child.`updated_at`,
|
||||||
tree.`depth` + 1,
|
tree.`depth` + 1,
|
||||||
CONCAT(tree.`visited_path`, child.`id`, '/'),
|
CONCAT(tree.`visited_path`, child.`id`, '/'),
|
||||||
LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) > 0
|
LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) > 0
|
||||||
@ -44,15 +61,80 @@ WITH RECURSIVE `active_disposal_tree` AS (
|
|||||||
INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id`
|
INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id`
|
||||||
WHERE tree.`depth` < 100
|
WHERE tree.`depth` < 100
|
||||||
AND tree.`cycle_detected` = 0
|
AND tree.`cycle_detected` = 0
|
||||||
|
), `review_rows` AS (
|
||||||
|
SELECT
|
||||||
|
1 AS `sort_group`,
|
||||||
|
CONCAT('D:', LPAD(disposal.`id`, 20, '0')) AS `sort_key`,
|
||||||
|
CAST(JSON_OBJECT(
|
||||||
|
'record_type', 'disposal',
|
||||||
|
'disposal_id', disposal.`id`,
|
||||||
|
'status', disposal.`status`,
|
||||||
|
'root_equipment_id', disposal.`equipment_id`,
|
||||||
|
'root_equipment_code', disposal.`equipment_code`,
|
||||||
|
'applied_at', DATE_FORMAT(disposal.`applied_at`, '%Y-%m-%dT%H:%i:%s.%f'),
|
||||||
|
'applied_by', disposal.`applied_by`,
|
||||||
|
'created_at', DATE_FORMAT(disposal.`created_at`, '%Y-%m-%dT%H:%i:%s.%f'),
|
||||||
|
'updated_at', DATE_FORMAT(disposal.`updated_at`, '%Y-%m-%dT%H:%i:%s.%f')
|
||||||
|
) AS CHAR) AS `payload`
|
||||||
|
FROM `active_disposals` disposal
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
2 AS `sort_group`,
|
||||||
|
CONCAT(
|
||||||
|
'T:', LPAD(tree.`disposal_id`, 20, '0'), ':',
|
||||||
|
LPAD(tree.`depth`, 3, '0'), ':', LPAD(tree.`equipment_id`, 20, '0')
|
||||||
|
) AS `sort_key`,
|
||||||
|
CAST(JSON_OBJECT(
|
||||||
|
'record_type', 'asset_tree',
|
||||||
|
'disposal_id', tree.`disposal_id`,
|
||||||
|
'equipment_id', tree.`equipment_id`,
|
||||||
|
'equipment_code', tree.`equipment_code`,
|
||||||
|
'equipment_name', tree.`equipment_name`,
|
||||||
|
'equipment_model', tree.`equipment_model`,
|
||||||
|
'equipment_type', tree.`equipment_type`,
|
||||||
|
'parent_id', tree.`parent_id`,
|
||||||
|
'parent_equipment_code', tree.`parent_equipment_code`,
|
||||||
|
'current_status', tree.`current_status`,
|
||||||
|
'department_id', tree.`department_id`,
|
||||||
|
'storage_location_id', tree.`storage_location_id`,
|
||||||
|
'mount_status', tree.`mount_status`,
|
||||||
|
'equipment_updated_at', DATE_FORMAT(tree.`equipment_updated_at`, '%Y-%m-%dT%H:%i:%s.%f'),
|
||||||
|
'depth', tree.`depth`,
|
||||||
|
'cycle_detected', tree.`cycle_detected`
|
||||||
|
) AS CHAR) AS `payload`
|
||||||
|
FROM `active_disposal_tree` tree
|
||||||
|
), `precheck_output` AS (
|
||||||
|
SELECT 0 AS `sort_group`, '1' AS `sort_key`, 'MISSING_ROOT_COUNT' AS `record_type`,
|
||||||
|
CAST(COUNT(*) AS CHAR) AS `payload`
|
||||||
|
FROM `active_disposals` disposal
|
||||||
|
LEFT JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id`
|
||||||
|
WHERE equipment.`id` IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT 0, '2', 'TREE_ANOMALY_COUNT', CAST(COUNT(*) AS CHAR)
|
||||||
|
FROM `active_disposal_tree`
|
||||||
|
WHERE `cycle_detected` = 1 OR `depth` >= 100
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT 0, '3', 'ACTIVE_DISPOSAL_COUNT', CAST(COUNT(*) AS CHAR)
|
||||||
|
FROM `active_disposals`
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT 0, '4', 'REVIEW_ROW_COUNT', CAST(COUNT(*) AS CHAR)
|
||||||
|
FROM `review_rows`
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT `sort_group`, `sort_key`, 'REVIEW_ROW', `payload`
|
||||||
|
FROM `review_rows`
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT `record_type`, `payload`
|
||||||
`disposal_id`,
|
FROM `precheck_output`
|
||||||
`equipment_id`,
|
ORDER BY `sort_group`, `sort_key`;
|
||||||
`equipment_code`,
|
|
||||||
`equipment_name`,
|
COMMIT;
|
||||||
`parent_id`,
|
|
||||||
`current_status`,
|
|
||||||
`depth`,
|
|
||||||
`cycle_detected`
|
|
||||||
FROM `active_disposal_tree`
|
|
||||||
ORDER BY `disposal_id`, `depth`, `equipment_id`;
|
|
||||||
|
|||||||
@ -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 path = require('node:path')
|
||||||
|
|
||||||
const deployPath = path.resolve(__dirname, '../../../deploy.sh')
|
const deployPath = path.resolve(__dirname, '../../../deploy.sh')
|
||||||
|
const precheckPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql'
|
||||||
|
)
|
||||||
const deployScript = fs.readFileSync(deployPath, 'utf8')
|
const deployScript = fs.readFileSync(deployPath, 'utf8')
|
||||||
|
const precheckSql = fs.readFileSync(precheckPath, 'utf8')
|
||||||
|
|
||||||
test('标准部署在 build/up 前必须通过报废迁移预检', () => {
|
test('标准部署在 build/up 前必须通过报废迁移预检', () => {
|
||||||
const gateCall = deployScript.lastIndexOf('\nrun_disposal_migration_precheck\n')
|
const gateCall = deployScript.lastIndexOf('\nrun_disposal_migration_precheck\n')
|
||||||
@ -32,16 +37,19 @@ test('预检只连接固定资产 MySQL,不在命令行暴露密码', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('无法确认或存在未核对报废单时默认阻断,仅允许本次显式确认', () => {
|
test('无法确认或存在未核对报废单时默认阻断,仅允许本次显式确认', () => {
|
||||||
assert.ok(
|
assert.match(precheckSql, /SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;/)
|
||||||
deployScript.includes(
|
assert.match(precheckSql, /START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;/)
|
||||||
"SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;"
|
assert.match(precheckSql, /'ACTIVE_DISPOSAL_COUNT'/)
|
||||||
),
|
assert.match(precheckSql, /'REVIEW_ROW'/)
|
||||||
'预检必须稳定读取待审批和已审批报废单的指纹字段'
|
assert.match(precheckSql, /COMMIT;/)
|
||||||
)
|
|
||||||
assert.match(deployScript, /printf '%s\\n' "\$active_rows" \| sha256sum/)
|
|
||||||
assert.match(
|
assert.match(
|
||||||
deployScript,
|
deployScript,
|
||||||
/CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "\$active_fingerprint"/
|
/snapshot_output="\$\([\s\S]*run_equipment_mysql --batch --skip-column-names --raw < "\$DISPOSAL_PRECHECK_SQL"[\s\S]*\)"/
|
||||||
|
)
|
||||||
|
assert.match(deployScript, /printf '%s\\n' "\$review_rows" \| sha256sum/)
|
||||||
|
assert.match(
|
||||||
|
deployScript,
|
||||||
|
/CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "\$CURRENT_DISPOSAL_FINGERPRINT"/
|
||||||
)
|
)
|
||||||
assert.match(deployScript, /已默认阻止部署/)
|
assert.match(deployScript, /已默认阻止部署/)
|
||||||
})
|
})
|
||||||
@ -49,11 +57,41 @@ test('无法确认或存在未核对报废单时默认阻断,仅允许本次
|
|||||||
test('未完成报废资产树存在环或深度达到 100 时禁止 override', () => {
|
test('未完成报废资产树存在环或深度达到 100 时禁止 override', () => {
|
||||||
const anomalyBlock = deployScript.indexOf('if [ "$tree_anomaly_count" -gt 0 ]')
|
const anomalyBlock = deployScript.indexOf('if [ "$tree_anomaly_count" -gt 0 ]')
|
||||||
const overrideCheck = deployScript.indexOf(
|
const overrideCheck = deployScript.indexOf(
|
||||||
'if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]'
|
'if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$CURRENT_DISPOSAL_FINGERPRINT" ]'
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.match(deployScript, /WHERE cycle_detected = 1 OR depth >= 100;/)
|
assert.match(precheckSql, /WHERE `cycle_detected` = 1 OR `depth` >= 100/)
|
||||||
assert.ok(anomalyBlock > 0, '必须对资产树异常数量做硬阻断')
|
assert.ok(anomalyBlock > 0, '必须对资产树异常数量做硬阻断')
|
||||||
assert.ok(overrideCheck > anomalyBlock, '资产树异常阻断必须早于指纹 override')
|
assert.ok(overrideCheck > anomalyBlock, '资产树异常阻断必须早于指纹 override')
|
||||||
assert.match(deployScript, /不允许通过确认指纹绕过/)
|
assert.match(deployScript, /不允许通过确认指纹绕过/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('人工展示与指纹来自同一次数据库一致性快照', () => {
|
||||||
|
const functionBody = deployScript.slice(
|
||||||
|
deployScript.indexOf('calculate_active_disposal_fingerprint()'),
|
||||||
|
deployScript.indexOf('\nrun_disposal_migration_precheck()')
|
||||||
|
)
|
||||||
|
const mysqlCalls = functionBody.match(/run_equipment_mysql/g) || []
|
||||||
|
|
||||||
|
assert.equal(mysqlCalls.length, 1, '一次指纹计算只能打开一个数据库会话')
|
||||||
|
assert.match(functionBody, /review_rows=.*snapshot_output/)
|
||||||
|
assert.match(functionBody, /printf '%s\\n' "\$review_rows"/)
|
||||||
|
assert.doesNotMatch(
|
||||||
|
deployScript.slice(
|
||||||
|
deployScript.indexOf('run_disposal_migration_precheck()'),
|
||||||
|
deployScript.indexOf('\nrecheck_disposal_migration_precheck()')
|
||||||
|
),
|
||||||
|
/run_equipment_mysql\s+<\s+"\$DISPOSAL_PRECHECK_SQL"/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('同名迁移同时存在完成和未完成记录时必须优先判为 incomplete', () => {
|
||||||
|
const stateQuery = deployScript.slice(
|
||||||
|
deployScript.indexOf('SELECT CASE'),
|
||||||
|
deployScript.indexOf('END\nFROM _prisma_migrations')
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
stateQuery.indexOf("THEN 'incomplete'") < stateQuery.indexOf("THEN 'applied'"),
|
||||||
|
'未完成记录必须优先阻断,不能被旧的已完成记录掩盖'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@ -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,
|
buildDisposalApprovedDingTalkContent,
|
||||||
buildDisposalCompletedDingTalkContent,
|
buildDisposalCompletedDingTalkContent,
|
||||||
buildDisposalRejectedDingTalkContent,
|
buildDisposalRejectedDingTalkContent,
|
||||||
createDisposal
|
createDisposal,
|
||||||
|
getDisposalDetail,
|
||||||
|
getDisposalList
|
||||||
} from '../disposalController'
|
} from '../disposalController'
|
||||||
|
|
||||||
const now = new Date('2026-07-14T03:00:00.000Z')
|
const now = new Date('2026-07-14T03:00:00.000Z')
|
||||||
@ -349,3 +351,96 @@ test('驳回报废时按每个 item.pre_status 分别恢复', async () => {
|
|||||||
;(prisma.operationLog.create as any) = originalLogCreate
|
;(prisma.operationLog.create as any) = originalLogCreate
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('行政已处理列表包含全部已审批、已驳回和已完成记录,不按处理人收窄', async () => {
|
||||||
|
const originalFindMany = prisma.disposalRecord.findMany
|
||||||
|
const originalCount = prisma.disposalRecord.count
|
||||||
|
let capturedWhere: any = null
|
||||||
|
|
||||||
|
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
|
||||||
|
capturedWhere = args.where
|
||||||
|
return [{
|
||||||
|
id: 119,
|
||||||
|
status: 'completed',
|
||||||
|
notes: '旧版处置备注',
|
||||||
|
completion_notes: null
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
;(prisma.disposalRecord.count as any) = async () => 1
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { res, state } = createResponse('administrative')
|
||||||
|
await getDisposalList(
|
||||||
|
{ query: { scope: 'processed', page: '1', pageSize: '10' } } as any,
|
||||||
|
res as any
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(state.statusCode, 200)
|
||||||
|
assert.deepEqual(capturedWhere.AND[0], {
|
||||||
|
status: { in: ['approved', 'rejected', 'completed'] }
|
||||||
|
})
|
||||||
|
assert.doesNotMatch(JSON.stringify(capturedWhere), /approved_by|completed_by/)
|
||||||
|
assert.equal(state.body.data.list[0].notes, '旧版处置备注')
|
||||||
|
assert.equal(state.body.data.list[0].historical_notes, '旧版处置备注')
|
||||||
|
} finally {
|
||||||
|
;(prisma.disposalRecord.findMany as any) = originalFindMany
|
||||||
|
;(prisma.disposalRecord.count as any) = originalCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('IT即使请求all范围也只能查询本人发起的报废记录', async () => {
|
||||||
|
const originalFindMany = prisma.disposalRecord.findMany
|
||||||
|
const originalCount = prisma.disposalRecord.count
|
||||||
|
let capturedWhere: any = null
|
||||||
|
|
||||||
|
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
|
||||||
|
capturedWhere = args.where
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
;(prisma.disposalRecord.count as any) = async () => 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { res, state } = createResponse('it')
|
||||||
|
await getDisposalList(
|
||||||
|
{ query: { scope: 'all', page: '1', pageSize: '10' } } as any,
|
||||||
|
res as any
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(state.statusCode, 200)
|
||||||
|
assert.deepEqual(capturedWhere.AND[0], { applied_by: 6 })
|
||||||
|
} finally {
|
||||||
|
;(prisma.disposalRecord.findMany as any) = originalFindMany
|
||||||
|
;(prisma.disposalRecord.count as any) = originalCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('IT不能通过详情接口查看他人申请,行政可以查看全部流程详情', async () => {
|
||||||
|
const originalFindUnique = prisma.disposalRecord.findUnique
|
||||||
|
;(prisma.disposalRecord.findUnique as any) = async () => ({
|
||||||
|
id: 120,
|
||||||
|
applied_by: 99,
|
||||||
|
status: 'completed',
|
||||||
|
notes: '历史备注',
|
||||||
|
completion_notes: null
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const itResponse = createResponse('it')
|
||||||
|
await getDisposalDetail(
|
||||||
|
{ params: { id: '120' } } as any,
|
||||||
|
itResponse.res as any
|
||||||
|
)
|
||||||
|
assert.equal(itResponse.state.statusCode, 403)
|
||||||
|
|
||||||
|
const administrativeResponse = createResponse('administrative')
|
||||||
|
await getDisposalDetail(
|
||||||
|
{ params: { id: '120' } } as any,
|
||||||
|
administrativeResponse.res as any
|
||||||
|
)
|
||||||
|
assert.equal(administrativeResponse.state.statusCode, 200)
|
||||||
|
assert.equal(administrativeResponse.state.body.data.notes, '历史备注')
|
||||||
|
assert.equal(administrativeResponse.state.body.data.historical_notes, '历史备注')
|
||||||
|
} finally {
|
||||||
|
;(prisma.disposalRecord.findUnique as any) = originalFindUnique
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@ -29,8 +29,10 @@ test('资产列表搜索不应再按借用人身份证号或历史 person_identi
|
|||||||
const originalCount = prisma.equipment.count
|
const originalCount = prisma.equipment.count
|
||||||
|
|
||||||
let capturedWhere: any = null
|
let capturedWhere: any = null
|
||||||
|
let capturedArgs: any = null
|
||||||
|
|
||||||
;(prisma.equipment.findMany as any) = async (args: any) => {
|
;(prisma.equipment.findMany as any) = async (args: any) => {
|
||||||
|
capturedArgs = args
|
||||||
capturedWhere = args.where
|
capturedWhere = args.where
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@ -66,6 +68,11 @@ test('资产列表搜索不应再按借用人身份证号或历史 person_identi
|
|||||||
false,
|
false,
|
||||||
'资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中'
|
'资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中'
|
||||||
)
|
)
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(capturedArgs).includes('person_identifier'),
|
||||||
|
false,
|
||||||
|
'资产接口也不应读取历史 person_identifier 后再返回给行政或 IT'
|
||||||
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
serializedWhere.includes('job_number'),
|
serializedWhere.includes('job_number'),
|
||||||
true,
|
true,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import prisma from '../../utils/prisma'
|
import prisma from '../../utils/prisma'
|
||||||
import { completeMaintenance, createMaintenance } from '../maintenanceController'
|
import { completeMaintenance, createMaintenance, updateMaintenance } from '../maintenanceController'
|
||||||
|
|
||||||
const createMockResponse = () => {
|
const createMockResponse = () => {
|
||||||
const responseBody = { statusCode: 200, body: null as any }
|
const responseBody = { statusCode: 200, body: null as any }
|
||||||
@ -129,3 +129,65 @@ test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', asyn
|
|||||||
;(prisma.$transaction as any) = originalTransaction
|
;(prisma.$transaction as any) = originalTransaction
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('维修资料更新只命中 in_progress,且不再接受未落库的 notes 字段', async () => {
|
||||||
|
const originalUpdateMany = prisma.maintenanceRecord.updateMany
|
||||||
|
const originalFindUnique = prisma.maintenanceRecord.findUnique
|
||||||
|
let updateArgs: any = null
|
||||||
|
|
||||||
|
;(prisma.maintenanceRecord.updateMany as any) = async (args: any) => {
|
||||||
|
updateArgs = args
|
||||||
|
return { count: 1 }
|
||||||
|
}
|
||||||
|
;(prisma.maintenanceRecord.findUnique as any) = async () => ({
|
||||||
|
id: 20,
|
||||||
|
status: 'in_progress',
|
||||||
|
repair_vendor: '测试维修商'
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { res, responseBody } = createMockResponse()
|
||||||
|
await updateMaintenance(
|
||||||
|
{
|
||||||
|
params: { id: '20' },
|
||||||
|
body: {
|
||||||
|
repair_vendor: '测试维修商',
|
||||||
|
estimated_cost: 200,
|
||||||
|
notes: '这个字段不属于维修表,不能静默接收'
|
||||||
|
}
|
||||||
|
} as any,
|
||||||
|
res as any
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(responseBody.statusCode, 200)
|
||||||
|
assert.deepEqual(updateArgs.where, { id: 20, status: 'in_progress' })
|
||||||
|
assert.equal(updateArgs.data.repair_vendor, '测试维修商')
|
||||||
|
assert.equal(updateArgs.data.estimated_cost, 200)
|
||||||
|
assert.equal(Object.prototype.hasOwnProperty.call(updateArgs.data, 'notes'), false)
|
||||||
|
} finally {
|
||||||
|
;(prisma.maintenanceRecord.updateMany as any) = originalUpdateMany
|
||||||
|
;(prisma.maintenanceRecord.findUnique as any) = originalFindUnique
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('维修资料更新与完成并发时条件更新失败并返回409', async () => {
|
||||||
|
const originalUpdateMany = prisma.maintenanceRecord.updateMany
|
||||||
|
const originalFindUnique = prisma.maintenanceRecord.findUnique
|
||||||
|
|
||||||
|
;(prisma.maintenanceRecord.updateMany as any) = async () => ({ count: 0 })
|
||||||
|
;(prisma.maintenanceRecord.findUnique as any) = async () => ({ id: 20, status: 'completed' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { res, responseBody } = createMockResponse()
|
||||||
|
await updateMaintenance(
|
||||||
|
{ params: { id: '20' }, body: { repair_vendor: '并发写入' } } as any,
|
||||||
|
res as any
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(responseBody.statusCode, 409)
|
||||||
|
assert.match(responseBody.body.message, /已被处理/)
|
||||||
|
} finally {
|
||||||
|
;(prisma.maintenanceRecord.updateMany as any) = originalUpdateMany
|
||||||
|
;(prisma.maintenanceRecord.findUnique as any) = originalFindUnique
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@ -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 prisma from '../utils/prisma'
|
||||||
import { successResponse, errorResponse } from '../utils/response'
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
import { logOperation } from './operationLogController'
|
import { logOperation } from './operationLogController'
|
||||||
import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue'
|
|
||||||
import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
|
import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
|
||||||
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
|
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
|
||||||
import {
|
import {
|
||||||
|
borrowAssetTree,
|
||||||
finalizeNormalReturnState,
|
finalizeNormalReturnState,
|
||||||
recordReturnToStorage,
|
recordReturnToStorage,
|
||||||
transferAssetTree
|
transferAssetTree
|
||||||
@ -21,83 +21,26 @@ const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => {
|
|||||||
: notes || undefined
|
: notes || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChildrenSnapshot = async (equipmentId: number) => {
|
|
||||||
const children = await prisma.equipment.findMany({
|
|
||||||
where: { parent_id: equipmentId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
equipment_code: true,
|
|
||||||
equipment_name: true,
|
|
||||||
equipment_model: true,
|
|
||||||
equipment_type: true,
|
|
||||||
unit_price: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return children.length > 0 ? JSON.stringify(children) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchBorrowTransaction = async (
|
const batchBorrowTransaction = async (
|
||||||
equipment: any,
|
equipmentId: number,
|
||||||
person: any,
|
person: any,
|
||||||
expectedReturnTime?: string,
|
expectedReturnTime?: string,
|
||||||
isLongTerm?: boolean,
|
isLongTerm?: boolean,
|
||||||
notes?: string
|
notes?: string
|
||||||
) => {
|
) => {
|
||||||
const childrenSnapshot = await getChildrenSnapshot(equipment.id)
|
|
||||||
const finalNotes = buildBorrowNotes(notes, isLongTerm)
|
const finalNotes = buildBorrowNotes(notes, isLongTerm)
|
||||||
|
return prisma.$transaction(
|
||||||
return prisma.$transaction(async (tx) => {
|
(tx) => borrowAssetTree(tx, {
|
||||||
await tx.borrowRecord.create({
|
rootEquipmentId: equipmentId,
|
||||||
data: {
|
person,
|
||||||
equipment_id: equipment.id,
|
expectedReturnTime: isLongTerm
|
||||||
equipment_code: equipment.equipment_code,
|
? null
|
||||||
person_id: person.id,
|
: (expectedReturnTime ? new Date(expectedReturnTime) : null),
|
||||||
person_name: person.real_name,
|
isLongTerm,
|
||||||
person_nickname: person.nickname,
|
notes: finalNotes
|
||||||
person_identifier: person.job_number,
|
}),
|
||||||
expected_return_time: isLongTerm ? null : (expectedReturnTime ? new Date(expectedReturnTime) : null),
|
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
|
||||||
is_long_term: Boolean(isLongTerm),
|
)
|
||||||
notes: finalNotes,
|
|
||||||
status: '已借出',
|
|
||||||
children_snapshot: childrenSnapshot
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
|
|
||||||
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
|
|
||||||
const values = calculateEquipmentValues({
|
|
||||||
unitPrice: Number(equipment.unit_price),
|
|
||||||
totalQuantity: Number(equipment.total_quantity),
|
|
||||||
remainingQuantity: nextRemainingQuantity,
|
|
||||||
status: nextStatus
|
|
||||||
})
|
|
||||||
const updated = await tx.equipment.updateMany({
|
|
||||||
where: {
|
|
||||||
id: equipment.id,
|
|
||||||
status: '可用',
|
|
||||||
remaining_quantity: equipment.remaining_quantity
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
remaining_quantity: nextRemainingQuantity,
|
|
||||||
total_value: values.total_value,
|
|
||||||
borrowed_value: values.borrowed_value,
|
|
||||||
available_value: values.available_value,
|
|
||||||
status: nextStatus
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (updated.count !== 1) {
|
|
||||||
throw new Error('资产库存已变化,请刷新后重试')
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncMountedChildrenState(
|
|
||||||
tx,
|
|
||||||
equipment.id,
|
|
||||||
'已借出',
|
|
||||||
person.main_department_id ? { departmentId: person.main_department_id } : {}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchReturnTransaction = async (
|
const batchReturnTransaction = async (
|
||||||
@ -182,7 +125,7 @@ export const batchBorrow = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await batchBorrowTransaction(
|
await batchBorrowTransaction(
|
||||||
equipment,
|
equipment.id,
|
||||||
person,
|
person,
|
||||||
expected_return_time,
|
expected_return_time,
|
||||||
is_long_term,
|
is_long_term,
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
import { Request, Response } from 'express'
|
import { Request, Response } from 'express'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
import prisma from '../utils/prisma'
|
import prisma from '../utils/prisma'
|
||||||
import { successResponse, errorResponse } from '../utils/response'
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
import {
|
import {
|
||||||
calculateEquipmentValues,
|
getDisplayEquipmentValue
|
||||||
getDisplayEquipmentValue,
|
|
||||||
syncMountedChildrenState
|
|
||||||
} from '../utils/assetValue'
|
} from '../utils/assetValue'
|
||||||
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
|
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
|
||||||
import {
|
import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
|
||||||
assertReturnStorageDepartmentContract,
|
|
||||||
resolveBorrowDepartmentUpdate
|
|
||||||
} from '../utils/borrowDepartment'
|
|
||||||
import {
|
import {
|
||||||
buildParentAssetFirstOrderBy,
|
buildParentAssetFirstOrderBy,
|
||||||
sortParentAssetsFirst
|
sortParentAssetsFirst
|
||||||
} from '../utils/equipmentSearchPriority'
|
} from '../utils/equipmentSearchPriority'
|
||||||
import { logOperation } from './operationLogController'
|
import { logOperation } from './operationLogController'
|
||||||
import {
|
import {
|
||||||
|
buildRecursiveBorrowSnapshot,
|
||||||
|
borrowAssetTree,
|
||||||
finalizeBorrowAnomalyReturnState,
|
finalizeBorrowAnomalyReturnState,
|
||||||
finalizeNormalReturnState,
|
finalizeNormalReturnState,
|
||||||
|
loadAssetTree,
|
||||||
|
registerBorrowAnomalyAssetTree,
|
||||||
recordReturnToStorage
|
recordReturnToStorage
|
||||||
} from '../services/assetMovementService'
|
} from '../services/assetMovementService'
|
||||||
|
|
||||||
@ -206,93 +206,23 @@ const appendLongTermNotes = (notes?: string, isLongTerm?: boolean) => {
|
|||||||
: notes || undefined
|
: notes || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChildrenSnapshot = async (equipmentId: number) => {
|
|
||||||
const children = await prisma.equipment.findMany({
|
|
||||||
where: { parent_id: equipmentId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
equipment_code: true,
|
|
||||||
equipment_name: true,
|
|
||||||
equipment_model: true,
|
|
||||||
equipment_type: true,
|
|
||||||
unit_price: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
children,
|
|
||||||
snapshot: children.length > 0 ? JSON.stringify(children) : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const borrowEquipmentInTransaction = async (
|
const borrowEquipmentInTransaction = async (
|
||||||
equipment: any,
|
equipmentId: number,
|
||||||
person: any,
|
person: any,
|
||||||
expectedReturn: Date | null,
|
expectedReturn: Date | null,
|
||||||
combinedNotes?: string,
|
combinedNotes?: string,
|
||||||
isLongTerm?: boolean,
|
isLongTerm?: boolean
|
||||||
childrenSnapshot?: string
|
|
||||||
) => {
|
) => {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(
|
||||||
const record = await tx.borrowRecord.create({
|
(tx) => borrowAssetTree(tx, {
|
||||||
data: {
|
rootEquipmentId: equipmentId,
|
||||||
equipment_id: equipment.id,
|
person,
|
||||||
equipment_code: equipment.equipment_code,
|
expectedReturnTime: expectedReturn,
|
||||||
person_id: person.id,
|
isLongTerm,
|
||||||
person_name: person.real_name,
|
notes: combinedNotes
|
||||||
person_nickname: person.nickname,
|
}),
|
||||||
person_identifier: person.job_number,
|
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
|
||||||
expected_return_time: expectedReturn,
|
)
|
||||||
is_long_term: Boolean(isLongTerm),
|
|
||||||
notes: combinedNotes,
|
|
||||||
status: '已借出',
|
|
||||||
children_snapshot: childrenSnapshot
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
|
|
||||||
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
|
|
||||||
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
|
|
||||||
const values = calculateEquipmentValues({
|
|
||||||
unitPrice: Number(equipment.unit_price),
|
|
||||||
totalQuantity: Number(equipment.total_quantity),
|
|
||||||
remainingQuantity: nextRemainingQuantity,
|
|
||||||
status: nextStatus
|
|
||||||
})
|
|
||||||
const updateResult = await tx.equipment.updateMany({
|
|
||||||
where: {
|
|
||||||
id: equipment.id,
|
|
||||||
status: '可用',
|
|
||||||
remaining_quantity: equipment.remaining_quantity
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
remaining_quantity: nextRemainingQuantity,
|
|
||||||
total_value: values.total_value,
|
|
||||||
borrowed_value: values.borrowed_value,
|
|
||||||
available_value: values.available_value,
|
|
||||||
status: nextStatus,
|
|
||||||
...(departmentUpdateData || {})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (updateResult.count !== 1) {
|
|
||||||
throw new Error('资产库存已变化,请刷新后重试')
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncMountedChildrenState(
|
|
||||||
tx,
|
|
||||||
equipment.id,
|
|
||||||
'已借出',
|
|
||||||
departmentUpdateData ? { departmentId: departmentUpdateData.department_id } : {}
|
|
||||||
)
|
|
||||||
|
|
||||||
const updatedEquipment = await tx.equipment.findUnique({
|
|
||||||
where: { id: equipment.id },
|
|
||||||
include: equipmentInclude
|
|
||||||
})
|
|
||||||
|
|
||||||
return { record, updatedEquipment }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const findActiveBorrowRecord = async (
|
const findActiveBorrowRecord = async (
|
||||||
@ -343,17 +273,11 @@ const getChildrenDiff = async (equipmentId: number, borrowRecord: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const borrowedChildren = JSON.parse(borrowRecord.children_snapshot) as any[]
|
const borrowedChildren = JSON.parse(borrowRecord.children_snapshot) as any[]
|
||||||
const currentChildren = await prisma.equipment.findMany({
|
const currentTree = await prisma.$transaction(
|
||||||
where: { parent_id: equipmentId },
|
(tx) => loadAssetTree(tx, equipmentId),
|
||||||
select: {
|
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
|
||||||
id: true,
|
)
|
||||||
equipment_code: true,
|
const currentChildren = buildRecursiveBorrowSnapshot(currentTree)
|
||||||
equipment_name: true,
|
|
||||||
equipment_model: true,
|
|
||||||
equipment_type: true,
|
|
||||||
unit_price: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const borrowedIds = new Set(borrowedChildren.map((child) => child.id))
|
const borrowedIds = new Set(borrowedChildren.map((child) => child.id))
|
||||||
const currentIds = new Set(currentChildren.map((child) => child.id))
|
const currentIds = new Set(currentChildren.map((child) => child.id))
|
||||||
@ -434,81 +358,26 @@ const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any, operate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registerBorrowAnomalyInTransaction = async (
|
const registerBorrowAnomalyInTransaction = async (
|
||||||
equipment: any,
|
equipmentId: number,
|
||||||
person: any,
|
person: any,
|
||||||
borrowTime: Date,
|
borrowTime: Date,
|
||||||
notes?: string
|
notes?: string
|
||||||
) => {
|
) => {
|
||||||
return prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(
|
||||||
const { children, snapshot } = await getChildrenSnapshot(equipment.id)
|
(tx) => registerBorrowAnomalyAssetTree(tx, {
|
||||||
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
|
rootEquipmentId: equipmentId,
|
||||||
const nextRemainingQuantity = Math.max(Number(equipment.total_quantity) - 1, 0)
|
person,
|
||||||
const values = calculateEquipmentValues({
|
borrowTime,
|
||||||
unitPrice: Number(equipment.unit_price),
|
notes
|
||||||
totalQuantity: Number(equipment.total_quantity),
|
}),
|
||||||
remainingQuantity: nextRemainingQuantity,
|
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
|
||||||
status: '已借出'
|
)
|
||||||
})
|
const updatedEquipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id: result.updatedRoot.id },
|
||||||
const record = await tx.borrowRecord.create({
|
include: equipmentInclude
|
||||||
data: {
|
|
||||||
equipment_id: equipment.id,
|
|
||||||
equipment_code: equipment.equipment_code,
|
|
||||||
person_id: person.id,
|
|
||||||
person_name: person.real_name,
|
|
||||||
person_nickname: person.nickname,
|
|
||||||
person_identifier: person.job_number,
|
|
||||||
borrow_time: borrowTime,
|
|
||||||
notes: notes?.trim() || null,
|
|
||||||
status: '已借出',
|
|
||||||
children_snapshot: snapshot
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const updated = await tx.equipment.updateMany({
|
|
||||||
where: {
|
|
||||||
id: equipment.id,
|
|
||||||
parent_id: equipment.parent_id,
|
|
||||||
department_id: equipment.department_id,
|
|
||||||
status: equipment.status,
|
|
||||||
mount_status: equipment.mount_status,
|
|
||||||
total_quantity: equipment.total_quantity,
|
|
||||||
remaining_quantity: equipment.remaining_quantity,
|
|
||||||
unit_price: equipment.unit_price,
|
|
||||||
updated_at: equipment.updated_at
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: '已借出',
|
|
||||||
remaining_quantity: nextRemainingQuantity,
|
|
||||||
total_value: values.total_value,
|
|
||||||
borrowed_value: values.borrowed_value,
|
|
||||||
available_value: values.available_value,
|
|
||||||
...(departmentUpdateData || {})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (updated.count !== 1) {
|
|
||||||
throw new Error('资产状态已变化,请刷新后重试')
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedEquipment = await tx.equipment.findUnique({
|
|
||||||
where: { id: equipment.id },
|
|
||||||
include: equipmentInclude
|
|
||||||
})
|
|
||||||
if (!updatedEquipment) throw new Error('资产不存在')
|
|
||||||
|
|
||||||
await syncMountedChildrenState(
|
|
||||||
tx,
|
|
||||||
equipment.id,
|
|
||||||
'已借出',
|
|
||||||
departmentUpdateData ? { departmentId: departmentUpdateData.department_id } : {}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
record,
|
|
||||||
updatedEquipment,
|
|
||||||
childrenCount: children.length
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
if (!updatedEquipment) throw new Error('资产不存在')
|
||||||
|
return { ...result, updatedEquipment }
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapBorrowRecord = (record: any) => ({
|
const mapBorrowRecord = (record: any) => ({
|
||||||
@ -524,10 +393,8 @@ const mapBorrowRecord = (record: any) => ({
|
|||||||
nickname: record.person.nickname,
|
nickname: record.person.nickname,
|
||||||
real_name: record.person.real_name,
|
real_name: record.person.real_name,
|
||||||
job_number: record.person.job_number,
|
job_number: record.person.job_number,
|
||||||
id_number: record.person.id_number,
|
|
||||||
department: record.person.mainDepartment?.name || record.person.department,
|
department: record.person.mainDepartment?.name || record.person.department,
|
||||||
main_department: record.person.mainDepartment || null,
|
main_department: record.person.mainDepartment || null,
|
||||||
phone: record.person.phone,
|
|
||||||
status: record.person.status
|
status: record.person.status
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@ -538,7 +405,6 @@ const mapBorrowRecord = (record: any) => ({
|
|||||||
person_id: record.person_id,
|
person_id: record.person_id,
|
||||||
person_name: record.person_name,
|
person_name: record.person_name,
|
||||||
person_nickname: record.person_nickname,
|
person_nickname: record.person_nickname,
|
||||||
person_identifier: record.person_identifier,
|
|
||||||
borrow_time: record.borrow_time,
|
borrow_time: record.borrow_time,
|
||||||
expected_return_time: record.expected_return_time,
|
expected_return_time: record.expected_return_time,
|
||||||
is_long_term: record.is_long_term,
|
is_long_term: record.is_long_term,
|
||||||
@ -582,14 +448,12 @@ export const borrowEquipment = async (req: Request, res: Response) => {
|
|||||||
const longTerm = Boolean(is_long_term)
|
const longTerm = Boolean(is_long_term)
|
||||||
const expectedReturn = longTerm ? null : parseDate(expected_return_time)
|
const expectedReturn = longTerm ? null : parseDate(expected_return_time)
|
||||||
const combinedNotes = appendLongTermNotes(notes, longTerm)
|
const combinedNotes = appendLongTermNotes(notes, longTerm)
|
||||||
const { children, snapshot } = await getChildrenSnapshot(equipment.id)
|
const { record: borrowRecord, childrenCount } = await borrowEquipmentInTransaction(
|
||||||
const { record: borrowRecord } = await borrowEquipmentInTransaction(
|
equipment.id,
|
||||||
equipment,
|
|
||||||
person,
|
person,
|
||||||
expectedReturn,
|
expectedReturn,
|
||||||
combinedNotes,
|
combinedNotes,
|
||||||
longTerm,
|
longTerm
|
||||||
snapshot
|
|
||||||
)
|
)
|
||||||
|
|
||||||
successResponse(
|
successResponse(
|
||||||
@ -597,7 +461,7 @@ export const borrowEquipment = async (req: Request, res: Response) => {
|
|||||||
{
|
{
|
||||||
id: borrowRecord.id,
|
id: borrowRecord.id,
|
||||||
borrow_time: borrowRecord.borrow_time,
|
borrow_time: borrowRecord.borrow_time,
|
||||||
children_count: children.length
|
children_count: childrenCount
|
||||||
},
|
},
|
||||||
'借用成功'
|
'借用成功'
|
||||||
)
|
)
|
||||||
@ -608,7 +472,7 @@ export const borrowEquipment = async (req: Request, res: Response) => {
|
|||||||
body: req.body,
|
body: req.body,
|
||||||
user: res.locals.user
|
user: res.locals.user
|
||||||
})
|
})
|
||||||
errorResponse(res, error.message || '借用资产失败')
|
errorResponse(res, error.message || '借用资产失败', error.status || 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -638,14 +502,12 @@ export const scanBorrow = async (req: Request, res: Response) => {
|
|||||||
const longTerm = Boolean(is_long_term)
|
const longTerm = Boolean(is_long_term)
|
||||||
const expectedReturn = longTerm ? null : parseDate(expected_return_time)
|
const expectedReturn = longTerm ? null : parseDate(expected_return_time)
|
||||||
const combinedNotes = appendLongTermNotes(notes, longTerm)
|
const combinedNotes = appendLongTermNotes(notes, longTerm)
|
||||||
const { children, snapshot } = await getChildrenSnapshot(equipment.id)
|
const { record: borrowRecord, updatedRoot, childrenCount } = await borrowEquipmentInTransaction(
|
||||||
const { record: borrowRecord, updatedEquipment } = await borrowEquipmentInTransaction(
|
equipment.id,
|
||||||
equipment,
|
|
||||||
person,
|
person,
|
||||||
expectedReturn,
|
expectedReturn,
|
||||||
combinedNotes,
|
combinedNotes,
|
||||||
longTerm,
|
longTerm
|
||||||
snapshot
|
|
||||||
)
|
)
|
||||||
|
|
||||||
successResponse(
|
successResponse(
|
||||||
@ -656,11 +518,11 @@ export const scanBorrow = async (req: Request, res: Response) => {
|
|||||||
equipment_name: equipment.equipment_name,
|
equipment_name: equipment.equipment_name,
|
||||||
borrow_time: borrowRecord.borrow_time,
|
borrow_time: borrowRecord.borrow_time,
|
||||||
status: '已借出',
|
status: '已借出',
|
||||||
remaining_quantity: updatedEquipment?.remaining_quantity ?? 0,
|
remaining_quantity: updatedRoot.remaining_quantity,
|
||||||
equipment_status: updatedEquipment?.status ?? '已借出',
|
equipment_status: updatedRoot.status,
|
||||||
storage_location_id: equipment.storage_location_id,
|
storage_location_id: equipment.storage_location_id,
|
||||||
storageLocation: equipment.storageLocation,
|
storageLocation: equipment.storageLocation,
|
||||||
children_count: children.length
|
children_count: childrenCount
|
||||||
},
|
},
|
||||||
'出库成功'
|
'出库成功'
|
||||||
)
|
)
|
||||||
@ -671,7 +533,7 @@ export const scanBorrow = async (req: Request, res: Response) => {
|
|||||||
body: req.body,
|
body: req.body,
|
||||||
user: res.locals.user
|
user: res.locals.user
|
||||||
})
|
})
|
||||||
errorResponse(res, error.message || '出库失败')
|
errorResponse(res, error.message || '出库失败', error.status || 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -721,14 +583,12 @@ export const scanBorrowBatch = async (req: Request, res: Response) => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const { snapshot } = await getChildrenSnapshot(equipment.id)
|
|
||||||
await borrowEquipmentInTransaction(
|
await borrowEquipmentInTransaction(
|
||||||
equipment,
|
equipment.id,
|
||||||
person,
|
person,
|
||||||
expectedReturn,
|
expectedReturn,
|
||||||
combinedNotes,
|
combinedNotes,
|
||||||
longTerm,
|
longTerm
|
||||||
snapshot
|
|
||||||
)
|
)
|
||||||
|
|
||||||
results.push({ equipment_code: code, status: 'success', message: '出库成功' })
|
results.push({ equipment_code: code, status: 'success', message: '出库成功' })
|
||||||
@ -1126,8 +986,7 @@ export const getBorrowRecordList = async (req: Request, res: Response) => {
|
|||||||
filters.push({
|
filters.push({
|
||||||
OR: [
|
OR: [
|
||||||
{ person_name: { contains: kw } },
|
{ person_name: { contains: kw } },
|
||||||
{ person_nickname: { contains: kw } },
|
{ person_nickname: { contains: kw } }
|
||||||
{ person_identifier: { contains: kw } }
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -1250,7 +1109,6 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
|
|||||||
id: true,
|
id: true,
|
||||||
person_name: true,
|
person_name: true,
|
||||||
person_nickname: true,
|
person_nickname: true,
|
||||||
person_identifier: true,
|
|
||||||
person: {
|
person: {
|
||||||
select: {
|
select: {
|
||||||
department: true,
|
department: true,
|
||||||
@ -1276,17 +1134,14 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const list = sortParentAssetsFirst(equipmentList).map((equipment) => {
|
const list = sortParentAssetsFirst(equipmentList).map((equipment) => {
|
||||||
const borrowerNames: string[] = []
|
const borrowerNames: string[] = []
|
||||||
const borrowerIdentifiers: string[] = []
|
|
||||||
const borrowerDepartments: string[] = []
|
const borrowerDepartments: string[] = []
|
||||||
|
|
||||||
for (const record of equipment.borrow_records) {
|
for (const record of equipment.borrow_records) {
|
||||||
const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ')
|
const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ')
|
||||||
const borrowerIdentifier = record.person_identifier || null
|
|
||||||
const borrowerDepartment =
|
const borrowerDepartment =
|
||||||
record.person?.mainDepartment?.name || record.person?.department || null
|
record.person?.mainDepartment?.name || record.person?.department || null
|
||||||
|
|
||||||
pushUnique(borrowerNames, borrowerName)
|
pushUnique(borrowerNames, borrowerName)
|
||||||
pushUnique(borrowerIdentifiers, borrowerIdentifier)
|
|
||||||
pushUnique(borrowerDepartments, borrowerDepartment)
|
pushUnique(borrowerDepartments, borrowerDepartment)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1304,7 +1159,6 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
|
|||||||
storageLocation: equipment.storageLocation,
|
storageLocation: equipment.storageLocation,
|
||||||
active_borrow_count: activeBorrowCount,
|
active_borrow_count: activeBorrowCount,
|
||||||
borrower_names: borrowerNames,
|
borrower_names: borrowerNames,
|
||||||
borrower_identifiers: borrowerIdentifiers,
|
|
||||||
borrower_departments: borrowerDepartments,
|
borrower_departments: borrowerDepartments,
|
||||||
can_return: activeBorrowCount > 0,
|
can_return: activeBorrowCount > 0,
|
||||||
can_handle_borrow_anomaly: anomalyMeta.canHandle,
|
can_handle_borrow_anomaly: anomalyMeta.canHandle,
|
||||||
@ -1399,7 +1253,7 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { record, updatedEquipment, childrenCount } = await registerBorrowAnomalyInTransaction(
|
const { record, updatedEquipment, childrenCount } = await registerBorrowAnomalyInTransaction(
|
||||||
equipment,
|
equipment.id,
|
||||||
person,
|
person,
|
||||||
borrowTime,
|
borrowTime,
|
||||||
typeof notes === 'string' ? notes : undefined
|
typeof notes === 'string' ? notes : undefined
|
||||||
@ -1431,6 +1285,6 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => {
|
|||||||
'已补登记借出记录'
|
'已补登记借出记录'
|
||||||
)
|
)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorResponse(res, error.message || '补登记借出失败')
|
errorResponse(res, error.message || '补登记借出失败', error.status || 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -202,6 +202,18 @@ const getCurrentUser = (res: Response) => ({
|
|||||||
role: String(res.locals.user?.role || '')
|
role: String(res.locals.user?.role || '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const withHistoricalDisposalNotes = <T extends {
|
||||||
|
status: string
|
||||||
|
notes?: string | null
|
||||||
|
completion_notes?: string | null
|
||||||
|
}>(record: T) => ({
|
||||||
|
...record,
|
||||||
|
historical_notes:
|
||||||
|
record.status === 'completed' && record.notes && !record.completion_notes
|
||||||
|
? record.notes
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
|
||||||
export const createDisposal = async (req: Request, res: Response) => {
|
export const createDisposal = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@ -382,39 +394,48 @@ export const getDisposalList = async (req: Request, res: Response) => {
|
|||||||
: currentUser.role === 'administrative'
|
: currentUser.role === 'administrative'
|
||||||
? 'pending'
|
? 'pending'
|
||||||
: 'mine'))
|
: 'mine'))
|
||||||
const where: Prisma.DisposalRecordWhereInput = {}
|
const validScopes = ['all', 'mine', 'pending', 'processed']
|
||||||
|
if (!validScopes.includes(resolvedScope)) {
|
||||||
|
return errorResponse(res, '报废列表范围无效', 422)
|
||||||
|
}
|
||||||
|
|
||||||
const scopeConditions: Prisma.DisposalRecordWhereInput[] = []
|
const scopeConditions: Prisma.DisposalRecordWhereInput[] = []
|
||||||
|
|
||||||
if (currentUser.role === 'it') {
|
if (currentUser.role === 'it') {
|
||||||
scopeConditions.push({ applied_by: currentUser.id })
|
scopeConditions.push({ applied_by: currentUser.id })
|
||||||
} else if (resolvedScope === 'mine') {
|
} else if (currentUser.role === 'administrative') {
|
||||||
scopeConditions.push({ applied_by: currentUser.id })
|
if (resolvedScope === 'pending') {
|
||||||
} else if (resolvedScope === 'pending') {
|
scopeConditions.push({ status: 'pending' })
|
||||||
scopeConditions.push({ status: 'pending' })
|
} else if (resolvedScope === 'processed') {
|
||||||
} else if (resolvedScope === 'processed') {
|
scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
|
||||||
scopeConditions.push({
|
} else {
|
||||||
OR: [
|
return errorResponse(res, '行政仅可查看全部待审批和全部已处理报废流程', 403)
|
||||||
{ approved_by: currentUser.id },
|
}
|
||||||
{ completed_by: currentUser.id }
|
} else if (currentUser.role === 'admin') {
|
||||||
]
|
if (resolvedScope === 'mine') {
|
||||||
})
|
scopeConditions.push({ applied_by: currentUser.id })
|
||||||
} else if (resolvedScope === 'all' && currentUser.role !== 'admin') {
|
} else if (resolvedScope === 'pending') {
|
||||||
return errorResponse(res, '只有管理员可以查看全部报废流程', 403)
|
scopeConditions.push({ status: 'pending' })
|
||||||
} else if (!['all', 'mine', 'pending', 'processed'].includes(resolvedScope)) {
|
} else if (resolvedScope === 'processed') {
|
||||||
return errorResponse(res, '报废列表范围无效', 422)
|
scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return errorResponse(res, '无权查看报废流程', 403)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scopeConditions.length > 0) where.AND = scopeConditions
|
if (status) scopeConditions.push({ status: String(status) })
|
||||||
if (status) where.status = String(status)
|
if (equipment_id) scopeConditions.push({ equipment_id: Number(equipment_id) })
|
||||||
if (equipment_id) where.equipment_id = Number(equipment_id)
|
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
const keywordWhere = [
|
const keywordWhere = [
|
||||||
{ equipment_code: { contains: String(keyword) } },
|
{ equipment_code: { contains: String(keyword) } },
|
||||||
{ disposal_reason: { contains: String(keyword) } },
|
{ disposal_reason: { contains: String(keyword) } },
|
||||||
{ items: { some: { equipment_code: { contains: String(keyword) } } } }
|
{ items: { some: { equipment_code: { contains: String(keyword) } } } }
|
||||||
]
|
]
|
||||||
where.AND = [...(Array.isArray(where.AND) ? where.AND : []), { OR: keywordWhere }]
|
scopeConditions.push({ OR: keywordWhere })
|
||||||
}
|
}
|
||||||
|
const where: Prisma.DisposalRecordWhereInput = scopeConditions.length > 0
|
||||||
|
? { AND: scopeConditions }
|
||||||
|
: {}
|
||||||
|
|
||||||
const skip = (Number(page) - 1) * Number(pageSize)
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
const [list, total] = await Promise.all([
|
const [list, total] = await Promise.all([
|
||||||
@ -433,7 +454,11 @@ export const getDisposalList = async (req: Request, res: Response) => {
|
|||||||
}),
|
}),
|
||||||
prisma.disposalRecord.count({ where })
|
prisma.disposalRecord.count({ where })
|
||||||
])
|
])
|
||||||
successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功')
|
successResponse(
|
||||||
|
res,
|
||||||
|
{ list: list.map(withHistoricalDisposalNotes), total, page: Number(page), pageSize: Number(pageSize) },
|
||||||
|
'获取成功'
|
||||||
|
)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorResponse(res, error.message || '获取报废记录列表失败')
|
errorResponse(res, error.message || '获取报废记录列表失败')
|
||||||
}
|
}
|
||||||
@ -450,7 +475,7 @@ export const getDisposalDetail = async (req: Request, res: Response) => {
|
|||||||
if (currentUser.role === 'it' && disposal.applied_by !== currentUser.id) {
|
if (currentUser.role === 'it' && disposal.applied_by !== currentUser.id) {
|
||||||
return errorResponse(res, '无权查看该报废申请', 403)
|
return errorResponse(res, '无权查看该报废申请', 403)
|
||||||
}
|
}
|
||||||
successResponse(res, disposal, '获取成功')
|
successResponse(res, withHistoricalDisposalNotes(disposal), '获取成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorResponse(res, error.message || '获取报废记录详情失败')
|
errorResponse(res, error.message || '获取报废记录详情失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,7 +76,6 @@ const activeBorrowSelect = {
|
|||||||
person_id: true,
|
person_id: true,
|
||||||
person_name: true,
|
person_name: true,
|
||||||
person_nickname: true,
|
person_nickname: true,
|
||||||
person_identifier: true,
|
|
||||||
borrow_time: true,
|
borrow_time: true,
|
||||||
status: true,
|
status: true,
|
||||||
person: {
|
person: {
|
||||||
@ -84,8 +83,7 @@ const activeBorrowSelect = {
|
|||||||
id: true,
|
id: true,
|
||||||
nickname: true,
|
nickname: true,
|
||||||
real_name: true,
|
real_name: true,
|
||||||
job_number: true,
|
job_number: true
|
||||||
id_number: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -226,7 +224,6 @@ const mapCurrentBorrow = (borrowRecords: any[] | undefined) => {
|
|||||||
person_id: record.person_id,
|
person_id: record.person_id,
|
||||||
person_name: record.person_name,
|
person_name: record.person_name,
|
||||||
person_nickname: record.person_nickname,
|
person_nickname: record.person_nickname,
|
||||||
person_identifier: record.person_identifier,
|
|
||||||
borrow_time: record.borrow_time,
|
borrow_time: record.borrow_time,
|
||||||
status: record.status,
|
status: record.status,
|
||||||
person: record.person
|
person: record.person
|
||||||
@ -234,8 +231,7 @@ const mapCurrentBorrow = (borrowRecords: any[] | undefined) => {
|
|||||||
id: record.person.id,
|
id: record.person.id,
|
||||||
nickname: record.person.nickname,
|
nickname: record.person.nickname,
|
||||||
real_name: record.person.real_name,
|
real_name: record.person.real_name,
|
||||||
job_number: record.person.job_number,
|
job_number: record.person.job_number
|
||||||
id_number: record.person.id_number
|
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -319,7 +319,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
|
|||||||
where.OR = [
|
where.OR = [
|
||||||
{ person_name: { contains: String(person_name) } },
|
{ person_name: { contains: String(person_name) } },
|
||||||
{ person_nickname: { contains: String(person_name) } },
|
{ person_nickname: { contains: String(person_name) } },
|
||||||
{ person_identifier: { contains: String(person_name) } }
|
{ person: { is: { job_number: { contains: String(person_name) } } } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (start_date || end_date) {
|
if (start_date || end_date) {
|
||||||
@ -356,7 +356,6 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
|
|||||||
{ header: '资产名称', key: 'equipment_name', width: 25 },
|
{ header: '资产名称', key: 'equipment_name', width: 25 },
|
||||||
{ header: '使用者', key: 'person_name', width: 15 },
|
{ header: '使用者', key: 'person_name', width: 15 },
|
||||||
{ header: '使用者昵称', key: 'person_nickname', width: 15 },
|
{ header: '使用者昵称', key: 'person_nickname', width: 15 },
|
||||||
{ header: '借用时工号', key: 'person_identifier', width: 18 },
|
|
||||||
{ header: '当前工号', key: 'current_job_number', width: 18 },
|
{ header: '当前工号', key: 'current_job_number', width: 18 },
|
||||||
{ header: '部门', key: 'department', width: 15 },
|
{ header: '部门', key: 'department', width: 15 },
|
||||||
{ header: '借用时间', key: 'borrow_time', width: 20 },
|
{ header: '借用时间', key: 'borrow_time', width: 20 },
|
||||||
@ -377,7 +376,6 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
|
|||||||
equipment_name: record.equipment?.equipment_name || '',
|
equipment_name: record.equipment?.equipment_name || '',
|
||||||
person_name: record.person_name,
|
person_name: record.person_name,
|
||||||
person_nickname: record.person_nickname,
|
person_nickname: record.person_nickname,
|
||||||
person_identifier: record.person_identifier || '',
|
|
||||||
current_job_number: record.person?.job_number || '',
|
current_job_number: record.person?.job_number || '',
|
||||||
department: record.person?.mainDepartment?.name || record.person?.department || '',
|
department: record.person?.mainDepartment?.name || record.person?.department || '',
|
||||||
borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '',
|
borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '',
|
||||||
|
|||||||
@ -192,32 +192,39 @@ export const getMaintenanceDetail = async (req: Request, res: Response) => {
|
|||||||
export const updateMaintenance = async (req: Request, res: Response) => {
|
export const updateMaintenance = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
const { repair_vendor, estimated_cost, expected_end_date, notes } = req.body
|
const { repair_vendor, estimated_cost, expected_end_date } = req.body
|
||||||
|
const maintenanceId = Number(id)
|
||||||
|
|
||||||
const maintenance = await prisma.maintenanceRecord.findUnique({
|
if (!Number.isInteger(maintenanceId) || maintenanceId <= 0) {
|
||||||
where: { id: Number(id) }
|
return errorResponse(res, '维修记录ID无效', 422)
|
||||||
})
|
|
||||||
|
|
||||||
if (!maintenance) {
|
|
||||||
return errorResponse(res, '维修记录不存在', 404)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maintenance.status !== 'in_progress') {
|
const result = await prisma.maintenanceRecord.updateMany({
|
||||||
return errorResponse(res, '只能更新进行中的维修记录')
|
where: { id: maintenanceId, status: 'in_progress' },
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.maintenanceRecord.update({
|
|
||||||
where: { id: Number(id) },
|
|
||||||
data: {
|
data: {
|
||||||
repair_vendor,
|
repair_vendor,
|
||||||
estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : undefined,
|
estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : undefined,
|
||||||
expected_end_date: expected_end_date ? new Date(expected_end_date) : undefined
|
expected_end_date: expected_end_date ? new Date(expected_end_date) : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (result.count !== 1) {
|
||||||
|
const current = await prisma.maintenanceRecord.findUnique({
|
||||||
|
where: { id: maintenanceId },
|
||||||
|
select: { id: true, status: true }
|
||||||
|
})
|
||||||
|
if (!current) {
|
||||||
|
return errorResponse(res, '维修记录不存在', 404)
|
||||||
|
}
|
||||||
|
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.maintenanceRecord.findUnique({
|
||||||
|
where: { id: maintenanceId }
|
||||||
|
})
|
||||||
|
|
||||||
successResponse(res, updated, '更新成功')
|
successResponse(res, updated, '更新成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorResponse(res, error.message || '更新维修记录失败')
|
errorResponse(res, error.message || '更新维修记录失败', error.status || 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -97,6 +97,10 @@ const sanitizePerson = (person: any) => {
|
|||||||
department_sync_bucket: person.department_sync_bucket,
|
department_sync_bucket: person.department_sync_bucket,
|
||||||
manager_source_userid: person.manager_source_userid,
|
manager_source_userid: person.manager_source_userid,
|
||||||
source_employee_status: person.source_employee_status,
|
source_employee_status: person.source_employee_status,
|
||||||
|
dingtalk_ready: Boolean(
|
||||||
|
person.status === 'active' &&
|
||||||
|
person.source_userid?.trim()
|
||||||
|
),
|
||||||
last_synced_at: person.last_synced_at,
|
last_synced_at: person.last_synced_at,
|
||||||
is_synced: Boolean(person.source_system),
|
is_synced: Boolean(person.source_system),
|
||||||
created_at: person.created_at,
|
created_at: person.created_at,
|
||||||
@ -104,6 +108,43 @@ const sanitizePerson = (person: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeBusinessPerson = (person: any) => {
|
||||||
|
const departments = collectDepartments(person)
|
||||||
|
const mainDepartment = person.mainDepartment || departments[0] || null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: person.id,
|
||||||
|
nickname: person.nickname,
|
||||||
|
real_name: person.real_name,
|
||||||
|
job_number: person.job_number,
|
||||||
|
department: mainDepartment?.name || person.department || null,
|
||||||
|
main_department: mainDepartment
|
||||||
|
? {
|
||||||
|
id: mainDepartment.id,
|
||||||
|
name: mainDepartment.name,
|
||||||
|
status: mainDepartment.status
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
departments: departments.map((department) => ({
|
||||||
|
id: department.id,
|
||||||
|
name: department.name,
|
||||||
|
status: department.status
|
||||||
|
})),
|
||||||
|
department_names: departments.map((department) => department.name),
|
||||||
|
position: person.position,
|
||||||
|
status: person.status,
|
||||||
|
dingtalk_ready: Boolean(
|
||||||
|
person.status === 'active' &&
|
||||||
|
person.source_userid?.trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsePositiveInteger = (value: unknown, fallback: number) => {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
const buildPersonWhere = (keyword?: unknown, status?: unknown) => {
|
const buildPersonWhere = (keyword?: unknown, status?: unknown) => {
|
||||||
const where: any = {}
|
const where: any = {}
|
||||||
|
|
||||||
@ -465,15 +506,30 @@ export const deletePerson = async (req: Request, res: Response) => {
|
|||||||
export const getPersonOptions = async (req: Request, res: Response) => {
|
export const getPersonOptions = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true'
|
const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true'
|
||||||
const persons = await prisma.person.findMany({
|
const page = parsePositiveInteger(req.query.page, 1)
|
||||||
where: includeInactive ? undefined : { status: 'active' },
|
const requestedPageSize = req.query.page_size ?? req.query.pageSize
|
||||||
|
const pageSize = Math.min(parsePositiveInteger(requestedPageSize, 50), 100)
|
||||||
|
const status = includeInactive ? undefined : (req.query.status || 'active')
|
||||||
|
const where = buildPersonWhere(req.query.keyword, status)
|
||||||
|
const [persons, total] = await Promise.all([
|
||||||
|
prisma.person.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
orderBy: includeInactive
|
orderBy: includeInactive
|
||||||
? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }]
|
? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }]
|
||||||
: [{ nickname: 'asc' }, { real_name: 'asc' }],
|
: [{ nickname: 'asc' }, { real_name: 'asc' }],
|
||||||
include: personInclude
|
include: personInclude
|
||||||
})
|
}),
|
||||||
|
prisma.person.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
successResponse(res, persons.map(sanitizePerson), '获取成功')
|
successResponse(res, {
|
||||||
|
list: persons.map(sanitizeBusinessPerson),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size: pageSize
|
||||||
|
}, '获取成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorResponse(res, error.message || '获取使用者选项失败')
|
errorResponse(res, error.message || '获取使用者选项失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
createAssetLabelPrintJob as createAssetLabelPrintJobService,
|
createAssetLabelPrintJob as createAssetLabelPrintJobService,
|
||||||
createPrintTerminalPairCode as createPrintTerminalPairCodeService,
|
createPrintTerminalPairCode as createPrintTerminalPairCodeService,
|
||||||
heartbeatPrintTerminalAgent,
|
heartbeatPrintTerminalAgent,
|
||||||
|
getDefaultPrintTerminalReadiness,
|
||||||
isPrintServiceError,
|
isPrintServiceError,
|
||||||
listPrintJobs,
|
listPrintJobs,
|
||||||
listPrintTerminals,
|
listPrintTerminals,
|
||||||
@ -53,6 +54,15 @@ export const getPrintTerminals = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getPrintReadiness = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const terminal = await getDefaultPrintTerminalReadiness()
|
||||||
|
successResponse(res, { terminal }, '获取打印就绪状态成功')
|
||||||
|
} catch (error) {
|
||||||
|
handlePrintServiceError(res, error, '获取打印就绪状态失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const createPrintTerminalPairCode = async (req: Request, res: Response) => {
|
export const createPrintTerminalPairCode = async (req: Request, res: Response) => {
|
||||||
const userId = getCurrentUserId(res)
|
const userId = getCurrentUserId(res)
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@ -142,9 +152,15 @@ export const cancelPrintJob = async (req: Request, res: Response) => {
|
|||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return errorResponse(res, '打印任务编号无效', 422)
|
return errorResponse(res, '打印任务编号无效', 422)
|
||||||
}
|
}
|
||||||
|
const userId = getCurrentUserId(res)
|
||||||
|
if (!userId) {
|
||||||
|
return errorResponse(res, '当前登录用户无效', 401)
|
||||||
|
}
|
||||||
|
const canManageAll = Array.isArray(res.locals.user?.permissions) &&
|
||||||
|
res.locals.user.permissions.includes('system.settings')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cancelQueuedPrintJob(jobId)
|
await cancelQueuedPrintJob(jobId, userId, canManageAll)
|
||||||
successResponse(res, null, '打印任务已取消')
|
successResponse(res, null, '打印任务已取消')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handlePrintServiceError(res, error, '取消打印任务失败')
|
handlePrintServiceError(res, error, '取消打印任务失败')
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { Request, Response } from 'express'
|
import { Request, Response } from 'express'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
import prisma from '../utils/prisma'
|
import prisma from '../utils/prisma'
|
||||||
import { successResponse, errorResponse } from '../utils/response'
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
import { getPermissionsForRole, isUserRole } from '../utils/permissions'
|
import { getPermissionsForRole, isUserRole } from '../utils/permissions'
|
||||||
|
|
||||||
const SALT_ROUNDS = 10
|
const SALT_ROUNDS = 10
|
||||||
|
const USER_STATUSES = ['active', 'inactive'] as const
|
||||||
|
|
||||||
class UserValidationError extends Error {
|
class UserValidationError extends Error {
|
||||||
constructor(message: string, public readonly status = 422) {
|
constructor(message: string, public readonly status = 422) {
|
||||||
@ -66,6 +68,38 @@ const normalizePersonId = (value: unknown) => {
|
|||||||
return personId
|
return personId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeUserStatus = (value: unknown, fallback?: string) => {
|
||||||
|
const status = value === undefined || value === null || value === ''
|
||||||
|
? fallback
|
||||||
|
: String(value).trim()
|
||||||
|
if (!status || !USER_STATUSES.includes(status as (typeof USER_STATUSES)[number])) {
|
||||||
|
throw new UserValidationError('状态必须是 active 或 inactive')
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentUserId = (res: Response) => {
|
||||||
|
const userId = Number((res as any).locals?.user?.id)
|
||||||
|
return Number.isInteger(userId) && userId > 0 ? userId : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockActiveAdministrators = async (tx: Prisma.TransactionClient) => {
|
||||||
|
return tx.$queryRawUnsafe<Array<{ id: number }>>(
|
||||||
|
"SELECT id FROM `user` WHERE role = 'admin' AND status = 'active' ORDER BY id FOR UPDATE"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertActiveAdministratorRemains = async (
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
targetUserId: number
|
||||||
|
) => {
|
||||||
|
const activeAdministrators = await lockActiveAdministrators(tx)
|
||||||
|
const targetIsActiveAdministrator = activeAdministrators.some((item) => Number(item.id) === targetUserId)
|
||||||
|
if (targetIsActiveAdministrator && activeAdministrators.length <= 1) {
|
||||||
|
throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => {
|
const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => {
|
||||||
const person = await prisma.person.findUnique({ where: { id: personId } })
|
const person = await prisma.person.findUnique({ where: { id: personId } })
|
||||||
if (!person) {
|
if (!person) {
|
||||||
@ -176,6 +210,7 @@ export const createUser = async (req: Request, res: Response) => {
|
|||||||
if (!isUserRole(primaryRole)) {
|
if (!isUserRole(primaryRole)) {
|
||||||
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
|
return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422)
|
||||||
}
|
}
|
||||||
|
const normalizedStatus = normalizeUserStatus(status, 'active')
|
||||||
|
|
||||||
const personId = normalizePersonId(person_id)
|
const personId = normalizePersonId(person_id)
|
||||||
if (typeof personId === 'number') {
|
if (typeof personId === 'number') {
|
||||||
@ -192,7 +227,7 @@ export const createUser = async (req: Request, res: Response) => {
|
|||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
role: primaryRole,
|
role: primaryRole,
|
||||||
status: status || 'active',
|
status: normalizedStatus,
|
||||||
person_id: personId ?? null
|
person_id: personId ?? null
|
||||||
},
|
},
|
||||||
include: { person: { select: personSelect } }
|
include: { person: { select: personSelect } }
|
||||||
@ -228,8 +263,18 @@ export const updateUser = async (req: Request, res: Response) => {
|
|||||||
updateData.role = newRole
|
updateData.role = newRole
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status !== undefined) {
|
||||||
updateData.status = status
|
updateData.status = normalizeUserStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = getCurrentUserId(res)
|
||||||
|
if (currentUserId === user.id) {
|
||||||
|
if (newRole && newRole !== 'admin') {
|
||||||
|
return errorResponse(res, '不能降低当前登录管理员自己的角色', 422)
|
||||||
|
}
|
||||||
|
if (updateData.status && updateData.status !== 'active') {
|
||||||
|
return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const personId = normalizePersonId(person_id)
|
const personId = normalizePersonId(person_id)
|
||||||
@ -240,11 +285,34 @@ export const updateUser = async (req: Request, res: Response) => {
|
|||||||
updateData.person_id = null
|
updateData.person_id = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.user.update({
|
const changesRoleOrStatus = Boolean(updateData.role || updateData.status)
|
||||||
where: { id: Number(id) },
|
|
||||||
data: updateData,
|
const updated = changesRoleOrStatus
|
||||||
include: { person: { select: personSelect } }
|
? await prisma.$transaction(async (tx) => {
|
||||||
})
|
const activeAdministrators = await lockActiveAdministrators(tx)
|
||||||
|
const current = await tx.user.findUnique({ where: { id: user.id } })
|
||||||
|
if (!current) {
|
||||||
|
throw new UserValidationError('用户不存在', 404)
|
||||||
|
}
|
||||||
|
const finalRole = updateData.role || current.role
|
||||||
|
const finalStatus = updateData.status || current.status
|
||||||
|
const removesActiveAdministrator = activeAdministrators.some(
|
||||||
|
(item) => Number(item.id) === current.id
|
||||||
|
) && (finalRole !== 'admin' || finalStatus !== 'active')
|
||||||
|
if (removesActiveAdministrator && activeAdministrators.length <= 1) {
|
||||||
|
throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409)
|
||||||
|
}
|
||||||
|
return tx.user.update({
|
||||||
|
where: { id: current.id },
|
||||||
|
data: updateData,
|
||||||
|
include: { person: { select: personSelect } }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
: await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: updateData,
|
||||||
|
include: { person: { select: personSelect } }
|
||||||
|
})
|
||||||
|
|
||||||
successResponse(res, sanitizeUser(updated), '更新成功')
|
successResponse(res, sanitizeUser(updated), '更新成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -284,24 +352,33 @@ export const updateUserStatus = async (req: Request, res: Response) => {
|
|||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
const { status } = req.body as { status?: string }
|
const { status } = req.body as { status?: string }
|
||||||
|
|
||||||
if (!status) {
|
const normalizedStatus = normalizeUserStatus(status)
|
||||||
return errorResponse(res, '状态不能为空', 422)
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse(res, '用户不存在', 404)
|
return errorResponse(res, '用户不存在', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.user.update({
|
if (getCurrentUserId(res) === user.id && normalizedStatus !== 'active') {
|
||||||
where: { id: Number(id) },
|
return errorResponse(res, '不能停用当前登录管理员自己的账户', 422)
|
||||||
data: { status },
|
}
|
||||||
include: { person: { select: personSelect } }
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
if (normalizedStatus !== 'active') {
|
||||||
|
await assertActiveAdministratorRemains(tx, user.id)
|
||||||
|
} else {
|
||||||
|
await lockActiveAdministrators(tx)
|
||||||
|
}
|
||||||
|
return tx.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { status: normalizedStatus },
|
||||||
|
include: { person: { select: personSelect } }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
successResponse(res, sanitizeUser(updated), '状态更新成功')
|
successResponse(res, sanitizeUser(updated), '状态更新成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorResponse(res, error.message || '更新用户状态失败')
|
errorResponse(res, error.message || '更新用户状态失败', error.status || 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,6 +388,10 @@ export const deleteUser = async (req: Request, res: Response) => {
|
|||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
const userId = Number(id)
|
const userId = Number(id)
|
||||||
|
|
||||||
|
if (getCurrentUserId(res) === userId) {
|
||||||
|
return errorResponse(res, '不能删除当前登录账户', 422)
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
include: {
|
include: {
|
||||||
@ -333,11 +414,6 @@ export const deleteUser = async (req: Request, res: Response) => {
|
|||||||
return errorResponse(res, '用户不存在', 404)
|
return errorResponse(res, '用户不存在', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不允许删除 admin 用户
|
|
||||||
if (user.username === 'admin') {
|
|
||||||
return errorResponse(res, '不能删除管理员账户')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
user._count.site_configs > 0 ||
|
user._count.site_configs > 0 ||
|
||||||
user._count.mount_histories > 0 ||
|
user._count.mount_histories > 0 ||
|
||||||
@ -353,6 +429,7 @@ export const deleteUser = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await assertActiveAdministratorRemains(tx, userId)
|
||||||
await tx.operationLog.deleteMany({
|
await tx.operationLog.deleteMany({
|
||||||
where: { user_id: userId }
|
where: { user_id: userId }
|
||||||
})
|
})
|
||||||
@ -362,6 +439,6 @@ export const deleteUser = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
successResponse(res, null, '删除成功')
|
successResponse(res, null, '删除成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorResponse(res, error.message || '删除用户失败')
|
errorResponse(res, error.message || '删除用户失败', error.status || 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import dotenv from 'dotenv'
|
|
||||||
import session from 'express-session'
|
import session from 'express-session'
|
||||||
import equipmentRoutes from './routes/equipment'
|
import equipmentRoutes from './routes/equipment'
|
||||||
import borrowRoutes, { returnRouter } from './routes/borrow'
|
import borrowRoutes, { returnRouter } from './routes/borrow'
|
||||||
@ -33,19 +33,18 @@ import printDownloadRoutes from './routes/printDownloads'
|
|||||||
import myAssetRoutes from './routes/myAssets'
|
import myAssetRoutes from './routes/myAssets'
|
||||||
import dingtalkRoutes from './routes/dingtalk'
|
import dingtalkRoutes from './routes/dingtalk'
|
||||||
import { keycloak, memoryStore } from './utils/keycloak'
|
import { keycloak, memoryStore } from './utils/keycloak'
|
||||||
import { protect, requirePermission } from './middleware/auth'
|
import { protect } from './middleware/auth'
|
||||||
import { PERMISSIONS } from './utils/permissions'
|
|
||||||
import { startHrSyncScheduler } from './services/hrSyncService'
|
import { startHrSyncScheduler } from './services/hrSyncService'
|
||||||
import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService'
|
import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService'
|
||||||
import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService'
|
import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService'
|
||||||
import { isCorsOriginAllowed } from './utils/corsPolicy'
|
import { isCorsOriginAllowed } from './utils/corsPolicy'
|
||||||
import { unsafeOriginGuard } from './middleware/originGuard'
|
import { unsafeOriginGuard } from './middleware/originGuard'
|
||||||
|
import { assertSupportedAuthMode, resolveSessionSecret } from './utils/authMode'
|
||||||
dotenv.config()
|
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT || 3000
|
const PORT = process.env.PORT || 3000
|
||||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secret'
|
const AUTH_MODE = assertSupportedAuthMode()
|
||||||
|
const SESSION_SECRET = resolveSessionSecret()
|
||||||
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === undefined
|
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === undefined
|
||||||
? process.env.NODE_ENV === 'production'
|
? process.env.NODE_ENV === 'production'
|
||||||
: process.env.SESSION_COOKIE_SECURE === 'true'
|
: process.env.SESSION_COOKIE_SECURE === 'true'
|
||||||
@ -73,7 +72,7 @@ app.use(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
if (process.env.AUTH_MODE !== 'local') {
|
if (AUTH_MODE === 'keycloak') {
|
||||||
app.use(keycloak.middleware({ logout: '/api/auth/logout', admin: '/keycloak' }))
|
app.use(keycloak.middleware({ logout: '/api/auth/logout', admin: '/keycloak' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,8 +103,8 @@ app.use('/api/sync', syncRoutes)
|
|||||||
app.use('/api/notifications', notificationRoutes)
|
app.use('/api/notifications', notificationRoutes)
|
||||||
app.use('/api/inventory', inventoryRoutes)
|
app.use('/api/inventory', inventoryRoutes)
|
||||||
app.use('/api/public/inventory', publicInventoryRoutes)
|
app.use('/api/public/inventory', publicInventoryRoutes)
|
||||||
app.use('/api/print-terminals', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), printTerminalRoutes)
|
app.use('/api/print-terminals', protect(), printTerminalRoutes)
|
||||||
app.use('/api/print-jobs', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), printJobRoutes)
|
app.use('/api/print-jobs', protect(), printJobRoutes)
|
||||||
app.use('/api/print-agent', printAgentRoutes)
|
app.use('/api/print-agent', printAgentRoutes)
|
||||||
app.use('/api/my-assets', myAssetRoutes)
|
app.use('/api/my-assets', myAssetRoutes)
|
||||||
app.use('/api/dingtalk', dingtalkRoutes)
|
app.use('/api/dingtalk', dingtalkRoutes)
|
||||||
|
|||||||
@ -7,8 +7,9 @@ import {
|
|||||||
} from '../utils/authIdentity'
|
} from '../utils/authIdentity'
|
||||||
import { PERMISSIONS, type Permission } from '../utils/permissions'
|
import { PERMISSIONS, type Permission } from '../utils/permissions'
|
||||||
import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService'
|
import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService'
|
||||||
|
import { resolveAuthMode } from '../utils/authMode'
|
||||||
|
|
||||||
const isLocalAuth = () => process.env.AUTH_MODE === 'local'
|
const isLocalAuth = () => resolveAuthMode() === 'local'
|
||||||
|
|
||||||
const hasRealBearerToken = (req: Request) => {
|
const hasRealBearerToken = (req: Request) => {
|
||||||
const authorization = req.headers.authorization
|
const authorization = req.headers.authorization
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import bcrypt from 'bcryptjs'
|
|||||||
import prisma from '../utils/prisma'
|
import prisma from '../utils/prisma'
|
||||||
import { protect } from '../middleware/auth'
|
import { protect } from '../middleware/auth'
|
||||||
import { resolveAuthUserById } from '../utils/authIdentity'
|
import { resolveAuthUserById } from '../utils/authIdentity'
|
||||||
|
import { resolveAuthMode } from '../utils/authMode'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ router.get('/user', protect(), (_req, res) => successResponse(res, res.locals.us
|
|||||||
|
|
||||||
// Keycloak 配置信息(前端使用)
|
// Keycloak 配置信息(前端使用)
|
||||||
router.get('/config', (req, res) => {
|
router.get('/config', (req, res) => {
|
||||||
const authMode = process.env.AUTH_MODE === 'keycloak' ? 'keycloak' : 'local'
|
const authMode = resolveAuthMode()
|
||||||
successResponse(res, {
|
successResponse(res, {
|
||||||
authMode,
|
authMode,
|
||||||
url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '',
|
url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '',
|
||||||
@ -47,7 +48,7 @@ router.post('/logout', async (req, res) => {
|
|||||||
// 本地登录模式(不跳转 Keycloak)
|
// 本地登录模式(不跳转 Keycloak)
|
||||||
router.post('/local/login', async (req: Request, res: Response) => {
|
router.post('/local/login', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (process.env.AUTH_MODE !== 'local') {
|
if (resolveAuthMode() !== 'local') {
|
||||||
return errorResponse(res, '本地登录未启用', 400)
|
return errorResponse(res, '本地登录未启用', 400)
|
||||||
}
|
}
|
||||||
const { username, password } = req.body as { username?: string; password?: string }
|
const { username, password } = req.body as { username?: string; password?: string }
|
||||||
|
|||||||
@ -15,9 +15,9 @@ const router = Router()
|
|||||||
|
|
||||||
router.use(protect())
|
router.use(protect())
|
||||||
|
|
||||||
router.get('/', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonList)
|
router.get('/', requirePermission(PERMISSIONS.USERS_MANAGE), getPersonList)
|
||||||
router.get('/options', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonOptions)
|
router.get('/options', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonOptions)
|
||||||
router.get('/:id', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonDetail)
|
router.get('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), getPersonDetail)
|
||||||
|
|
||||||
router.post('/', requirePermission(PERMISSIONS.USERS_MANAGE), createPerson)
|
router.post('/', requirePermission(PERMISSIONS.USERS_MANAGE), createPerson)
|
||||||
router.put('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), updatePerson)
|
router.put('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), updatePerson)
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import {
|
|||||||
createAssetLabelPrintJob,
|
createAssetLabelPrintJob,
|
||||||
getPrintJobs
|
getPrintJobs
|
||||||
} from '../controllers/printController'
|
} from '../controllers/printController'
|
||||||
|
import { requirePermission } from '../middleware/auth'
|
||||||
|
import { PERMISSIONS } from '../utils/permissions'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.post('/asset-labels', createAssetLabelPrintJob)
|
router.post('/asset-labels', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), createAssetLabelPrintJob)
|
||||||
router.get('/', getPrintJobs)
|
router.get('/', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getPrintJobs)
|
||||||
router.patch('/:id/cancel', cancelPrintJob)
|
router.patch('/:id/cancel', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), cancelPrintJob)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import {
|
import {
|
||||||
createPrintTerminalPairCode,
|
createPrintTerminalPairCode,
|
||||||
|
getPrintReadiness,
|
||||||
getPrintTerminals,
|
getPrintTerminals,
|
||||||
setDefaultPrintTerminal,
|
setDefaultPrintTerminal,
|
||||||
updatePrintTerminalStatus
|
updatePrintTerminalStatus
|
||||||
} from '../controllers/printController'
|
} from '../controllers/printController'
|
||||||
|
import { requirePermission } from '../middleware/auth'
|
||||||
|
import { PERMISSIONS } from '../utils/permissions'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.get('/', getPrintTerminals)
|
router.get('/readiness', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), getPrintReadiness)
|
||||||
router.post('/pair-codes', createPrintTerminalPairCode)
|
router.get('/', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getPrintTerminals)
|
||||||
router.patch('/:id/default', setDefaultPrintTerminal)
|
router.post('/pair-codes', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), createPrintTerminalPairCode)
|
||||||
router.patch('/:id/status', updatePrintTerminalStatus)
|
router.patch('/:id/default', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), setDefaultPrintTerminal)
|
||||||
|
router.patch('/:id/status', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), updatePrintTerminalStatus)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
borrowAssetTree,
|
||||||
finalizeBorrowAnomalyReturnState,
|
finalizeBorrowAnomalyReturnState,
|
||||||
|
registerBorrowAnomalyAssetTree,
|
||||||
transferAssetTree
|
transferAssetTree
|
||||||
} from '../assetMovementService'
|
} from '../assetMovementService'
|
||||||
|
|
||||||
@ -153,3 +155,370 @@ test('异常归还在无活动借用时按锁后快照 CAS 恢复资产', async
|
|||||||
assert.equal(updates[0].data.remaining_quantity, 1)
|
assert.equal(updates[0].data.remaining_quantity, 1)
|
||||||
assert.equal(updates[0].data.department_id, null)
|
assert.equal(updates[0].data.department_id, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const borrowPerson = {
|
||||||
|
id: 8,
|
||||||
|
real_name: '测试员工',
|
||||||
|
nickname: '员工',
|
||||||
|
job_number: 'E0008',
|
||||||
|
main_department_id: 9
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowRoot = {
|
||||||
|
...rootEquipment,
|
||||||
|
equipment_model: 'M720',
|
||||||
|
equipment_type: '主机'
|
||||||
|
}
|
||||||
|
|
||||||
|
test('借出时锁定递归资产树并同步根资产、配件部门及递归快照', async () => {
|
||||||
|
const child = {
|
||||||
|
...childEquipment,
|
||||||
|
equipment_model: '32G',
|
||||||
|
equipment_type: '内存条'
|
||||||
|
}
|
||||||
|
const grandchild = {
|
||||||
|
...child,
|
||||||
|
id: 22,
|
||||||
|
equipment_code: 'JS-RAM-0022',
|
||||||
|
equipment_name: '内存子配件',
|
||||||
|
equipment_model: '16G',
|
||||||
|
parent_id: child.id
|
||||||
|
}
|
||||||
|
const updates: any[] = []
|
||||||
|
let borrowRecordData: any = null
|
||||||
|
const childrenForParents = (parentIds: number[]) => {
|
||||||
|
if (parentIds.includes(borrowRoot.id)) return [child]
|
||||||
|
if (parentIds.includes(child.id)) return [grandchild]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const client = {
|
||||||
|
$queryRaw: async (query: any) => {
|
||||||
|
if (query.sql.startsWith('SELECT id FROM equipment')) return []
|
||||||
|
if (query.sql.includes('FROM borrow_record')) return []
|
||||||
|
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...borrowRoot }]
|
||||||
|
if (query.sql.includes('WHERE equipment.parent_id IN')) {
|
||||||
|
return childrenForParents(query.values as number[]).map((item) => ({ ...item }))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
findUnique: async () => ({ ...borrowRoot }),
|
||||||
|
findMany: async (args: any) => childrenForParents(args.where.parent_id.in).map((item) => ({ ...item })),
|
||||||
|
updateMany: async (args: any) => {
|
||||||
|
updates.push(args)
|
||||||
|
return { count: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borrowRecord: {
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
borrowRecordData = data
|
||||||
|
return { id: 701, borrow_time: now, ...data }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maintenanceRecord: { findFirst: async () => null },
|
||||||
|
disposalRecord: { findFirst: async () => null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await borrowAssetTree(client as any, {
|
||||||
|
rootEquipmentId: borrowRoot.id,
|
||||||
|
person: borrowPerson,
|
||||||
|
expectedReturnTime: null,
|
||||||
|
isLongTerm: true,
|
||||||
|
notes: '在职长期'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.childrenCount, 2)
|
||||||
|
assert.equal(updates.length, 3)
|
||||||
|
assert.deepEqual(updates.map((item) => item.data.department_id), [9, 9, 9])
|
||||||
|
assert.equal(updates[0].data.status, '已借出')
|
||||||
|
assert.equal(updates[0].data.remaining_quantity, 0)
|
||||||
|
assert.deepEqual(updates.slice(1).map((item) => item.data.remaining_quantity), [0, 0])
|
||||||
|
const snapshot = JSON.parse(borrowRecordData.children_snapshot)
|
||||||
|
assert.deepEqual(snapshot.map((item: any) => ({ id: item.id, parent_id: item.parent_id, depth: item.depth })), [
|
||||||
|
{ id: 21, parent_id: 20, depth: 1 },
|
||||||
|
{ id: 22, parent_id: 21, depth: 2 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('借出初读后并发调拨部门时中止且不创建借用记录', async () => {
|
||||||
|
let transferredAfterInitialRead = false
|
||||||
|
let equipmentWriteCount = 0
|
||||||
|
let borrowCreateCount = 0
|
||||||
|
const client = {
|
||||||
|
$queryRaw: async (query: any) => {
|
||||||
|
if (query.sql.startsWith('SELECT id FROM equipment')) {
|
||||||
|
transferredAfterInitialRead = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (query.sql.includes('WHERE equipment.id = ?')) {
|
||||||
|
return [{ ...borrowRoot, department_id: transferredAfterInitialRead ? 12 : borrowRoot.department_id }]
|
||||||
|
}
|
||||||
|
if (query.sql.includes('WHERE equipment.parent_id IN')) return []
|
||||||
|
if (query.sql.includes('FROM borrow_record')) return []
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
findUnique: async () => ({ ...borrowRoot }),
|
||||||
|
findMany: async () => [],
|
||||||
|
updateMany: async () => {
|
||||||
|
equipmentWriteCount += 1
|
||||||
|
return { count: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borrowRecord: {
|
||||||
|
create: async () => {
|
||||||
|
borrowCreateCount += 1
|
||||||
|
return { id: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
borrowAssetTree(client as any, {
|
||||||
|
rootEquipmentId: borrowRoot.id,
|
||||||
|
person: borrowPerson
|
||||||
|
}),
|
||||||
|
/资产树状态或挂载关系已变化/
|
||||||
|
)
|
||||||
|
assert.equal(equipmentWriteCount, 0)
|
||||||
|
assert.equal(borrowCreateCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('借出初读后并发挂载新配件时中止且不创建借用记录', async () => {
|
||||||
|
let mountedAfterInitialRead = false
|
||||||
|
let equipmentWriteCount = 0
|
||||||
|
let borrowCreateCount = 0
|
||||||
|
const newChild = {
|
||||||
|
...childEquipment,
|
||||||
|
equipment_model: '16G',
|
||||||
|
equipment_type: '内存条'
|
||||||
|
}
|
||||||
|
const client = {
|
||||||
|
$queryRaw: async (query: any) => {
|
||||||
|
if (query.sql.startsWith('SELECT id FROM equipment')) {
|
||||||
|
mountedAfterInitialRead = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...borrowRoot }]
|
||||||
|
if (query.sql.includes('WHERE equipment.parent_id IN')) {
|
||||||
|
return mountedAfterInitialRead && query.values.includes(borrowRoot.id) ? [{ ...newChild }] : []
|
||||||
|
}
|
||||||
|
if (query.sql.includes('FROM borrow_record')) return []
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
findUnique: async () => ({ ...borrowRoot }),
|
||||||
|
findMany: async () => [],
|
||||||
|
updateMany: async () => {
|
||||||
|
equipmentWriteCount += 1
|
||||||
|
return { count: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borrowRecord: {
|
||||||
|
create: async () => {
|
||||||
|
borrowCreateCount += 1
|
||||||
|
return { id: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
borrowAssetTree(client as any, {
|
||||||
|
rootEquipmentId: borrowRoot.id,
|
||||||
|
person: borrowPerson
|
||||||
|
}),
|
||||||
|
/资产树状态或挂载关系已变化/
|
||||||
|
)
|
||||||
|
assert.equal(equipmentWriteCount, 0)
|
||||||
|
assert.equal(borrowCreateCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('同一资产并发重复借出时只有一个请求成功', async () => {
|
||||||
|
let state = { ...borrowRoot }
|
||||||
|
const records: any[] = []
|
||||||
|
let lockCount = 0
|
||||||
|
let releaseSecondLock!: () => void
|
||||||
|
const secondLockGate = new Promise<void>((resolve) => {
|
||||||
|
releaseSecondLock = resolve
|
||||||
|
})
|
||||||
|
const client = {
|
||||||
|
$queryRaw: async (query: any) => {
|
||||||
|
if (query.sql.startsWith('SELECT id FROM equipment')) {
|
||||||
|
lockCount += 1
|
||||||
|
if (lockCount === 2) await secondLockGate
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...state }]
|
||||||
|
if (query.sql.includes('WHERE equipment.parent_id IN')) return []
|
||||||
|
if (query.sql.includes('FROM borrow_record')) {
|
||||||
|
return records.map((record) => ({ id: record.id, equipment_id: record.equipment_id }))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
findUnique: async () => ({ ...state }),
|
||||||
|
findMany: async () => [],
|
||||||
|
updateMany: async (args: any) => {
|
||||||
|
state = { ...state, ...args.data }
|
||||||
|
releaseSecondLock()
|
||||||
|
return { count: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borrowRecord: {
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
const record = { id: records.length + 1, borrow_time: now, ...data }
|
||||||
|
records.push(record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maintenanceRecord: { findFirst: async () => null },
|
||||||
|
disposalRecord: { findFirst: async () => null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
borrowAssetTree(client as any, { rootEquipmentId: borrowRoot.id, person: borrowPerson }),
|
||||||
|
borrowAssetTree(client as any, { rootEquipmentId: borrowRoot.id, person: borrowPerson })
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(results.filter((item) => item.status === 'fulfilled').length, 1)
|
||||||
|
assert.equal(results.filter((item) => item.status === 'rejected').length, 1)
|
||||||
|
const rejected = results.find((item): item is PromiseRejectedResult => item.status === 'rejected')
|
||||||
|
assert.match(String(rejected?.reason?.message || ''), /资产树状态或挂载关系已变化/)
|
||||||
|
assert.equal(records.length, 1)
|
||||||
|
assert.equal(state.remaining_quantity, 0)
|
||||||
|
assert.equal(state.status, '已借出')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('异常借用补登记锁定完整资产树并保存递归配件快照', async () => {
|
||||||
|
const anomalousRoot = {
|
||||||
|
...borrowRoot,
|
||||||
|
status: '已借出',
|
||||||
|
remaining_quantity: 0
|
||||||
|
}
|
||||||
|
const child = {
|
||||||
|
...childEquipment,
|
||||||
|
equipment_model: '32G',
|
||||||
|
equipment_type: '内存条',
|
||||||
|
status: '已借出',
|
||||||
|
remaining_quantity: 0
|
||||||
|
}
|
||||||
|
const grandchild = {
|
||||||
|
...child,
|
||||||
|
id: 22,
|
||||||
|
equipment_code: 'JS-SSD-0022',
|
||||||
|
equipment_name: '固态硬盘',
|
||||||
|
equipment_model: '1T',
|
||||||
|
equipment_type: '固态硬盘',
|
||||||
|
parent_id: child.id
|
||||||
|
}
|
||||||
|
const childrenForParents = (parentIds: number[]) => {
|
||||||
|
if (parentIds.includes(anomalousRoot.id)) return [child]
|
||||||
|
if (parentIds.includes(child.id)) return [grandchild]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const updates: any[] = []
|
||||||
|
let borrowRecordData: any = null
|
||||||
|
const client = {
|
||||||
|
$queryRaw: async (query: any) => {
|
||||||
|
if (query.sql.startsWith('SELECT id FROM equipment')) return []
|
||||||
|
if (query.sql.includes('FROM borrow_record')) return []
|
||||||
|
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...anomalousRoot }]
|
||||||
|
if (query.sql.includes('WHERE equipment.parent_id IN')) {
|
||||||
|
return childrenForParents(query.values as number[]).map((item) => ({ ...item }))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
findUnique: async () => ({ ...anomalousRoot }),
|
||||||
|
findMany: async (args: any) => childrenForParents(args.where.parent_id.in).map((item) => ({ ...item })),
|
||||||
|
updateMany: async (args: any) => {
|
||||||
|
updates.push(args)
|
||||||
|
return { count: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borrowRecord: {
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
borrowRecordData = data
|
||||||
|
return { id: 801, ...data }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maintenanceRecord: { findFirst: async () => null },
|
||||||
|
disposalRecord: { findFirst: async () => null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await registerBorrowAnomalyAssetTree(client as any, {
|
||||||
|
rootEquipmentId: anomalousRoot.id,
|
||||||
|
person: borrowPerson,
|
||||||
|
borrowTime: now,
|
||||||
|
notes: '补登记历史借出'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.childrenCount, 2)
|
||||||
|
assert.equal(updates.length, 3)
|
||||||
|
assert.deepEqual(updates.map((item) => item.data.department_id), [9, 9, 9])
|
||||||
|
assert.deepEqual(updates.map((item) => item.data.remaining_quantity), [0, 0, 0])
|
||||||
|
const snapshot = JSON.parse(borrowRecordData.children_snapshot)
|
||||||
|
assert.deepEqual(snapshot.map((item: any) => ({ id: item.id, parent_id: item.parent_id, depth: item.depth })), [
|
||||||
|
{ id: 21, parent_id: 20, depth: 1 },
|
||||||
|
{ id: 22, parent_id: 21, depth: 2 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('异常借用补登记初读后并发挂载新配件时中止且不写入', async () => {
|
||||||
|
const anomalousRoot = {
|
||||||
|
...borrowRoot,
|
||||||
|
status: '已借出',
|
||||||
|
remaining_quantity: 0
|
||||||
|
}
|
||||||
|
const newChild = {
|
||||||
|
...childEquipment,
|
||||||
|
equipment_model: '16G',
|
||||||
|
equipment_type: '内存条',
|
||||||
|
status: '已借出',
|
||||||
|
remaining_quantity: 0
|
||||||
|
}
|
||||||
|
let mountedAfterInitialRead = false
|
||||||
|
let equipmentWriteCount = 0
|
||||||
|
let borrowCreateCount = 0
|
||||||
|
const client = {
|
||||||
|
$queryRaw: async (query: any) => {
|
||||||
|
if (query.sql.startsWith('SELECT id FROM equipment')) {
|
||||||
|
mountedAfterInitialRead = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...anomalousRoot }]
|
||||||
|
if (query.sql.includes('WHERE equipment.parent_id IN')) {
|
||||||
|
return mountedAfterInitialRead && query.values.includes(anomalousRoot.id)
|
||||||
|
? [{ ...newChild }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
if (query.sql.includes('FROM borrow_record')) return []
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
findUnique: async () => ({ ...anomalousRoot }),
|
||||||
|
findMany: async () => [],
|
||||||
|
updateMany: async () => {
|
||||||
|
equipmentWriteCount += 1
|
||||||
|
return { count: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borrowRecord: {
|
||||||
|
create: async () => {
|
||||||
|
borrowCreateCount += 1
|
||||||
|
return { id: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
registerBorrowAnomalyAssetTree(client as any, {
|
||||||
|
rootEquipmentId: anomalousRoot.id,
|
||||||
|
person: borrowPerson,
|
||||||
|
borrowTime: now
|
||||||
|
}),
|
||||||
|
/资产树状态或挂载关系已变化/
|
||||||
|
)
|
||||||
|
assert.equal(equipmentWriteCount, 0)
|
||||||
|
assert.equal(borrowCreateCount, 0)
|
||||||
|
})
|
||||||
|
|||||||
@ -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
|
id: number
|
||||||
equipment_code: string
|
equipment_code: string
|
||||||
equipment_name: string
|
equipment_name: string
|
||||||
|
equipment_model: string | null
|
||||||
|
equipment_type: string
|
||||||
parent_id: number | null
|
parent_id: number | null
|
||||||
department_id: number | null
|
department_id: number | null
|
||||||
storage_location_id: number | null
|
storage_location_id: number | null
|
||||||
@ -22,6 +24,8 @@ const treeSelect = {
|
|||||||
id: true,
|
id: true,
|
||||||
equipment_code: true,
|
equipment_code: true,
|
||||||
equipment_name: true,
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
parent_id: true,
|
parent_id: true,
|
||||||
department_id: true,
|
department_id: true,
|
||||||
storage_location_id: true,
|
storage_location_id: true,
|
||||||
@ -64,7 +68,9 @@ const getAssetTreeFingerprint = (items: AssetTreeItem[]) => JSON.stringify(
|
|||||||
department_id: item.department_id,
|
department_id: item.department_id,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
mount_status: item.mount_status,
|
mount_status: item.mount_status,
|
||||||
|
total_quantity: item.total_quantity,
|
||||||
remaining_quantity: item.remaining_quantity,
|
remaining_quantity: item.remaining_quantity,
|
||||||
|
unit_price: item.unit_price.toString(),
|
||||||
updated_at: item.updated_at.getTime()
|
updated_at: item.updated_at.getTime()
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => left.id - right.id)
|
.sort((left, right) => left.id - right.id)
|
||||||
@ -82,6 +88,8 @@ const normalizeRawAssetTreeItem = (item: Record<string, unknown>): AssetTreeItem
|
|||||||
id: Number(item.id),
|
id: Number(item.id),
|
||||||
equipment_code: String(item.equipment_code || ''),
|
equipment_code: String(item.equipment_code || ''),
|
||||||
equipment_name: String(item.equipment_name || ''),
|
equipment_name: String(item.equipment_name || ''),
|
||||||
|
equipment_model: item.equipment_model === null ? null : String(item.equipment_model || ''),
|
||||||
|
equipment_type: String(item.equipment_type || ''),
|
||||||
parent_id: item.parent_id === null ? null : Number(item.parent_id),
|
parent_id: item.parent_id === null ? null : Number(item.parent_id),
|
||||||
department_id: item.department_id === null ? null : Number(item.department_id),
|
department_id: item.department_id === null ? null : Number(item.department_id),
|
||||||
storage_location_id: item.storage_location_id === null
|
storage_location_id: item.storage_location_id === null
|
||||||
@ -150,6 +158,8 @@ const loadAssetTreeForUpdate = async (
|
|||||||
equipment.id,
|
equipment.id,
|
||||||
equipment.equipment_code,
|
equipment.equipment_code,
|
||||||
equipment.equipment_name,
|
equipment.equipment_name,
|
||||||
|
equipment.equipment_model,
|
||||||
|
equipment.equipment_type,
|
||||||
equipment.parent_id,
|
equipment.parent_id,
|
||||||
equipment.department_id,
|
equipment.department_id,
|
||||||
equipment.storage_location_id,
|
equipment.storage_location_id,
|
||||||
@ -181,6 +191,8 @@ const loadAssetTreeForUpdate = async (
|
|||||||
equipment.id,
|
equipment.id,
|
||||||
equipment.equipment_code,
|
equipment.equipment_code,
|
||||||
equipment.equipment_name,
|
equipment.equipment_name,
|
||||||
|
equipment.equipment_model,
|
||||||
|
equipment.equipment_type,
|
||||||
equipment.parent_id,
|
equipment.parent_id,
|
||||||
equipment.department_id,
|
equipment.department_id,
|
||||||
equipment.storage_location_id,
|
equipment.storage_location_id,
|
||||||
@ -225,6 +237,378 @@ export const loadAndLockAssetTree = async (
|
|||||||
return afterLock
|
return afterLock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BorrowAssetSnapshotItem = {
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model: string | null
|
||||||
|
equipment_type: string
|
||||||
|
unit_price: number
|
||||||
|
parent_id: number | null
|
||||||
|
parent_equipment_id: number | null
|
||||||
|
depth: number
|
||||||
|
status: string
|
||||||
|
department_id: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildRecursiveBorrowSnapshot = (
|
||||||
|
items: AssetTreeItem[]
|
||||||
|
): BorrowAssetSnapshotItem[] => {
|
||||||
|
if (items.length <= 1) return []
|
||||||
|
|
||||||
|
const depthById = new Map<number, number>([[items[0].id, 0]])
|
||||||
|
return items.slice(1).map((item) => {
|
||||||
|
const parentDepth = item.parent_id === null ? -1 : depthById.get(item.parent_id)
|
||||||
|
if (parentDepth === undefined || parentDepth < 0) {
|
||||||
|
throw new AssetTreeConflictError(`资产 ${item.equipment_code} 的父子关系不完整,请先检查资产关系`)
|
||||||
|
}
|
||||||
|
const depth = parentDepth + 1
|
||||||
|
depthById.set(item.id, depth)
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
equipment_code: item.equipment_code,
|
||||||
|
equipment_name: item.equipment_name,
|
||||||
|
equipment_model: item.equipment_model,
|
||||||
|
equipment_type: item.equipment_type,
|
||||||
|
unit_price: Number(item.unit_price),
|
||||||
|
parent_id: item.parent_id,
|
||||||
|
parent_equipment_id: item.parent_id,
|
||||||
|
depth,
|
||||||
|
status: item.status,
|
||||||
|
department_id: item.department_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const borrowAssetTree = async (
|
||||||
|
client: TransactionClient,
|
||||||
|
input: {
|
||||||
|
rootEquipmentId: number
|
||||||
|
person: {
|
||||||
|
id: number
|
||||||
|
real_name: string
|
||||||
|
nickname: string
|
||||||
|
job_number?: string | null
|
||||||
|
main_department_id?: number | null
|
||||||
|
}
|
||||||
|
expectedReturnTime?: Date | null
|
||||||
|
isLongTerm?: boolean
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const items = await loadAndLockAssetTree(client, input.rootEquipmentId)
|
||||||
|
const root = items[0]
|
||||||
|
if (root.parent_id !== null) {
|
||||||
|
throw new AssetTreeConflictError('该子资产当前已挂载,请先卸载后再借用')
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = items.find((item) => item.status !== '可用')
|
||||||
|
if (blocked) {
|
||||||
|
throw new AssetTreeConflictError(`资产 ${blocked.equipment_code} 当前为${blocked.status},不能借用`)
|
||||||
|
}
|
||||||
|
if (root.total_quantity <= 0 || root.remaining_quantity <= 0 ||
|
||||||
|
root.remaining_quantity > root.total_quantity) {
|
||||||
|
throw new AssetTreeConflictError('主资产库存与状态不一致,请先核查')
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidChildInventory = items.slice(1).find((item) =>
|
||||||
|
item.total_quantity <= 0 || item.remaining_quantity !== item.total_quantity
|
||||||
|
)
|
||||||
|
if (invalidChildInventory) {
|
||||||
|
throw new AssetTreeConflictError(
|
||||||
|
`配件 ${invalidChildInventory.equipment_code} 库存与挂载状态不一致,请先核查`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inconsistentDepartment = items.slice(1).find(
|
||||||
|
(item) => item.department_id !== root.department_id
|
||||||
|
)
|
||||||
|
if (inconsistentDepartment) {
|
||||||
|
throw new AssetTreeConflictError(
|
||||||
|
`配件 ${inconsistentDepartment.equipment_code} 与主资产部门不一致,请先核查`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipmentIds = items.map((item) => item.id)
|
||||||
|
const activeBorrowRows = await client.$queryRaw<Array<{ id: number; equipment_id: number }>>(Prisma.sql`
|
||||||
|
SELECT id, equipment_id
|
||||||
|
FROM borrow_record
|
||||||
|
WHERE equipment_id IN (${Prisma.join(equipmentIds)}) AND status = ${'已借出'}
|
||||||
|
ORDER BY id
|
||||||
|
FOR UPDATE
|
||||||
|
`)
|
||||||
|
const rootActiveBorrowCount = activeBorrowRows.filter(
|
||||||
|
(row) => Number(row.equipment_id) === root.id
|
||||||
|
).length
|
||||||
|
const expectedRootBorrowCount = root.total_quantity - root.remaining_quantity
|
||||||
|
if (rootActiveBorrowCount !== expectedRootBorrowCount) {
|
||||||
|
throw new AssetTreeConflictError('主资产库存与未归还借用记录不一致,请先核查')
|
||||||
|
}
|
||||||
|
const occupiedChild = activeBorrowRows.find((row) => Number(row.equipment_id) !== root.id)
|
||||||
|
if (occupiedChild) {
|
||||||
|
throw new AssetTreeConflictError('资产树中的配件存在未归还借用记录,请先核查')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [activeMaintenance, activeDisposal] = await Promise.all([
|
||||||
|
client.maintenanceRecord.findFirst({
|
||||||
|
where: { equipment_id: { in: equipmentIds }, status: 'in_progress' },
|
||||||
|
select: { id: true }
|
||||||
|
}),
|
||||||
|
client.disposalRecord.findFirst({
|
||||||
|
where: {
|
||||||
|
status: { in: ['pending', 'approved'] },
|
||||||
|
OR: [
|
||||||
|
{ equipment_id: { in: equipmentIds } },
|
||||||
|
{ items: { some: { equipment_id: { in: equipmentIds } } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
})
|
||||||
|
])
|
||||||
|
if (activeMaintenance) {
|
||||||
|
throw new AssetTreeConflictError('资产树存在进行中的维修记录,不能借用')
|
||||||
|
}
|
||||||
|
if (activeDisposal) {
|
||||||
|
throw new AssetTreeConflictError('资产树存在进行中的报废申请,不能借用')
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDepartmentId = input.person.main_department_id ?? root.department_id
|
||||||
|
const nextRemainingQuantity = root.remaining_quantity - 1
|
||||||
|
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
|
||||||
|
const rootValues = calculateEquipmentValues({
|
||||||
|
unitPrice: Number(root.unit_price),
|
||||||
|
totalQuantity: root.total_quantity,
|
||||||
|
remainingQuantity: nextRemainingQuantity,
|
||||||
|
status: nextStatus
|
||||||
|
})
|
||||||
|
const updatedRoot = await client.equipment.updateMany({
|
||||||
|
where: {
|
||||||
|
id: root.id,
|
||||||
|
parent_id: root.parent_id,
|
||||||
|
department_id: root.department_id,
|
||||||
|
status: root.status,
|
||||||
|
mount_status: root.mount_status,
|
||||||
|
total_quantity: root.total_quantity,
|
||||||
|
remaining_quantity: root.remaining_quantity,
|
||||||
|
unit_price: root.unit_price,
|
||||||
|
updated_at: root.updated_at
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
remaining_quantity: nextRemainingQuantity,
|
||||||
|
total_value: rootValues.total_value,
|
||||||
|
borrowed_value: rootValues.borrowed_value,
|
||||||
|
available_value: rootValues.available_value,
|
||||||
|
status: nextStatus,
|
||||||
|
department_id: targetDepartmentId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (updatedRoot.count !== 1) {
|
||||||
|
throw new AssetTreeConflictError('主资产库存、归属或挂载状态已变化,请刷新后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of items.slice(1)) {
|
||||||
|
const childValues = calculateEquipmentValues({
|
||||||
|
unitPrice: Number(child.unit_price),
|
||||||
|
totalQuantity: child.total_quantity,
|
||||||
|
remainingQuantity: 0,
|
||||||
|
status: '已借出'
|
||||||
|
})
|
||||||
|
const updatedChild = await client.equipment.updateMany({
|
||||||
|
where: {
|
||||||
|
id: child.id,
|
||||||
|
parent_id: child.parent_id,
|
||||||
|
department_id: child.department_id,
|
||||||
|
status: child.status,
|
||||||
|
mount_status: child.mount_status,
|
||||||
|
total_quantity: child.total_quantity,
|
||||||
|
remaining_quantity: child.remaining_quantity,
|
||||||
|
unit_price: child.unit_price,
|
||||||
|
updated_at: child.updated_at
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: '已借出',
|
||||||
|
remaining_quantity: 0,
|
||||||
|
total_value: childValues.total_value,
|
||||||
|
borrowed_value: childValues.borrowed_value,
|
||||||
|
available_value: childValues.available_value,
|
||||||
|
department_id: targetDepartmentId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (updatedChild.count !== 1) {
|
||||||
|
throw new AssetTreeConflictError(
|
||||||
|
`配件 ${child.equipment_code} 的状态、归属或挂载关系已变化,请刷新后重试`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotItems = buildRecursiveBorrowSnapshot(items)
|
||||||
|
const record = await client.borrowRecord.create({
|
||||||
|
data: {
|
||||||
|
equipment_id: root.id,
|
||||||
|
equipment_code: root.equipment_code,
|
||||||
|
person_id: input.person.id,
|
||||||
|
person_name: input.person.real_name,
|
||||||
|
person_nickname: input.person.nickname,
|
||||||
|
person_identifier: input.person.job_number || null,
|
||||||
|
expected_return_time: input.expectedReturnTime ?? null,
|
||||||
|
is_long_term: Boolean(input.isLongTerm),
|
||||||
|
notes: input.notes?.trim() || null,
|
||||||
|
status: '已借出',
|
||||||
|
children_snapshot: snapshotItems.length > 0 ? JSON.stringify(snapshotItems) : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
items,
|
||||||
|
snapshotItems,
|
||||||
|
childrenCount: snapshotItems.length,
|
||||||
|
updatedRoot: {
|
||||||
|
...root,
|
||||||
|
department_id: targetDepartmentId,
|
||||||
|
status: nextStatus,
|
||||||
|
remaining_quantity: nextRemainingQuantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const registerBorrowAnomalyAssetTree = async (
|
||||||
|
client: TransactionClient,
|
||||||
|
input: {
|
||||||
|
rootEquipmentId: number
|
||||||
|
person: {
|
||||||
|
id: number
|
||||||
|
real_name: string
|
||||||
|
nickname: string
|
||||||
|
job_number?: string | null
|
||||||
|
main_department_id?: number | null
|
||||||
|
}
|
||||||
|
borrowTime: Date
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const items = await loadAndLockAssetTree(client, input.rootEquipmentId)
|
||||||
|
const root = items[0]
|
||||||
|
if (root.parent_id !== null) {
|
||||||
|
throw new AssetTreeConflictError('该资产当前作为已挂载子资产存在,请先处理所属父资产')
|
||||||
|
}
|
||||||
|
if (root.status !== '已借出' || root.total_quantity !== 1 || root.remaining_quantity !== 0) {
|
||||||
|
throw new AssetTreeConflictError('该资产当前不存在可补登记的借用单缺失问题')
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedChild = items.slice(1).find((item) =>
|
||||||
|
!['可用', '已借出'].includes(item.status) ||
|
||||||
|
item.total_quantity <= 0 ||
|
||||||
|
(item.status === '可用' && item.remaining_quantity !== item.total_quantity) ||
|
||||||
|
(item.status === '已借出' && item.remaining_quantity !== 0)
|
||||||
|
)
|
||||||
|
if (blockedChild) {
|
||||||
|
throw new AssetTreeConflictError(
|
||||||
|
`配件 ${blockedChild.equipment_code} 的状态或库存与主资产借用状态不一致,请先核查`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipmentIds = items.map((item) => item.id)
|
||||||
|
const activeBorrowRows = await client.$queryRaw<Array<{ id: number; equipment_id: number }>>(Prisma.sql`
|
||||||
|
SELECT id, equipment_id
|
||||||
|
FROM borrow_record
|
||||||
|
WHERE equipment_id IN (${Prisma.join(equipmentIds)}) AND status = ${'已借出'}
|
||||||
|
ORDER BY id
|
||||||
|
FOR UPDATE
|
||||||
|
`)
|
||||||
|
if (activeBorrowRows.length > 0) {
|
||||||
|
throw new AssetTreeConflictError('资产树中已存在未归还借用记录,不能重复补登记')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [activeMaintenance, activeDisposal] = await Promise.all([
|
||||||
|
client.maintenanceRecord.findFirst({
|
||||||
|
where: { equipment_id: { in: equipmentIds }, status: 'in_progress' },
|
||||||
|
select: { id: true }
|
||||||
|
}),
|
||||||
|
client.disposalRecord.findFirst({
|
||||||
|
where: {
|
||||||
|
status: { in: ['pending', 'approved'] },
|
||||||
|
OR: [
|
||||||
|
{ equipment_id: { in: equipmentIds } },
|
||||||
|
{ items: { some: { equipment_id: { in: equipmentIds } } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
})
|
||||||
|
])
|
||||||
|
if (activeMaintenance) {
|
||||||
|
throw new AssetTreeConflictError('资产树存在进行中的维修记录,不能补登记借用')
|
||||||
|
}
|
||||||
|
if (activeDisposal) {
|
||||||
|
throw new AssetTreeConflictError('资产树存在进行中的报废申请,不能补登记借用')
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDepartmentId = input.person.main_department_id ?? root.department_id
|
||||||
|
for (const item of items) {
|
||||||
|
const values = calculateEquipmentValues({
|
||||||
|
unitPrice: Number(item.unit_price),
|
||||||
|
totalQuantity: item.total_quantity,
|
||||||
|
remainingQuantity: 0,
|
||||||
|
status: '已借出'
|
||||||
|
})
|
||||||
|
const updated = await client.equipment.updateMany({
|
||||||
|
where: {
|
||||||
|
id: item.id,
|
||||||
|
parent_id: item.parent_id,
|
||||||
|
department_id: item.department_id,
|
||||||
|
status: item.status,
|
||||||
|
mount_status: item.mount_status,
|
||||||
|
total_quantity: item.total_quantity,
|
||||||
|
remaining_quantity: item.remaining_quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
updated_at: item.updated_at
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: '已借出',
|
||||||
|
remaining_quantity: 0,
|
||||||
|
total_value: values.total_value,
|
||||||
|
borrowed_value: values.borrowed_value,
|
||||||
|
available_value: values.available_value,
|
||||||
|
department_id: targetDepartmentId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (updated.count !== 1) {
|
||||||
|
throw new AssetTreeConflictError(
|
||||||
|
`资产 ${item.equipment_code} 的状态、归属或挂载关系已变化,请刷新后重试`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotItems = buildRecursiveBorrowSnapshot(items)
|
||||||
|
const record = await client.borrowRecord.create({
|
||||||
|
data: {
|
||||||
|
equipment_id: root.id,
|
||||||
|
equipment_code: root.equipment_code,
|
||||||
|
person_id: input.person.id,
|
||||||
|
person_name: input.person.real_name,
|
||||||
|
person_nickname: input.person.nickname,
|
||||||
|
person_identifier: input.person.job_number || null,
|
||||||
|
borrow_time: input.borrowTime,
|
||||||
|
notes: input.notes?.trim() || null,
|
||||||
|
status: '已借出',
|
||||||
|
children_snapshot: snapshotItems.length > 0 ? JSON.stringify(snapshotItems) : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
items,
|
||||||
|
snapshotItems,
|
||||||
|
childrenCount: snapshotItems.length,
|
||||||
|
updatedRoot: {
|
||||||
|
...root,
|
||||||
|
department_id: targetDepartmentId,
|
||||||
|
status: '已借出',
|
||||||
|
remaining_quantity: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getDepartmentName = async (client: TransactionClient, departmentId: number | null) => {
|
const getDepartmentName = async (client: TransactionClient, departmentId: number | null) => {
|
||||||
if (!departmentId) return null
|
if (!departmentId) return null
|
||||||
const department = await client.department.findUnique({
|
const department = await client.department.findUnique({
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from '../utils/inventoryScope'
|
} from '../utils/inventoryScope'
|
||||||
import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft'
|
import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft'
|
||||||
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
|
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
|
||||||
|
import { PUBLIC_ASSET_PERSON_SOURCE_USERID } from '../utils/systemReservedPerson'
|
||||||
|
|
||||||
type ScopeEntity = {
|
type ScopeEntity = {
|
||||||
id: number
|
id: number
|
||||||
@ -27,6 +28,14 @@ type FrozenParticipantDraft = {
|
|||||||
department_name: string | null
|
department_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeInventoryParticipant = <T extends { person_source_userid?: string | null }>(participant: T) => {
|
||||||
|
const { person_source_userid: sourceUserId, ...safeParticipant } = participant
|
||||||
|
return {
|
||||||
|
...safeParticipant,
|
||||||
|
is_public_asset_person: sourceUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type FrozenItemDraft = {
|
type FrozenItemDraft = {
|
||||||
participant_person_id: number | null
|
participant_person_id: number | null
|
||||||
equipment_id: number
|
equipment_id: number
|
||||||
@ -400,7 +409,7 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => {
|
|||||||
unassigned_count: unassignedCount,
|
unassigned_count: unassignedCount,
|
||||||
department_breakdown: buildDepartmentBreakdown(scope.items),
|
department_breakdown: buildDepartmentBreakdown(scope.items),
|
||||||
asset_type_breakdown: buildAssetTypeBreakdown(scope.items),
|
asset_type_breakdown: buildAssetTypeBreakdown(scope.items),
|
||||||
participants_sample: scope.participants.slice(0, 10),
|
participants_sample: scope.participants.slice(0, 10).map(sanitizeInventoryParticipant),
|
||||||
items_sample: scope.items.slice(0, 10)
|
items_sample: scope.items.slice(0, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -787,7 +796,7 @@ export const getInventoryTaskDetail = async (taskId: number) => {
|
|||||||
return {
|
return {
|
||||||
...serializeTaskListItem(task),
|
...serializeTaskListItem(task),
|
||||||
...serializedScope,
|
...serializedScope,
|
||||||
participants: task.participants,
|
participants: task.participants.map(sanitizeInventoryParticipant),
|
||||||
items: task.items,
|
items: task.items,
|
||||||
recent_actions: task.actions,
|
recent_actions: task.actions,
|
||||||
department_breakdown: departmentBreakdown,
|
department_breakdown: departmentBreakdown,
|
||||||
|
|||||||
@ -1080,6 +1080,34 @@ export const listPrintTerminals = async () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getDefaultPrintTerminalReadiness = async () => {
|
||||||
|
await markStalePrintTerminalsOffline()
|
||||||
|
|
||||||
|
const terminals = await prisma.printTerminal.findMany({
|
||||||
|
where: { is_default: true },
|
||||||
|
orderBy: DEFAULT_TERMINAL_ORDER_BY
|
||||||
|
})
|
||||||
|
const preferred = resolvePreferredDefaultTerminal(terminals)
|
||||||
|
if (!preferred) return null
|
||||||
|
|
||||||
|
const mapped = mapTerminalRecord({
|
||||||
|
...preferred,
|
||||||
|
recentFailedJob: null
|
||||||
|
}, {
|
||||||
|
preferredDefaultTerminalId: preferred.id
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: mapped.id,
|
||||||
|
name: mapped.name,
|
||||||
|
status: mapped.status,
|
||||||
|
is_default: true,
|
||||||
|
last_seen_at: mapped.last_seen_at,
|
||||||
|
selected_printer_name: mapped.selected_printer_name,
|
||||||
|
readiness: mapped.readiness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const setDefaultPrintTerminal = async (terminalId: number) => {
|
export const setDefaultPrintTerminal = async (terminalId: number) => {
|
||||||
const existing = await prisma.printTerminal.findUnique({
|
const existing = await prisma.printTerminal.findUnique({
|
||||||
where: { id: terminalId }
|
where: { id: terminalId }
|
||||||
@ -1346,7 +1374,11 @@ export const listPrintJobs = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cancelQueuedPrintJob = async (jobId: number) => {
|
export const cancelQueuedPrintJob = async (
|
||||||
|
jobId: number,
|
||||||
|
requestedBy: number,
|
||||||
|
allowAnyRequester: boolean = false
|
||||||
|
) => {
|
||||||
const existing = await prisma.printJob.findUnique({
|
const existing = await prisma.printJob.findUnique({
|
||||||
where: { id: jobId }
|
where: { id: jobId }
|
||||||
})
|
})
|
||||||
@ -1359,12 +1391,23 @@ export const cancelQueuedPrintJob = async (jobId: number) => {
|
|||||||
throw new PrintServiceError('只有排队中的任务可以取消', 422)
|
throw new PrintServiceError('只有排队中的任务可以取消', 422)
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.printJob.update({
|
if (!allowAnyRequester && existing.requested_by !== requestedBy) {
|
||||||
where: { id: jobId },
|
throw new PrintServiceError('只能取消本人提交的排队任务', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelled = await prisma.printJob.updateMany({
|
||||||
|
where: {
|
||||||
|
id: jobId,
|
||||||
|
status: PRINT_JOB_STATUS.queued,
|
||||||
|
...(!allowAnyRequester ? { requested_by: requestedBy } : {})
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
status: PRINT_JOB_STATUS.cancelled
|
status: PRINT_JOB_STATUS.cancelled
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (cancelled.count !== 1) {
|
||||||
|
throw new PrintServiceError('打印任务状态已变化,请刷新后重试', 409)
|
||||||
|
}
|
||||||
|
|
||||||
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json))
|
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json))
|
||||||
}
|
}
|
||||||
|
|||||||
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_APPLY))
|
||||||
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
|
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
|
||||||
assert.ok(permissions.includes(PERMISSIONS.ASSETS_VIEW_OWN))
|
assert.ok(permissions.includes(PERMISSIONS.ASSETS_VIEW_OWN))
|
||||||
|
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
|
||||||
})
|
})
|
||||||
test('行政可以登记和审批但不能借还、调拨或挂载', () => {
|
test('行政可以登记和审批但不能借还、调拨或挂载', () => {
|
||||||
const permissions = getPermissionsForRole('administrative')
|
const permissions = getPermissionsForRole('administrative')
|
||||||
assert.ok(permissions.includes(PERMISSIONS.ASSETS_CREATE))
|
assert.ok(permissions.includes(PERMISSIONS.ASSETS_CREATE))
|
||||||
assert.ok(permissions.includes(PERMISSIONS.ASSETS_CORRECT))
|
assert.ok(permissions.includes(PERMISSIONS.ASSETS_CORRECT))
|
||||||
|
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
|
||||||
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
|
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
|
||||||
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_BORROW))
|
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_BORROW))
|
||||||
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
|
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
|
||||||
@ -23,6 +25,7 @@ test('IT可以流转和发起报废但不能审批或管理用户', () => {
|
|||||||
const permissions = getPermissionsForRole('it')
|
const permissions = getPermissionsForRole('it')
|
||||||
assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW))
|
assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW))
|
||||||
assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
|
assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
|
||||||
|
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
|
||||||
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY))
|
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY))
|
||||||
assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
|
assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
|
||||||
assert.ok(!permissions.includes(PERMISSIONS.USERS_MANAGE))
|
assert.ok(!permissions.includes(PERMISSIONS.USERS_MANAGE))
|
||||||
|
|||||||
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 session from 'express-session'
|
||||||
import Keycloak from 'keycloak-connect'
|
import Keycloak from 'keycloak-connect'
|
||||||
|
import { assertKeycloakConfiguration } from './authMode'
|
||||||
|
|
||||||
type KeycloakEnvConfig = {
|
type KeycloakEnvConfig = {
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
@ -8,19 +9,13 @@ type KeycloakEnvConfig = {
|
|||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredEnv = ['KEYCLOAK_URL', 'KEYCLOAK_REALM', 'KEYCLOAK_CLIENT_ID'] as const
|
const validatedKeycloakConfig = assertKeycloakConfiguration()
|
||||||
|
|
||||||
requiredEnv.forEach((key) => {
|
|
||||||
if (!process.env[key]) {
|
|
||||||
console.warn(`[Keycloak] 环境变量 ${key} 未设置,Keycloak 认证可能无法正常工作。`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const keycloakConfig: KeycloakEnvConfig = {
|
const keycloakConfig: KeycloakEnvConfig = {
|
||||||
serverUrl: process.env.KEYCLOAK_URL || 'http://localhost:8080',
|
serverUrl: validatedKeycloakConfig?.serverUrl || 'http://localhost:8080',
|
||||||
realm: process.env.KEYCLOAK_REALM || 'equipment-management',
|
realm: validatedKeycloakConfig?.realm || 'equipment-management',
|
||||||
clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client',
|
clientId: validatedKeycloakConfig?.clientId || 'equipment-client',
|
||||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET
|
clientSecret: validatedKeycloakConfig?.clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoryStore = new session.MemoryStore()
|
const memoryStore = new session.MemoryStore()
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export const PERMISSIONS = {
|
|||||||
ASSETS_TRANSFER: 'assets.transfer',
|
ASSETS_TRANSFER: 'assets.transfer',
|
||||||
ASSETS_MAINTENANCE: 'assets.maintenance',
|
ASSETS_MAINTENANCE: 'assets.maintenance',
|
||||||
ASSETS_MOUNT: 'assets.mount',
|
ASSETS_MOUNT: 'assets.mount',
|
||||||
|
ASSETS_PRINT_LABELS: 'assets.print_labels',
|
||||||
DISPOSALS_APPLY: 'disposals.apply',
|
DISPOSALS_APPLY: 'disposals.apply',
|
||||||
DISPOSALS_APPROVE: 'disposals.approve',
|
DISPOSALS_APPROVE: 'disposals.approve',
|
||||||
DISPOSALS_COMPLETE: 'disposals.complete',
|
DISPOSALS_COMPLETE: 'disposals.complete',
|
||||||
@ -35,6 +36,7 @@ const ROLE_PERMISSIONS: Record<UserRole, readonly Permission[]> = {
|
|||||||
PERMISSIONS.ASSETS_CREATE,
|
PERMISSIONS.ASSETS_CREATE,
|
||||||
PERMISSIONS.ASSETS_IMPORT,
|
PERMISSIONS.ASSETS_IMPORT,
|
||||||
PERMISSIONS.ASSETS_CORRECT,
|
PERMISSIONS.ASSETS_CORRECT,
|
||||||
|
PERMISSIONS.ASSETS_PRINT_LABELS,
|
||||||
PERMISSIONS.DISPOSALS_APPROVE,
|
PERMISSIONS.DISPOSALS_APPROVE,
|
||||||
PERMISSIONS.DISPOSALS_COMPLETE,
|
PERMISSIONS.DISPOSALS_COMPLETE,
|
||||||
PERMISSIONS.INVENTORY_MANAGE,
|
PERMISSIONS.INVENTORY_MANAGE,
|
||||||
@ -47,6 +49,7 @@ const ROLE_PERMISSIONS: Record<UserRole, readonly Permission[]> = {
|
|||||||
PERMISSIONS.ASSETS_TRANSFER,
|
PERMISSIONS.ASSETS_TRANSFER,
|
||||||
PERMISSIONS.ASSETS_MAINTENANCE,
|
PERMISSIONS.ASSETS_MAINTENANCE,
|
||||||
PERMISSIONS.ASSETS_MOUNT,
|
PERMISSIONS.ASSETS_MOUNT,
|
||||||
|
PERMISSIONS.ASSETS_PRINT_LABELS,
|
||||||
PERMISSIONS.DISPOSALS_APPLY,
|
PERMISSIONS.DISPOSALS_APPLY,
|
||||||
PERMISSIONS.NOTIFICATIONS_VIEW,
|
PERMISSIONS.NOTIFICATIONS_VIEW,
|
||||||
PERMISSIONS.REPORTS_VIEW
|
PERMISSIONS.REPORTS_VIEW
|
||||||
|
|||||||
234
deploy.sh
234
deploy.sh
@ -8,6 +8,7 @@ COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml"
|
|||||||
PROJECT_NAME="equipment-management"
|
PROJECT_NAME="equipment-management"
|
||||||
BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh"
|
BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh"
|
||||||
DISPOSAL_PRECHECK_SQL="$PROJECT_DIR/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql"
|
DISPOSAL_PRECHECK_SQL="$PROJECT_DIR/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql"
|
||||||
|
DISPOSAL_MIGRATION_NAME="20260714_asset_permissions_disposal_dingtalk"
|
||||||
MYSQL_CONTAINER="equipment-mysql"
|
MYSQL_CONTAINER="equipment-mysql"
|
||||||
SKIP_BACKUP="${SKIP_BACKUP:-0}"
|
SKIP_BACKUP="${SKIP_BACKUP:-0}"
|
||||||
CONFIRM_ACTIVE_DISPOSALS_REVIEWED="${CONFIRM_ACTIVE_DISPOSALS_REVIEWED:-}"
|
CONFIRM_ACTIVE_DISPOSALS_REVIEWED="${CONFIRM_ACTIVE_DISPOSALS_REVIEWED:-}"
|
||||||
@ -15,6 +16,10 @@ HEALTHCHECK_ATTEMPTS="${HEALTHCHECK_ATTEMPTS:-12}"
|
|||||||
HEALTHCHECK_INTERVAL_SECONDS="${HEALTHCHECK_INTERVAL_SECONDS:-5}"
|
HEALTHCHECK_INTERVAL_SECONDS="${HEALTHCHECK_INTERVAL_SECONDS:-5}"
|
||||||
HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}"
|
HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}"
|
||||||
EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql")
|
EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql")
|
||||||
|
DISPOSAL_PRECHECK_REQUIRED=0
|
||||||
|
INITIAL_DISPOSAL_FINGERPRINT=""
|
||||||
|
CURRENT_DISPOSAL_FINGERPRINT=""
|
||||||
|
CURRENT_ACTIVE_DISPOSAL_COUNT=0
|
||||||
|
|
||||||
get_container_status() {
|
get_container_status() {
|
||||||
local container="$1"
|
local container="$1"
|
||||||
@ -77,62 +82,72 @@ run_equipment_mysql() {
|
|||||||
' sh "$@"
|
' sh "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_disposal_migration_precheck() {
|
get_disposal_migration_state() {
|
||||||
local mysql_status tree_anomaly_count active_rows active_count active_fingerprint
|
local migrations_table_exists state
|
||||||
|
|
||||||
|
migrations_table_exists="$(
|
||||||
|
printf '%s\n' \
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '_prisma_migrations';" |
|
||||||
|
run_equipment_mysql --batch --skip-column-names --raw
|
||||||
|
)"
|
||||||
|
migrations_table_exists="$(printf '%s' "$migrations_table_exists" | tr -d '[:space:]')"
|
||||||
|
if [ "$migrations_table_exists" = "0" ]; then
|
||||||
|
printf 'pending\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$migrations_table_exists" != "1" ]; then
|
||||||
|
echo "无法确认 Prisma 迁移表状态。" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
state="$(run_equipment_mysql --batch --skip-column-names --raw <<SQL
|
||||||
|
SELECT CASE
|
||||||
|
WHEN SUM(CASE WHEN finished_at IS NULL AND rolled_back_at IS NULL THEN 1 ELSE 0 END) > 0 THEN 'incomplete'
|
||||||
|
WHEN SUM(CASE WHEN finished_at IS NOT NULL AND rolled_back_at IS NULL THEN 1 ELSE 0 END) > 0 THEN 'applied'
|
||||||
|
ELSE 'pending'
|
||||||
|
END
|
||||||
|
FROM _prisma_migrations
|
||||||
|
WHERE migration_name = '$DISPOSAL_MIGRATION_NAME';
|
||||||
|
SQL
|
||||||
|
)"
|
||||||
|
state="$(printf '%s' "$state" | tr -d '[:space:]')"
|
||||||
|
case "$state" in
|
||||||
|
applied|pending|incomplete) printf '%s\n' "$state" ;;
|
||||||
|
*) echo "报废迁移状态不可解析: $state" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
calculate_active_disposal_fingerprint() {
|
||||||
|
local display_rows="${1:-0}"
|
||||||
|
local snapshot_output missing_root_count tree_anomaly_count review_row_count
|
||||||
|
local actual_review_row_count review_rows active_fingerprint active_count
|
||||||
|
|
||||||
echo ">>> 执行报废流程迁移前只读核查..."
|
|
||||||
if [ ! -r "$DISPOSAL_PRECHECK_SQL" ]; then
|
if [ ! -r "$DISPOSAL_PRECHECK_SQL" ]; then
|
||||||
echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2
|
echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
if ! snapshot_output="$(
|
||||||
mysql_status="$(get_container_status "$MYSQL_CONTAINER")"
|
run_equipment_mysql --batch --skip-column-names --raw < "$DISPOSAL_PRECHECK_SQL"
|
||||||
if [ "$mysql_status" != "running" ]; then
|
|
||||||
echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! run_equipment_mysql < "$DISPOSAL_PRECHECK_SQL"; then
|
|
||||||
echo "报废流程迁移前核查执行失败,已阻止部署。" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! tree_anomaly_count="$(run_equipment_mysql --batch --skip-column-names --raw <<'SQL'
|
|
||||||
WITH RECURSIVE active_disposal_tree AS (
|
|
||||||
SELECT
|
|
||||||
disposal.id AS disposal_id,
|
|
||||||
equipment.id AS equipment_id,
|
|
||||||
equipment.parent_id,
|
|
||||||
0 AS depth,
|
|
||||||
CAST(CONCAT('/', equipment.id, '/') AS CHAR(4096)) AS visited_path,
|
|
||||||
0 AS cycle_detected
|
|
||||||
FROM disposal_record disposal
|
|
||||||
INNER JOIN equipment equipment ON equipment.id = disposal.equipment_id
|
|
||||||
WHERE disposal.status IN ('pending', 'approved')
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
tree.disposal_id,
|
|
||||||
child.id,
|
|
||||||
child.parent_id,
|
|
||||||
tree.depth + 1,
|
|
||||||
CONCAT(tree.visited_path, child.id, '/'),
|
|
||||||
LOCATE(CONCAT('/', child.id, '/'), tree.visited_path) > 0
|
|
||||||
FROM active_disposal_tree tree
|
|
||||||
INNER JOIN equipment child ON child.parent_id = tree.equipment_id
|
|
||||||
WHERE tree.depth < 100
|
|
||||||
AND tree.cycle_detected = 0
|
|
||||||
)
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM active_disposal_tree
|
|
||||||
WHERE cycle_detected = 1 OR depth >= 100;
|
|
||||||
SQL
|
|
||||||
)"; then
|
)"; then
|
||||||
echo "无法确认未完成报废资产树是否存在环或超深层级,已阻止部署。" >&2
|
echo "报废流程迁移前一致性快照核查执行失败,已阻止部署。" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
tree_anomaly_count="$(printf '%s' "$tree_anomaly_count" | tr -d '[:space:]')"
|
|
||||||
|
missing_root_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "MISSING_ROOT_COUNT" { print $2 }')"
|
||||||
|
tree_anomaly_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "TREE_ANOMALY_COUNT" { print $2 }')"
|
||||||
|
active_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "ACTIVE_DISPOSAL_COUNT" { print $2 }')"
|
||||||
|
review_row_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW_COUNT" { print $2 }')"
|
||||||
|
review_rows="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW" { sub(/^[^\t]*\t/, ""); print }')"
|
||||||
|
|
||||||
|
if ! [[ "$missing_root_count" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "缺失根资产数量不可解析,已阻止部署。" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "$missing_root_count" -gt 0 ]; then
|
||||||
|
echo "检测到 $missing_root_count 条未完成报废单缺少根资产,已阻止部署。" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! [[ "$tree_anomaly_count" =~ ^[0-9]+$ ]]; then
|
if ! [[ "$tree_anomaly_count" =~ ^[0-9]+$ ]]; then
|
||||||
echo "资产树异常数量不可解析,已阻止部署。" >&2
|
echo "资产树异常数量不可解析,已阻止部署。" >&2
|
||||||
return 1
|
return 1
|
||||||
@ -143,31 +158,81 @@ SQL
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! active_rows="$(
|
if ! [[ "$active_count" =~ ^[0-9]+$ ]]; then
|
||||||
printf '%s\n' \
|
echo "未完成报废单数量不可解析,已阻止部署。" >&2
|
||||||
"SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;" |
|
return 1
|
||||||
run_equipment_mysql --batch --skip-column-names --raw
|
fi
|
||||||
)"; then
|
if ! [[ "$review_row_count" =~ ^[0-9]+$ ]]; then
|
||||||
echo "无法读取未完成报废单指纹数据,已阻止部署。" >&2
|
echo "人工核对行数量不可解析,已阻止部署。" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
actual_review_row_count="$(
|
||||||
|
printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW" { count += 1 } END { print count + 0 }'
|
||||||
|
)"
|
||||||
|
if [ "$actual_review_row_count" -ne "$review_row_count" ] || \
|
||||||
|
{ [ "$active_count" -gt 0 ] && [ "$review_row_count" -lt "$active_count" ]; }; then
|
||||||
|
echo "一致性快照的统计与人工核对行不一致,已阻止部署。" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
active_count="$(printf '%s\n' "$active_rows" | awk 'NF { count += 1 } END { print count + 0 }')"
|
|
||||||
|
|
||||||
if [ "$active_count" -gt 0 ]; then
|
if [ "$display_rows" = "1" ]; then
|
||||||
if ! command -v sha256sum >/dev/null 2>&1; then
|
echo ">>> 同一一致性快照中的未完成报废单及完整资产树:"
|
||||||
echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2
|
if [ "$review_row_count" -gt 0 ]; then
|
||||||
return 1
|
printf '%s\n' "$review_rows"
|
||||||
|
else
|
||||||
|
echo " (无未完成报废单)"
|
||||||
fi
|
fi
|
||||||
active_fingerprint="$(printf '%s\n' "$active_rows" | sha256sum | awk '{ print $1 }')"
|
fi
|
||||||
if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then
|
|
||||||
echo "未完成报废单指纹不可解析,已阻止部署。" >&2
|
if ! command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# 人工展示与指纹使用同一个 snapshot_output 中的 REVIEW_ROW,避免二次查询产生漂移。
|
||||||
|
active_fingerprint="$(printf '%s\n' "$review_rows" | sha256sum | awk '{ print $1 }')"
|
||||||
|
if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then
|
||||||
|
echo "未完成报废单指纹不可解析,已阻止部署。" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_ACTIVE_DISPOSAL_COUNT="$active_count"
|
||||||
|
CURRENT_DISPOSAL_FINGERPRINT="$active_fingerprint"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_disposal_migration_precheck() {
|
||||||
|
local mysql_status migration_state
|
||||||
|
|
||||||
|
mysql_status="$(get_container_status "$MYSQL_CONTAINER")"
|
||||||
|
if [ "$mysql_status" != "running" ]; then
|
||||||
|
echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
migration_state="$(get_disposal_migration_state)"
|
||||||
|
case "$migration_state" in
|
||||||
|
applied)
|
||||||
|
echo ">>> 报废流程迁移已应用,跳过一次性迁移前核查。"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
incomplete)
|
||||||
|
echo "检测到未完成且未回滚的报废流程迁移记录,已阻止部署;请先恢复 Prisma 迁移状态。" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
;;
|
||||||
echo "检测到 $active_count 条未完成报废单。" >&2
|
pending)
|
||||||
echo "本次未完成报废单指纹: $active_fingerprint" >&2
|
DISPOSAL_PRECHECK_REQUIRED=1
|
||||||
if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]; then
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ">>> 执行报废流程迁移前只读核查..."
|
||||||
|
calculate_active_disposal_fingerprint 1
|
||||||
|
INITIAL_DISPOSAL_FINGERPRINT="$CURRENT_DISPOSAL_FINGERPRINT"
|
||||||
|
|
||||||
|
if [ "$CURRENT_ACTIVE_DISPOSAL_COUNT" -gt 0 ]; then
|
||||||
|
echo "检测到 $CURRENT_ACTIVE_DISPOSAL_COUNT 条未完成报废单。" >&2
|
||||||
|
echo "本次完整资产树指纹: $CURRENT_DISPOSAL_FINGERPRINT" >&2
|
||||||
|
if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$CURRENT_DISPOSAL_FINGERPRINT" ]; then
|
||||||
echo "已默认阻止部署。请业务负责人逐单核对上方资产树。" >&2
|
echo "已默认阻止部署。请业务负责人逐单核对上方资产树。" >&2
|
||||||
echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$active_fingerprint。" >&2
|
echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$CURRENT_DISPOSAL_FINGERPRINT。" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。"
|
echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。"
|
||||||
@ -176,6 +241,39 @@ SQL
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recheck_disposal_migration_precheck() {
|
||||||
|
local migration_state
|
||||||
|
if [ "$DISPOSAL_PRECHECK_REQUIRED" != "1" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> 镜像构建后复核报废资产树是否变化..."
|
||||||
|
migration_state="$(get_disposal_migration_state)"
|
||||||
|
case "$migration_state" in
|
||||||
|
applied)
|
||||||
|
echo "报废流程迁移已由其他发布成功应用,允许继续。"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
incomplete)
|
||||||
|
echo "构建期间出现未完成的报废流程迁移记录,已阻止更新容器。" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
calculate_active_disposal_fingerprint
|
||||||
|
if [ "$CURRENT_DISPOSAL_FINGERPRINT" != "$INITIAL_DISPOSAL_FINGERPRINT" ]; then
|
||||||
|
echo "镜像构建期间未完成报废单或完整资产树发生变化,已阻止更新容器。" >&2
|
||||||
|
echo "构建前指纹: $INITIAL_DISPOSAL_FINGERPRINT" >&2
|
||||||
|
echo "当前指纹: $CURRENT_DISPOSAL_FINGERPRINT" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "报废单及完整资产树指纹未变化。"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${DEPLOY_SH_FUNCTIONS_ONLY:-0}" = "1" ]; then
|
||||||
|
return 0 2>/dev/null || exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if docker compose version >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1; then
|
||||||
COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE")
|
COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE")
|
||||||
elif command -v docker-compose >/dev/null 2>&1; then
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
@ -228,6 +326,8 @@ run_disposal_migration_precheck
|
|||||||
echo ">>> 先构建固定资产系统镜像..."
|
echo ">>> 先构建固定资产系统镜像..."
|
||||||
"${COMPOSE_CMD[@]}" build
|
"${COMPOSE_CMD[@]}" build
|
||||||
|
|
||||||
|
recheck_disposal_migration_precheck
|
||||||
|
|
||||||
echo ">>> 镜像构建成功,开始更新固定资产系统容器..."
|
echo ">>> 镜像构建成功,开始更新固定资产系统容器..."
|
||||||
"${COMPOSE_CMD[@]}" up -d --remove-orphans
|
"${COMPOSE_CMD[@]}" up -d --remove-orphans
|
||||||
|
|
||||||
|
|||||||
@ -33,10 +33,15 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4
|
DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret}
|
NODE_ENV: production
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
|
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||||
AUTH_MODE: ${AUTH_MODE:-local}
|
AUTH_MODE: ${AUTH_MODE:-local}
|
||||||
|
KEYCLOAK_URL: ${KEYCLOAK_URL:-}
|
||||||
|
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-}
|
||||||
|
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-}
|
||||||
|
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-}
|
||||||
PORT: ${PORT:-3000}
|
PORT: ${PORT:-3000}
|
||||||
SQLSERVER_HOST: ${SQLSERVER_HOST:-}
|
SQLSERVER_HOST: ${SQLSERVER_HOST:-}
|
||||||
SQLSERVER_PORT: ${SQLSERVER_PORT:-1433}
|
SQLSERVER_PORT: ${SQLSERVER_PORT:-1433}
|
||||||
@ -79,7 +84,10 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
|
||||||
VITE_AUTH_MODE: ${VITE_AUTH_MODE:-local}
|
VITE_AUTH_MODE: ${AUTH_MODE:-local}
|
||||||
|
VITE_KEYCLOAK_URL: ${KEYCLOAK_URL:-}
|
||||||
|
VITE_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-}
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-}
|
||||||
container_name: equipment-frontend
|
container_name: equipment-frontend
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -5,10 +5,16 @@ WORKDIR /app
|
|||||||
# 构建参数
|
# 构建参数
|
||||||
ARG VITE_API_BASE_URL=http://localhost:3000/api
|
ARG VITE_API_BASE_URL=http://localhost:3000/api
|
||||||
ARG VITE_AUTH_MODE=local
|
ARG VITE_AUTH_MODE=local
|
||||||
|
ARG VITE_KEYCLOAK_URL=
|
||||||
|
ARG VITE_KEYCLOAK_REALM=equipment-management
|
||||||
|
ARG VITE_KEYCLOAK_CLIENT_ID=equipment-client
|
||||||
|
|
||||||
# 设置环境变量
|
# 设置环境变量
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
ENV VITE_AUTH_MODE=$VITE_AUTH_MODE
|
ENV VITE_AUTH_MODE=$VITE_AUTH_MODE
|
||||||
|
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL
|
||||||
|
ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM
|
||||||
|
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|||||||
@ -54,7 +54,10 @@ const byRealName = filterManualReturnRecordCandidates(sampleRecords as any, '周
|
|||||||
assert.equal(byRealName[0].id, 102, '姓名搜索应支持')
|
assert.equal(byRealName[0].id, 102, '姓名搜索应支持')
|
||||||
|
|
||||||
const byIdentifier = filterManualReturnRecordCandidates(sampleRecords as any, 'A1001')
|
const byIdentifier = filterManualReturnRecordCandidates(sampleRecords as any, 'A1001')
|
||||||
assert.equal(byIdentifier[0].id, 101, '工号搜索应支持')
|
assert.equal(byIdentifier.length, 0, '历史身份证或旧标识不应作为业务搜索条件')
|
||||||
|
|
||||||
|
const byPhone = filterManualReturnRecordCandidates(sampleRecords as any, '13800000001')
|
||||||
|
assert.equal(byPhone.length, 0, '手机号不应作为业务搜索条件')
|
||||||
|
|
||||||
const byDepartment = filterManualReturnRecordCandidates(sampleRecords as any, '设计')
|
const byDepartment = filterManualReturnRecordCandidates(sampleRecords as any, '设计')
|
||||||
assert.equal(byDepartment[0].id, 101, '部门搜索应支持')
|
assert.equal(byDepartment[0].id, 101, '部门搜索应支持')
|
||||||
|
|||||||
@ -29,7 +29,6 @@ export interface ReturnSearchAssetCandidate {
|
|||||||
storageLocation?: StorageLocation | null
|
storageLocation?: StorageLocation | null
|
||||||
active_borrow_count: number
|
active_borrow_count: number
|
||||||
borrower_names?: string[]
|
borrower_names?: string[]
|
||||||
borrower_identifiers?: string[]
|
|
||||||
borrower_departments?: string[]
|
borrower_departments?: string[]
|
||||||
can_return?: boolean
|
can_return?: boolean
|
||||||
can_handle_borrow_anomaly?: boolean
|
can_handle_borrow_anomaly?: boolean
|
||||||
|
|||||||
@ -1,6 +1,37 @@
|
|||||||
import api from './index'
|
import api from './index'
|
||||||
import type { ApiResponse, PaginatedResponse, Person } from '@/types'
|
import type { ApiResponse, PaginatedResponse, Person } from '@/types'
|
||||||
|
|
||||||
|
export type PersonOption = Pick<
|
||||||
|
Person,
|
||||||
|
| 'id'
|
||||||
|
| 'nickname'
|
||||||
|
| 'real_name'
|
||||||
|
| 'job_number'
|
||||||
|
| 'department'
|
||||||
|
| 'main_department'
|
||||||
|
| 'departments'
|
||||||
|
| 'department_names'
|
||||||
|
| 'position'
|
||||||
|
| 'status'
|
||||||
|
> & {
|
||||||
|
dingtalk_ready: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonOptionListData {
|
||||||
|
list: PersonOption[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonOptionListParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
status?: 'active' | 'inactive'
|
||||||
|
includeInactive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const getPersonList = async (params: {
|
export const getPersonList = async (params: {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
@ -62,10 +93,14 @@ export const deletePerson = async (id: number) => {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPersonOptions = async (params?: { includeInactive?: boolean }) => {
|
export const getPersonOptions = async (params: PersonOptionListParams = {}) => {
|
||||||
const response = await api.get<ApiResponse<Person[]>>('/persons/options', {
|
const response = await api.get<ApiResponse<PersonOptionListData>>('/persons/options', {
|
||||||
params: {
|
params: {
|
||||||
include_inactive: params?.includeInactive ? 'true' : undefined
|
page: params.page,
|
||||||
|
page_size: params.pageSize,
|
||||||
|
keyword: params.keyword,
|
||||||
|
status: params.status,
|
||||||
|
include_inactive: params.includeInactive ? 'true' : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@ -326,6 +326,32 @@ export const getPrintTerminals = async () => {
|
|||||||
return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal)
|
return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getPrintTerminalReadiness = async (): Promise<PrintTerminal | null> => {
|
||||||
|
const response = await api.get<ApiResponse<unknown>>('/print-terminals/readiness')
|
||||||
|
const data = toRecord(response.data.data)
|
||||||
|
const rawTerminal = toRecord(data.default_terminal ?? data.terminal ?? data)
|
||||||
|
const terminalId = toNumber(rawTerminal.id ?? rawTerminal.terminal_id, 0)
|
||||||
|
|
||||||
|
if (!terminalId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const readinessValue = rawTerminal.readiness ?? data.readiness ?? {
|
||||||
|
is_ready: rawTerminal.is_ready ?? data.is_ready,
|
||||||
|
reason: rawTerminal.readiness_reason ?? data.readiness_reason,
|
||||||
|
message: rawTerminal.readiness_message ?? data.readiness_message
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePrintTerminal({
|
||||||
|
...rawTerminal,
|
||||||
|
id: terminalId,
|
||||||
|
name: rawTerminal.name ?? rawTerminal.terminal_name,
|
||||||
|
is_default: true,
|
||||||
|
selected_printer_name: rawTerminal.selected_printer_name ?? rawTerminal.printer_name,
|
||||||
|
readiness: readinessValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const createAssetLabelPrintJob = async (
|
export const createAssetLabelPrintJob = async (
|
||||||
payload: CreateAssetLabelPrintJobPayload
|
payload: CreateAssetLabelPrintJobPayload
|
||||||
): Promise<CreateAssetLabelPrintJobResult> => {
|
): Promise<CreateAssetLabelPrintJobResult> => {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export const PERMISSIONS = {
|
|||||||
ASSETS_TRANSFER: 'assets.transfer',
|
ASSETS_TRANSFER: 'assets.transfer',
|
||||||
ASSETS_MAINTENANCE: 'assets.maintenance',
|
ASSETS_MAINTENANCE: 'assets.maintenance',
|
||||||
ASSETS_MOUNT: 'assets.mount',
|
ASSETS_MOUNT: 'assets.mount',
|
||||||
|
ASSETS_PRINT_LABELS: 'assets.print_labels',
|
||||||
DISPOSALS_APPLY: 'disposals.apply',
|
DISPOSALS_APPLY: 'disposals.apply',
|
||||||
DISPOSALS_APPROVE: 'disposals.approve',
|
DISPOSALS_APPROVE: 'disposals.approve',
|
||||||
DISPOSALS_COMPLETE: 'disposals.complete',
|
DISPOSALS_COMPLETE: 'disposals.complete',
|
||||||
|
|||||||
@ -46,6 +46,29 @@
|
|||||||
<p v-if="terminalError" class="text-sm text-status-warning">{{ terminalError }}</p>
|
<p v-if="terminalError" class="text-sm text-status-warning">{{ terminalError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="latestRemoteJob" class="panel flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="text-sm font-semibold text-text-primary">本次远程打印任务 #{{ latestRemoteJob.id }}</p>
|
||||||
|
<span class="badge" :class="getJobStatusBadgeClass(latestRemoteJob.status)">
|
||||||
|
{{ getJobStatusText(latestRemoteJob.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
{{ latestRemoteJob.target_terminal_name || defaultTerminal?.name || '默认打印电脑' }} · {{ equipmentList.length }} 张标签
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="latestRemoteJob.status === 'queued'"
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:disabled="cancelSubmitting"
|
||||||
|
@click="handleCancelRemoteJob"
|
||||||
|
>
|
||||||
|
{{ cancelSubmitting ? '取消中...' : '取消本次任务' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
v-for="equipment in equipmentList"
|
v-for="equipment in equipmentList"
|
||||||
@ -91,8 +114,8 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { getEquipmentBatchForBarcode } from '@/api/barcode'
|
import { getEquipmentBatchForBarcode } from '@/api/barcode'
|
||||||
import { createAssetLabelPrintJob, getPrintTerminals } from '@/api/print'
|
import { cancelQueuedPrintJob, createAssetLabelPrintJob, getPrintTerminalReadiness } from '@/api/print'
|
||||||
import type { PrintTerminal } from '@/types'
|
import type { PrintJob, PrintTerminal } from '@/types'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
import {
|
import {
|
||||||
buildLocalBatchPrintHtml,
|
buildLocalBatchPrintHtml,
|
||||||
@ -149,8 +172,10 @@ const previewImages = ref<Record<number, string>>({})
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const terminalLoading = ref(false)
|
const terminalLoading = ref(false)
|
||||||
const remoteSubmitting = ref(false)
|
const remoteSubmitting = ref(false)
|
||||||
|
const cancelSubmitting = ref(false)
|
||||||
const defaultTerminal = ref<PrintTerminal | null>(null)
|
const defaultTerminal = ref<PrintTerminal | null>(null)
|
||||||
const terminalError = ref<string | null>(null)
|
const terminalError = ref<string | null>(null)
|
||||||
|
const latestRemoteJob = ref<PrintJob | null>(null)
|
||||||
|
|
||||||
const DISPLAY_FONT = '"Arial Black", "Bahnschrift", "Segoe UI", sans-serif'
|
const DISPLAY_FONT = '"Arial Black", "Bahnschrift", "Segoe UI", sans-serif'
|
||||||
const BODY_FONT = '"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif'
|
const BODY_FONT = '"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif'
|
||||||
@ -183,24 +208,7 @@ const getProfileText = (terminal?: PrintTerminal | null) => {
|
|||||||
return terminal.readiness.message
|
return terminal.readiness.message
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = terminal.label_profile
|
return terminal.readiness?.is_ready ? '60 × 40 已就绪' : '待管理员检查配置'
|
||||||
if (!profile) {
|
|
||||||
return '未完成 60x40 配置'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.message) {
|
|
||||||
return profile.message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.is_ready === true || profile.configured === true) {
|
|
||||||
return profile.paper_size ? `${profile.paper_size} 已就绪` : '标签配置已完成'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.paper_size) {
|
|
||||||
return `${profile.paper_size} 待确认`
|
|
||||||
}
|
|
||||||
|
|
||||||
return '未完成 60x40 配置'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteStatusBadgeClass = computed(() => {
|
const remoteStatusBadgeClass = computed(() => {
|
||||||
@ -225,7 +233,6 @@ const remoteStatusText = computed(() => {
|
|||||||
const remotePrinterName = computed(() => {
|
const remotePrinterName = computed(() => {
|
||||||
return (
|
return (
|
||||||
defaultTerminal.value?.selected_printer_name ||
|
defaultTerminal.value?.selected_printer_name ||
|
||||||
defaultTerminal.value?.printer_snapshot?.[0]?.name ||
|
|
||||||
'未选择打印机'
|
'未选择打印机'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -240,11 +247,11 @@ const remoteStatusDescription = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (terminalError.value) {
|
if (terminalError.value) {
|
||||||
return '暂时无法读取办公室打印状态,建议先刷新系统设置里的打印终端。'
|
return '暂时无法读取办公室打印状态,请联系管理员检查打印终端。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!defaultTerminal.value) {
|
if (!defaultTerminal.value) {
|
||||||
return '当前还没有设置默认打印电脑,请先到系统设置里的“打印终端”完成配对。'
|
return '当前还没有设置默认打印电脑,请联系管理员完成终端配置。'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) {
|
if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) {
|
||||||
@ -268,6 +275,23 @@ const remoteActionDisabled = computed(() => {
|
|||||||
return defaultTerminal.value.status === 'disabled'
|
return defaultTerminal.value.status === 'disabled'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getJobStatusText = (status?: string | null) => {
|
||||||
|
if (status === 'queued') return '排队中'
|
||||||
|
if (status === 'claimed') return '已领取'
|
||||||
|
if (status === 'printing') return '打印中'
|
||||||
|
if (status === 'completed') return '已完成'
|
||||||
|
if (status === 'failed') return '失败'
|
||||||
|
if (status === 'cancelled') return '已取消'
|
||||||
|
return status || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJobStatusBadgeClass = (status?: string | null) => {
|
||||||
|
if (status === 'completed') return 'badge-success'
|
||||||
|
if (status === 'failed') return 'badge-error'
|
||||||
|
if (status === 'queued' || status === 'claimed' || status === 'printing') return 'badge-warning'
|
||||||
|
return 'badge-neutral'
|
||||||
|
}
|
||||||
|
|
||||||
const loadImage = (src: string) =>
|
const loadImage = (src: string) =>
|
||||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
const image = new Image()
|
const image = new Image()
|
||||||
@ -677,8 +701,7 @@ const fetchDefaultTerminal = async () => {
|
|||||||
terminalError.value = null
|
terminalError.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const terminals = await getPrintTerminals()
|
defaultTerminal.value = await getPrintTerminalReadiness()
|
||||||
defaultTerminal.value = terminals.find((item) => item.is_default) || null
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('获取打印终端失败:', error)
|
console.error('获取打印终端失败:', error)
|
||||||
terminalError.value = error?.message || '获取打印终端失败'
|
terminalError.value = error?.message || '获取打印终端失败'
|
||||||
@ -717,7 +740,7 @@ const handleRemotePrint = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!defaultTerminal.value) {
|
if (!defaultTerminal.value) {
|
||||||
alert('当前还没有配置默认打印电脑,请先到系统设置里的“打印终端”完成配对')
|
alert('当前还没有配置默认打印电脑,请联系管理员处理')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -744,6 +767,7 @@ const handleRemotePrint = async () => {
|
|||||||
rendered_labels: renderedLabels,
|
rendered_labels: renderedLabels,
|
||||||
target_terminal_id: targetTerminalId
|
target_terminal_id: targetTerminalId
|
||||||
})
|
})
|
||||||
|
latestRemoteJob.value = result.job
|
||||||
|
|
||||||
const targetTerminalName = result.job.target_terminal_name || defaultTerminal.value.name
|
const targetTerminalName = result.job.target_terminal_name || defaultTerminal.value.name
|
||||||
alert(result.queue_notice || `标签已发送到 ${targetTerminalName}`)
|
alert(result.queue_notice || `标签已发送到 ${targetTerminalName}`)
|
||||||
@ -757,6 +781,23 @@ const handleRemotePrint = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCancelRemoteJob = async () => {
|
||||||
|
const job = latestRemoteJob.value
|
||||||
|
if (!job || job.status !== 'queued') return
|
||||||
|
if (!window.confirm(`确定要取消本次打印任务 #${job.id} 吗?`)) return
|
||||||
|
|
||||||
|
cancelSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await cancelQueuedPrintJob(job.id)
|
||||||
|
latestRemoteJob.value = { ...job, status: 'cancelled' }
|
||||||
|
alert('本次打印任务已取消')
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.message || '取消打印任务失败')
|
||||||
|
} finally {
|
||||||
|
cancelSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handlePrint = async () => {
|
const handlePrint = async () => {
|
||||||
if (equipmentList.value.length === 0) {
|
if (equipmentList.value.length === 0) {
|
||||||
alert('没有可打印的二维码')
|
alert('没有可打印的二维码')
|
||||||
|
|||||||
@ -90,7 +90,7 @@
|
|||||||
v-model.trim="personKeyword"
|
v-model.trim="personKeyword"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
|
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
|
||||||
@keyup.enter="handlePersonKeywordSubmit"
|
@keyup.enter="handlePersonKeywordSubmit"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-text-tertiary">
|
<p class="text-xs text-text-tertiary">
|
||||||
@ -99,7 +99,7 @@
|
|||||||
|
|
||||||
<div v-if="showPersonSearchResults" class="space-y-2">
|
<div v-if="showPersonSearchResults" class="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-3 px-1">
|
<div class="flex items-center justify-between gap-3 px-1">
|
||||||
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p>
|
<p class="text-xs text-text-tertiary">支持花名、姓名、工号和部门搜索。</p>
|
||||||
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
||||||
{{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }}
|
{{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }}
|
||||||
</p>
|
</p>
|
||||||
@ -129,7 +129,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
{{ person.department || '未填写部门' }}
|
{{ person.department || '未填写部门' }}
|
||||||
<span v-if="person.phone"> · {{ person.phone }}</span>
|
|
||||||
<span v-if="person.job_number"> · {{ person.job_number }}</span>
|
<span v-if="person.job_number"> · {{ person.job_number }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -205,13 +204,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { getPersonOptions } from '@/api/person'
|
import { getPersonOptions, type PersonOption } from '@/api/person'
|
||||||
import {
|
import {
|
||||||
registerBorrowAnomaly,
|
registerBorrowAnomaly,
|
||||||
resolveBorrowAnomalyAsReturned
|
resolveBorrowAnomalyAsReturned
|
||||||
} from '@/api/borrow'
|
} from '@/api/borrow'
|
||||||
import type { Person } from '@/types'
|
|
||||||
import {
|
import {
|
||||||
buildManualBorrowPersonDisplay,
|
buildManualBorrowPersonDisplay,
|
||||||
filterManualBorrowPersons,
|
filterManualBorrowPersons,
|
||||||
@ -246,7 +244,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mode = ref<'mark-returned' | 'register-borrow'>('mark-returned')
|
const mode = ref<'mark-returned' | 'register-borrow'>('mark-returned')
|
||||||
const persons = ref<Person[]>([])
|
const persons = ref<PersonOption[]>([])
|
||||||
const personsLoading = ref(false)
|
const personsLoading = ref(false)
|
||||||
const selectedPersonId = ref<number | null>(null)
|
const selectedPersonId = ref<number | null>(null)
|
||||||
const personKeyword = ref('')
|
const personKeyword = ref('')
|
||||||
@ -254,6 +252,8 @@ const borrowDate = ref('')
|
|||||||
const notes = ref('')
|
const notes = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const feedback = ref<{ type: 'warning' | 'error'; message: string } | null>(null)
|
const feedback = ref<{ type: 'warning' | 'error'; message: string } | null>(null)
|
||||||
|
let personSearchTimer: number | null = null
|
||||||
|
let latestPersonRequestId = 0
|
||||||
|
|
||||||
const selectedPerson = computed(() => {
|
const selectedPerson = computed(() => {
|
||||||
if (selectedPersonId.value === null) {
|
if (selectedPersonId.value === null) {
|
||||||
@ -285,21 +285,37 @@ const resetForm = () => {
|
|||||||
feedback.value = null
|
feedback.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensurePersonsLoaded = async () => {
|
const fetchPersons = async (keyword = '') => {
|
||||||
if (persons.value.length > 0 || personsLoading.value) {
|
const requestId = ++latestPersonRequestId
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
personsLoading.value = true
|
personsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getPersonOptions({ includeInactive: true })
|
const response = await getPersonOptions({
|
||||||
persons.value = response.data || []
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
includeInactive: true
|
||||||
|
})
|
||||||
|
if (requestId === latestPersonRequestId) {
|
||||||
|
persons.value = response.data?.list || []
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (requestId === latestPersonRequestId) {
|
||||||
|
persons.value = []
|
||||||
|
feedback.value = {
|
||||||
|
type: 'error',
|
||||||
|
message: error?.message || '使用者搜索失败,请稍后重试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
personsLoading.value = false
|
if (requestId === latestPersonRequestId) {
|
||||||
|
personsLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectPerson = (person: Person) => {
|
const ensurePersonsLoaded = async () => fetchPersons(personKeyword.value.trim())
|
||||||
|
|
||||||
|
const selectPerson = (person: PersonOption) => {
|
||||||
selectedPersonId.value = person.id
|
selectedPersonId.value = person.id
|
||||||
personKeyword.value = buildManualBorrowPersonDisplay(person)
|
personKeyword.value = buildManualBorrowPersonDisplay(person)
|
||||||
}
|
}
|
||||||
@ -412,6 +428,29 @@ watch(
|
|||||||
if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) {
|
if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) {
|
||||||
selectedPersonId.value = null
|
selectedPersonId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!props.modelValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personSearchTimer) {
|
||||||
|
window.clearTimeout(personSearchTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldKeepCurrentSelection) {
|
||||||
|
personsLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
personSearchTimer = window.setTimeout(() => {
|
||||||
|
fetchPersons(value.trim())
|
||||||
|
}, 300)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (personSearchTimer) {
|
||||||
|
window.clearTimeout(personSearchTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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"
|
v-model.trim="personKeyword"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
|
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
|
||||||
@keyup.enter="handlePersonKeywordSubmit"
|
@keyup.enter="handlePersonKeywordSubmit"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-text-tertiary">
|
<p class="text-xs text-text-tertiary">
|
||||||
@ -206,7 +206,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div v-if="showPersonSearchResults" class="space-y-2">
|
<div v-if="showPersonSearchResults" class="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-3 px-1">
|
<div class="flex items-center justify-between gap-3 px-1">
|
||||||
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p>
|
<p class="text-xs text-text-tertiary">支持花名、姓名、工号和部门搜索。</p>
|
||||||
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
||||||
{{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }}
|
{{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }}
|
||||||
</p>
|
</p>
|
||||||
@ -225,7 +225,6 @@
|
|||||||
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
{{ person.department || '未填写部门' }}
|
{{ person.department || '未填写部门' }}
|
||||||
<span v-if="person.phone"> · {{ person.phone }}</span>
|
|
||||||
<span v-if="person.job_number"> · {{ person.job_number }}</span>
|
<span v-if="person.job_number"> · {{ person.job_number }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -320,9 +319,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { getEquipmentList, scanEquipment } from '@/api/equipment'
|
import { getEquipmentList, scanEquipment } from '@/api/equipment'
|
||||||
import { scanBorrow } from '@/api/borrow'
|
import { scanBorrow } from '@/api/borrow'
|
||||||
import { getPersonList } from '@/api/person'
|
import { getPersonOptions, type PersonOption } from '@/api/person'
|
||||||
import { getChildren } from '@/api/mount'
|
import { getChildren } from '@/api/mount'
|
||||||
import type { Equipment, Person } from '@/types'
|
import type { Equipment } from '@/types'
|
||||||
import type { ChildEquipment } from '@/api/mount'
|
import type { ChildEquipment } from '@/api/mount'
|
||||||
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
|
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
|
||||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||||
@ -366,7 +365,7 @@ const manualSearchResults = ref<Equipment[]>([])
|
|||||||
const manualSearchLoading = ref(false)
|
const manualSearchLoading = ref(false)
|
||||||
const personKeyword = ref('')
|
const personKeyword = ref('')
|
||||||
const selectedPersonId = ref<number | null>(null)
|
const selectedPersonId = ref<number | null>(null)
|
||||||
const persons = ref<Person[]>([])
|
const persons = ref<PersonOption[]>([])
|
||||||
let equipmentSearchTimer: number | null = null
|
let equipmentSearchTimer: number | null = null
|
||||||
let personSearchTimer: number | null = null
|
let personSearchTimer: number | null = null
|
||||||
let latestEquipmentRequestId = 0
|
let latestEquipmentRequestId = 0
|
||||||
@ -517,7 +516,7 @@ const selectManualSearchResult = async (equipment: Equipment) => {
|
|||||||
await handleScan(equipment.equipment_code)
|
await handleScan(equipment.equipment_code)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectPerson = (person: Person) => {
|
const selectPerson = (person: PersonOption) => {
|
||||||
selectedPersonId.value = person.id
|
selectedPersonId.value = person.id
|
||||||
personKeyword.value = buildManualBorrowPersonDisplay(person)
|
personKeyword.value = buildManualBorrowPersonDisplay(person)
|
||||||
}
|
}
|
||||||
@ -625,14 +624,14 @@ const fetchPersons = async (keyword = '') => {
|
|||||||
const requestId = ++latestPersonRequestId
|
const requestId = ++latestPersonRequestId
|
||||||
personSearchLoading.value = true
|
personSearchLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getPersonList({
|
const response = await getPersonOptions({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: keyword ? 20 : 100,
|
pageSize: 20,
|
||||||
keyword: keyword || undefined,
|
keyword: keyword || undefined,
|
||||||
status: 'active'
|
status: 'active'
|
||||||
})
|
})
|
||||||
if (response.data && requestId === latestPersonRequestId) {
|
if (response.data && requestId === latestPersonRequestId) {
|
||||||
persons.value = response.data.list
|
persons.value = response.data.list || []
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取使用者列表失败', error)
|
console.error('获取使用者列表失败', error)
|
||||||
|
|||||||
@ -210,7 +210,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
{{ record.person?.department || '未填写部门' }}
|
{{ record.person?.department || '未填写部门' }}
|
||||||
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
|
|
||||||
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
|
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
|
||||||
<span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
|
<span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -5,7 +5,6 @@ export interface ManualBorrowSearchPerson {
|
|||||||
job_number?: string | null
|
job_number?: string | null
|
||||||
department?: string | null
|
department?: string | null
|
||||||
department_names?: string[]
|
department_names?: string[]
|
||||||
phone?: string | null
|
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,8 +65,7 @@ export const filterManualBorrowPersons = <T extends ManualBorrowSearchPerson>(
|
|||||||
includesKeyword(person.nickname, normalizedKeyword) ||
|
includesKeyword(person.nickname, normalizedKeyword) ||
|
||||||
includesKeyword(person.real_name, normalizedKeyword) ||
|
includesKeyword(person.real_name, normalizedKeyword) ||
|
||||||
includesKeyword(person.job_number, normalizedKeyword) ||
|
includesKeyword(person.job_number, normalizedKeyword) ||
|
||||||
includesKeyword(buildDepartmentText(person), normalizedKeyword) ||
|
includesKeyword(buildDepartmentText(person), normalizedKeyword)
|
||||||
includesKeyword(person.phone, normalizedKeyword)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ export interface ManualReturnRecordPerson {
|
|||||||
real_name?: string | null
|
real_name?: string | null
|
||||||
department?: string | null
|
department?: string | null
|
||||||
department_names?: string[]
|
department_names?: string[]
|
||||||
phone?: string | null
|
|
||||||
status?: string | null
|
status?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,7 +10,6 @@ export interface ManualReturnRecordCandidate {
|
|||||||
id: number
|
id: number
|
||||||
person_name: string
|
person_name: string
|
||||||
person_nickname: string
|
person_nickname: string
|
||||||
person_identifier?: string | null
|
|
||||||
person?: ManualReturnRecordPerson | null
|
person?: ManualReturnRecordPerson | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,17 +31,13 @@ const buildDepartmentText = (record: ManualReturnRecordCandidate) => {
|
|||||||
const getMatchScore = (record: ManualReturnRecordCandidate, keyword: string) => {
|
const getMatchScore = (record: ManualReturnRecordCandidate, keyword: string) => {
|
||||||
const nickname = normalize(record.person_nickname)
|
const nickname = normalize(record.person_nickname)
|
||||||
const realName = normalize(record.person_name)
|
const realName = normalize(record.person_name)
|
||||||
const identifier = normalize(record.person_identifier)
|
|
||||||
|
|
||||||
if (nickname === keyword) return 0
|
if (nickname === keyword) return 0
|
||||||
if (realName === keyword) return 1
|
if (realName === keyword) return 1
|
||||||
if (identifier === keyword) return 2
|
if (nickname.startsWith(keyword)) return 2
|
||||||
if (nickname.startsWith(keyword)) return 3
|
if (realName.startsWith(keyword)) return 3
|
||||||
if (realName.startsWith(keyword)) return 4
|
if (nickname.includes(keyword)) return 4
|
||||||
if (identifier.startsWith(keyword)) return 5
|
if (realName.includes(keyword)) return 5
|
||||||
if (nickname.includes(keyword)) return 6
|
return 6
|
||||||
if (realName.includes(keyword)) return 7
|
|
||||||
return 8
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filterManualReturnRecordCandidates = <T extends ManualReturnRecordCandidate>(
|
export const filterManualReturnRecordCandidates = <T extends ManualReturnRecordCandidate>(
|
||||||
@ -60,9 +54,7 @@ export const filterManualReturnRecordCandidates = <T extends ManualReturnRecordC
|
|||||||
return (
|
return (
|
||||||
includesKeyword(record.person_nickname, normalizedKeyword) ||
|
includesKeyword(record.person_nickname, normalizedKeyword) ||
|
||||||
includesKeyword(record.person_name, normalizedKeyword) ||
|
includesKeyword(record.person_name, normalizedKeyword) ||
|
||||||
includesKeyword(record.person_identifier, normalizedKeyword) ||
|
includesKeyword(buildDepartmentText(record), normalizedKeyword)
|
||||||
includesKeyword(buildDepartmentText(record), normalizedKeyword) ||
|
|
||||||
includesKeyword(record.person?.phone, normalizedKeyword)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -98,8 +90,7 @@ export const findPreferredManualReturnRecordCandidate = <T extends ManualReturnR
|
|||||||
const exactMatch = records.find((record) => {
|
const exactMatch = records.find((record) => {
|
||||||
return (
|
return (
|
||||||
normalize(record.person_nickname) === normalizedKeyword ||
|
normalize(record.person_nickname) === normalizedKeyword ||
|
||||||
normalize(record.person_name) === normalizedKeyword ||
|
normalize(record.person_name) === normalizedKeyword
|
||||||
normalize(record.person_identifier) === normalizedKeyword
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -123,7 +114,6 @@ export const isManualReturnSelectedRecordKeyword = <T extends ManualReturnRecord
|
|||||||
return [
|
return [
|
||||||
normalize(record.person_nickname),
|
normalize(record.person_nickname),
|
||||||
normalize(record.person_name),
|
normalize(record.person_name),
|
||||||
normalize(record.person_identifier),
|
|
||||||
displayName
|
displayName
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@ -6,7 +6,6 @@ export interface ManualReturnAssetCandidate {
|
|||||||
equipment_type?: string | null
|
equipment_type?: string | null
|
||||||
active_borrow_count: number
|
active_borrow_count: number
|
||||||
borrower_names?: string[]
|
borrower_names?: string[]
|
||||||
borrower_identifiers?: string[]
|
|
||||||
borrower_departments?: string[]
|
borrower_departments?: string[]
|
||||||
can_return?: boolean
|
can_return?: boolean
|
||||||
can_handle_borrow_anomaly?: boolean
|
can_handle_borrow_anomaly?: boolean
|
||||||
|
|||||||
@ -528,7 +528,7 @@ const canViewDisposals = computed(() => authStore.canAny([
|
|||||||
PERMISSIONS.DISPOSALS_APPROVE,
|
PERMISSIONS.DISPOSALS_APPROVE,
|
||||||
PERMISSIONS.DISPOSALS_COMPLETE
|
PERMISSIONS.DISPOSALS_COMPLETE
|
||||||
]))
|
]))
|
||||||
const canPrintLabels = computed(() => canCreate.value || canCorrect.value)
|
const canPrintLabels = computed(() => authStore.can(PERMISSIONS.ASSETS_PRINT_LABELS))
|
||||||
const canOverrideCorrectionRestrictions = computed(() => authStore.can(PERMISSIONS.SYSTEM_SETTINGS))
|
const canOverrideCorrectionRestrictions = computed(() => authStore.can(PERMISSIONS.SYSTEM_SETTINGS))
|
||||||
const hasActiveMaintenance = computed(() => maintenanceRecords.value.some((record) => record.status === 'in_progress'))
|
const hasActiveMaintenance = computed(() => maintenanceRecords.value.some((record) => record.status === 'in_progress'))
|
||||||
const hasActiveDisposal = computed(() => disposalRecords.value.some((record) => record.status === 'pending' || record.status === 'approved'))
|
const hasActiveDisposal = computed(() => disposalRecords.value.some((record) => record.status === 'pending' || record.status === 'approved'))
|
||||||
|
|||||||
@ -500,7 +500,7 @@
|
|||||||
v-model.trim="borrowPersonKeyword"
|
v-model.trim="borrowPersonKeyword"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
|
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
|
||||||
@keyup.enter="handleBorrowPersonKeywordSubmit"
|
@keyup.enter="handleBorrowPersonKeywordSubmit"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-text-tertiary">输入关键词搜索,点选即可;如果只匹配到 1 人,按回车也能直接选中。</p>
|
<p class="text-xs text-text-tertiary">输入关键词搜索,点选即可;如果只匹配到 1 人,按回车也能直接选中。</p>
|
||||||
@ -508,7 +508,7 @@
|
|||||||
<!-- 搜索结果 -->
|
<!-- 搜索结果 -->
|
||||||
<div v-if="showBorrowPersonResults" class="mt-2 space-y-2">
|
<div v-if="showBorrowPersonResults" class="mt-2 space-y-2">
|
||||||
<div class="flex items-center justify-between gap-3 px-1">
|
<div class="flex items-center justify-between gap-3 px-1">
|
||||||
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p>
|
<p class="text-xs text-text-tertiary">支持花名、姓名、工号和部门搜索。</p>
|
||||||
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
||||||
{{ borrowPersonLoading ? '搜索中...' : `共 ${filteredBorrowPersons.length} 条` }}
|
{{ borrowPersonLoading ? '搜索中...' : `共 ${filteredBorrowPersons.length} 条` }}
|
||||||
</p>
|
</p>
|
||||||
@ -528,7 +528,6 @@
|
|||||||
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
||||||
<p class="mt-0.5 text-xs text-text-secondary">
|
<p class="mt-0.5 text-xs text-text-secondary">
|
||||||
{{ person.department || '未填写部门' }}
|
{{ person.department || '未填写部门' }}
|
||||||
<span v-if="person.phone"> · {{ person.phone }}</span>
|
|
||||||
<span v-if="person.job_number"> · {{ person.job_number }}</span>
|
<span v-if="person.job_number"> · {{ person.job_number }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -664,7 +663,6 @@
|
|||||||
<p class="text-sm font-medium text-text-primary">{{ record.person_nickname }} · {{ record.person_name }}</p>
|
<p class="text-sm font-medium text-text-primary">{{ record.person_nickname }} · {{ record.person_name }}</p>
|
||||||
<p class="mt-0.5 text-xs text-text-secondary">
|
<p class="mt-0.5 text-xs text-text-secondary">
|
||||||
{{ record.person?.department || '未填写部门' }}
|
{{ record.person?.department || '未填写部门' }}
|
||||||
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
|
|
||||||
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
|
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
|
||||||
<span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
|
<span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
|
||||||
</p>
|
</p>
|
||||||
@ -839,7 +837,7 @@ import { PERMISSIONS } from '@/auth/permissions'
|
|||||||
import { ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
|
import { ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
import { getEquipmentList, deleteEquipment, scanEquipment } from '@/api/equipment'
|
import { getEquipmentList, deleteEquipment, scanEquipment } from '@/api/equipment'
|
||||||
import { getPersonOptions, getPersonList } from '@/api/person'
|
import { getPersonOptions, type PersonOption } from '@/api/person'
|
||||||
import { getDepartmentOptions } from '@/api/department'
|
import { getDepartmentOptions } from '@/api/department'
|
||||||
import { getAssetTypeOptions } from '@/api/assetType'
|
import { getAssetTypeOptions } from '@/api/assetType'
|
||||||
import { getStorageLocationOptions } from '@/api/storageLocation'
|
import { getStorageLocationOptions } from '@/api/storageLocation'
|
||||||
@ -854,7 +852,7 @@ import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
|
|||||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||||
import MountHistory from '@/components/MountHistory.vue'
|
import MountHistory from '@/components/MountHistory.vue'
|
||||||
import MountManager from '@/components/MountManager.vue'
|
import MountManager from '@/components/MountManager.vue'
|
||||||
import type { Equipment, Person, Department, AssetType, StorageLocation, BorrowRecord } from '@/types'
|
import type { Equipment, Department, AssetType, StorageLocation, BorrowRecord } from '@/types'
|
||||||
import type { ReturnSearchAssetCandidate } from '@/api/borrow'
|
import type { ReturnSearchAssetCandidate } from '@/api/borrow'
|
||||||
|
|
||||||
// 导入搜索辅助函数
|
// 导入搜索辅助函数
|
||||||
@ -885,7 +883,7 @@ const canMount = computed(() => authStore.can(PERMISSIONS.ASSETS_MOUNT))
|
|||||||
const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE))
|
const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE))
|
||||||
const canApplyDisposal = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY))
|
const canApplyDisposal = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY))
|
||||||
const canExport = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW))
|
const canExport = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW))
|
||||||
const canPrintLabels = computed(() => canCreate.value || canCorrect.value)
|
const canPrintLabels = computed(() => authStore.can(PERMISSIONS.ASSETS_PRINT_LABELS))
|
||||||
const canSelectAssets = computed(() => canBorrow.value || canTransfer.value || canPrintLabels.value)
|
const canSelectAssets = computed(() => canBorrow.value || canTransfer.value || canPrintLabels.value)
|
||||||
const list = ref<Equipment[]>([])
|
const list = ref<Equipment[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@ -895,7 +893,6 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.v
|
|||||||
const selectedIds = ref<number[]>([])
|
const selectedIds = ref<number[]>([])
|
||||||
const expandedIds = ref(new Set<number>())
|
const expandedIds = ref(new Set<number>())
|
||||||
|
|
||||||
const persons = ref<Person[]>([])
|
|
||||||
const departments = ref<Department[]>([])
|
const departments = ref<Department[]>([])
|
||||||
const assetTypes = ref<AssetType[]>([])
|
const assetTypes = ref<AssetType[]>([])
|
||||||
const storageLocations = ref<StorageLocation[]>([])
|
const storageLocations = ref<StorageLocation[]>([])
|
||||||
@ -956,7 +953,7 @@ const borrowForm = ref({
|
|||||||
const borrowPersonKeyword = ref('')
|
const borrowPersonKeyword = ref('')
|
||||||
const borrowSelectedPersonId = ref<number | null>(null)
|
const borrowSelectedPersonId = ref<number | null>(null)
|
||||||
const borrowPersonLoading = ref(false)
|
const borrowPersonLoading = ref(false)
|
||||||
const borrowPersons = ref<Person[]>([])
|
const borrowPersons = ref<PersonOption[]>([])
|
||||||
const borrowSubmitting = ref(false)
|
const borrowSubmitting = ref(false)
|
||||||
let borrowPersonSearchTimer: number | null = null
|
let borrowPersonSearchTimer: number | null = null
|
||||||
let latestBorrowPersonRequestId = 0
|
let latestBorrowPersonRequestId = 0
|
||||||
@ -1117,14 +1114,14 @@ const fetchBorrowPersons = async (keyword = '') => {
|
|||||||
const requestId = ++latestBorrowPersonRequestId
|
const requestId = ++latestBorrowPersonRequestId
|
||||||
borrowPersonLoading.value = true
|
borrowPersonLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getPersonList({
|
const response = await getPersonOptions({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: keyword ? 20 : 100,
|
pageSize: 20,
|
||||||
keyword: keyword || undefined,
|
keyword: keyword || undefined,
|
||||||
status: 'active'
|
status: 'active'
|
||||||
})
|
})
|
||||||
if (response.data && requestId === latestBorrowPersonRequestId) {
|
if (response.data && requestId === latestBorrowPersonRequestId) {
|
||||||
borrowPersons.value = response.data.list
|
borrowPersons.value = response.data.list || []
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取使用者列表失败', error)
|
console.error('获取使用者列表失败', error)
|
||||||
@ -1135,7 +1132,7 @@ const fetchBorrowPersons = async (keyword = '') => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectBorrowPerson = (person: Person) => {
|
const selectBorrowPerson = (person: PersonOption) => {
|
||||||
borrowSelectedPersonId.value = person.id
|
borrowSelectedPersonId.value = person.id
|
||||||
borrowPersonKeyword.value = buildManualBorrowPersonDisplay(person)
|
borrowPersonKeyword.value = buildManualBorrowPersonDisplay(person)
|
||||||
}
|
}
|
||||||
@ -1396,13 +1393,11 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchOptions = async () => {
|
const fetchOptions = async () => {
|
||||||
const [p, d, t, s] = await Promise.all([
|
const [d, t, s] = await Promise.all([
|
||||||
getPersonOptions(),
|
|
||||||
getDepartmentOptions(),
|
getDepartmentOptions(),
|
||||||
getAssetTypeOptions(),
|
getAssetTypeOptions(),
|
||||||
getStorageLocationOptions()
|
getStorageLocationOptions()
|
||||||
])
|
])
|
||||||
if (p.data) persons.value = p.data
|
|
||||||
if (d.data) departments.value = d.data
|
if (d.data) departments.value = d.data
|
||||||
if (t.data) assetTypes.value = t.data
|
if (t.data) assetTypes.value = t.data
|
||||||
if (s.data) storageLocations.value = s.data
|
if (s.data) storageLocations.value = s.data
|
||||||
@ -1868,7 +1863,7 @@ const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?:
|
|||||||
return `${nickname}(${realName})`
|
return `${nickname}(${realName})`
|
||||||
}
|
}
|
||||||
|
|
||||||
return nickname || realName || currentBorrow.person_identifier || '使用者待补充'
|
return nickname || realName || '使用者待补充'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCurrentBorrowerMeta = (equipment: {
|
const getCurrentBorrowerMeta = (equipment: {
|
||||||
|
|||||||
@ -441,6 +441,20 @@
|
|||||||
<span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待分配</span>
|
<span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待分配</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 max-w-xl field-stack">
|
||||||
|
<label class="field-label">搜索责任人</label>
|
||||||
|
<input
|
||||||
|
v-model.trim="personOptionKeyword"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="输入花名 / 姓名 / 工号 / 部门"
|
||||||
|
@input="schedulePersonOptionSearch"
|
||||||
|
/>
|
||||||
|
<p class="field-hint">
|
||||||
|
{{ personOptionLoading ? '正在搜索...' : personOptionError || `已加载 ${personOptions.length} / ${personOptionTotal} 人` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="item in unassignedItems"
|
v-for="item in unassignedItems"
|
||||||
@ -521,9 +535,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getPersonOptions } from '@/api/person'
|
import { getPersonOptions, type PersonOption } from '@/api/person'
|
||||||
import {
|
import {
|
||||||
assignInventoryTaskUnassignedItem,
|
assignInventoryTaskUnassignedItem,
|
||||||
closeInventoryTask,
|
closeInventoryTask,
|
||||||
@ -537,8 +551,7 @@ import type {
|
|||||||
InventoryTaskAction,
|
InventoryTaskAction,
|
||||||
InventoryTaskDetail,
|
InventoryTaskDetail,
|
||||||
InventoryTaskItem,
|
InventoryTaskItem,
|
||||||
InventoryTaskParticipant,
|
InventoryTaskParticipant
|
||||||
Person
|
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
||||||
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
|
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
|
||||||
@ -558,7 +571,11 @@ const router = useRouter()
|
|||||||
|
|
||||||
const task = ref<InventoryTaskDetail | null>(null)
|
const task = ref<InventoryTaskDetail | null>(null)
|
||||||
const unassignedItems = ref<InventoryTaskItem[]>([])
|
const unassignedItems = ref<InventoryTaskItem[]>([])
|
||||||
const personOptions = ref<Person[]>([])
|
const personOptions = ref<PersonOption[]>([])
|
||||||
|
const personOptionKeyword = ref('')
|
||||||
|
const personOptionTotal = ref(0)
|
||||||
|
const personOptionLoading = ref(false)
|
||||||
|
const personOptionError = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const closing = ref(false)
|
const closing = ref(false)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
@ -568,6 +585,8 @@ const itemFilter = ref<ItemFilter>('all')
|
|||||||
const participantFilter = ref('all')
|
const participantFilter = ref('all')
|
||||||
const itemKeyword = ref('')
|
const itemKeyword = ref('')
|
||||||
const adminScanCode = ref('')
|
const adminScanCode = ref('')
|
||||||
|
let personOptionSearchTimer: number | null = null
|
||||||
|
let latestPersonOptionRequestId = 0
|
||||||
const showAdminScanner = ref(false)
|
const showAdminScanner = ref(false)
|
||||||
const adminLastHandledItem = ref<InventoryTaskItem | null>(null)
|
const adminLastHandledItem = ref<InventoryTaskItem | null>(null)
|
||||||
const assignPersonIdMap = reactive<Record<number, string>>({})
|
const assignPersonIdMap = reactive<Record<number, string>>({})
|
||||||
@ -729,17 +748,45 @@ const resolveActionDetail = (action: InventoryTaskAction) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPersonOptions = async () => {
|
const loadPersonOptions = async (keyword = '') => {
|
||||||
|
const requestId = ++latestPersonOptionRequestId
|
||||||
|
personOptionLoading.value = true
|
||||||
|
personOptionError.value = ''
|
||||||
try {
|
try {
|
||||||
const response = await getPersonOptions()
|
const response = await getPersonOptions({
|
||||||
personOptions.value = response.data
|
page: 1,
|
||||||
.filter((person) => person.status === 'active')
|
pageSize: 50,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
if (requestId !== latestPersonOptionRequestId) return
|
||||||
|
personOptions.value = (response.data?.list || [])
|
||||||
|
.slice()
|
||||||
.sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'))
|
.sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'))
|
||||||
} catch (error) {
|
personOptionTotal.value = response.data?.total || 0
|
||||||
|
} catch (error: any) {
|
||||||
console.error('获取人员列表失败', error)
|
console.error('获取人员列表失败', error)
|
||||||
|
if (requestId === latestPersonOptionRequestId) {
|
||||||
|
personOptions.value = []
|
||||||
|
personOptionTotal.value = 0
|
||||||
|
personOptionError.value = error?.message || '人员搜索失败'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestPersonOptionRequestId) {
|
||||||
|
personOptionLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const schedulePersonOptionSearch = () => {
|
||||||
|
if (personOptionSearchTimer) {
|
||||||
|
window.clearTimeout(personOptionSearchTimer)
|
||||||
|
}
|
||||||
|
personOptionSearchTimer = window.setTimeout(() => {
|
||||||
|
loadPersonOptions(personOptionKeyword.value.trim())
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
const loadTask = async () => {
|
const loadTask = async () => {
|
||||||
if (!Number.isInteger(taskId.value) || taskId.value <= 0) {
|
if (!Number.isInteger(taskId.value) || taskId.value <= 0) {
|
||||||
router.push('/inventory')
|
router.push('/inventory')
|
||||||
@ -875,4 +922,10 @@ watch(
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadTask()
|
loadTask()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (personOptionSearchTimer) {
|
||||||
|
window.clearTimeout(personOptionSearchTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ const PUBLIC_ASSET_SOURCE_USERID = '__public_asset__'
|
|||||||
const PUBLIC_ASSET_NAME = '公共资产'
|
const PUBLIC_ASSET_NAME = '公共资产'
|
||||||
|
|
||||||
type PublicAssetLike = {
|
type PublicAssetLike = {
|
||||||
|
is_public_asset_person?: boolean
|
||||||
person_source_userid?: string | null
|
person_source_userid?: string | null
|
||||||
person_nickname?: string | null
|
person_nickname?: string | null
|
||||||
person_name?: string | null
|
person_name?: string | null
|
||||||
@ -19,6 +20,10 @@ export const isInventoryPublicAssetPerson = (value?: PublicAssetLike | null) =>
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value.is_public_asset_person) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (normalizeText(value.person_source_userid) === PUBLIC_ASSET_SOURCE_USERID) {
|
if (normalizeText(value.person_source_userid) === PUBLIC_ASSET_SOURCE_USERID) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -365,7 +365,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
{{ record.person?.department || '未填写部门' }}
|
{{ record.person?.department || '未填写部门' }}
|
||||||
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
|
|
||||||
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
|
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -195,17 +195,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<label class="field-label">绑定员工</label>
|
<label class="field-label">绑定员工</label>
|
||||||
<select v-model="newUser.person_id" class="select">
|
<PersonBindingPicker
|
||||||
<option value="">暂不绑定</option>
|
v-model="newUser.person_id"
|
||||||
<option
|
v-model:selected-person="newUserSelectedPerson"
|
||||||
v-for="person in personOptions"
|
clear-label="暂不绑定"
|
||||||
:key="person.id"
|
/>
|
||||||
:value="String(person.id)"
|
|
||||||
:disabled="!isPersonDingTalkReady(person)"
|
|
||||||
>
|
|
||||||
{{ formatPersonOption(person) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p class="field-hint">仅可绑定在职且已同步钉钉身份的员工,不会按姓名自动关联。</p>
|
<p class="field-hint">仅可绑定在职且已同步钉钉身份的员工,不会按姓名自动关联。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
@ -261,17 +255,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<label class="field-label">绑定员工</label>
|
<label class="field-label">绑定员工</label>
|
||||||
<select v-model="editingUserDraft.person_id" class="select">
|
<PersonBindingPicker
|
||||||
<option value="">解除绑定</option>
|
:key="String(editingUser.id)"
|
||||||
<option
|
v-model="editingUserDraft.person_id"
|
||||||
v-for="person in personOptions"
|
v-model:selected-person="editingUserSelectedPerson"
|
||||||
:key="person.id"
|
clear-label="解除绑定"
|
||||||
:value="String(person.id)"
|
/>
|
||||||
:disabled="!isPersonDingTalkReady(person)"
|
|
||||||
>
|
|
||||||
{{ formatPersonOption(person) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
@ -305,7 +294,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||||
import type { Person, User, UserRole } from '@/types'
|
import type { User, UserRole } from '@/types'
|
||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
getUserList,
|
getUserList,
|
||||||
@ -315,11 +304,11 @@ import {
|
|||||||
deleteUser
|
deleteUser
|
||||||
} from '@/api/users'
|
} from '@/api/users'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
import { getPersonOptions } from '@/api/person'
|
import type { PersonOption } from '@/api/person'
|
||||||
import { getRoleLabel } from '@/auth/permissions'
|
import { getRoleLabel } from '@/auth/permissions'
|
||||||
|
import PersonBindingPicker from '@/components/PersonBindingPicker.vue'
|
||||||
|
|
||||||
const users = ref<(User & { status: 'active' | 'inactive'; last_login_at?: string | null })[]>([])
|
const users = ref<(User & { status: 'active' | 'inactive'; last_login_at?: string | null })[]>([])
|
||||||
const personOptions = ref<Person[]>([])
|
|
||||||
const listLoading = ref(false)
|
const listLoading = ref(false)
|
||||||
const createLoading = ref(false)
|
const createLoading = ref(false)
|
||||||
const updateLoading = ref(false)
|
const updateLoading = ref(false)
|
||||||
@ -327,6 +316,8 @@ const showCreateUser = ref(false)
|
|||||||
const createUserTrigger = ref<HTMLButtonElement | null>(null)
|
const createUserTrigger = ref<HTMLButtonElement | null>(null)
|
||||||
const createDialog = ref<HTMLFormElement | null>(null)
|
const createDialog = ref<HTMLFormElement | null>(null)
|
||||||
const createUsernameInput = ref<HTMLInputElement | null>(null)
|
const createUsernameInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const newUserSelectedPerson = ref<PersonOption | null>(null)
|
||||||
|
const editingUserSelectedPerson = ref<PersonOption | null>(null)
|
||||||
|
|
||||||
const newUser = reactive({
|
const newUser = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
@ -367,15 +358,6 @@ const fetchUsers = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchPersonOptions = async () => {
|
|
||||||
try {
|
|
||||||
const response = await getPersonOptions({ includeInactive: true })
|
|
||||||
personOptions.value = response.data || []
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取员工选项失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetNewForm = () => {
|
const resetNewForm = () => {
|
||||||
newUser.username = ''
|
newUser.username = ''
|
||||||
newUser.name = ''
|
newUser.name = ''
|
||||||
@ -384,6 +366,7 @@ const resetNewForm = () => {
|
|||||||
newUser.email = ''
|
newUser.email = ''
|
||||||
newUser.phone = ''
|
newUser.phone = ''
|
||||||
newUser.person_id = ''
|
newUser.person_id = ''
|
||||||
|
newUserSelectedPerson.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreate = async () => {
|
const openCreate = async () => {
|
||||||
@ -459,10 +442,20 @@ const openEdit = (user: User & { status: 'active' | 'inactive' }) => {
|
|||||||
editingUserDraft.phone = user.phone || ''
|
editingUserDraft.phone = user.phone || ''
|
||||||
editingUserDraft.status = user.status
|
editingUserDraft.status = user.status
|
||||||
editingUserDraft.person_id = user.person_id ? String(user.person_id) : ''
|
editingUserDraft.person_id = user.person_id ? String(user.person_id) : ''
|
||||||
|
editingUserSelectedPerson.value = user.person
|
||||||
|
? {
|
||||||
|
id: user.person.id,
|
||||||
|
nickname: user.person.nickname,
|
||||||
|
real_name: user.person.real_name,
|
||||||
|
status: user.person.status,
|
||||||
|
dingtalk_ready: user.dingtalk_ready !== false
|
||||||
|
}
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeEdit = () => {
|
const closeEdit = () => {
|
||||||
editingUser.value = null
|
editingUser.value = null
|
||||||
|
editingUserSelectedPerson.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitEdit = async () => {
|
const submitEdit = async () => {
|
||||||
@ -534,19 +527,6 @@ const getUserRoleBadgeClass = (role: UserRole) => {
|
|||||||
return classes[role]
|
return classes[role]
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPersonOption = (person: Person) => {
|
|
||||||
const name = person.nickname && person.nickname !== person.real_name
|
|
||||||
? `${person.nickname}(${person.real_name})`
|
|
||||||
: person.nickname || person.real_name
|
|
||||||
const jobNumber = person.job_number ? ` · ${person.job_number}` : ''
|
|
||||||
const inactive = person.status === 'inactive' ? ' · 已停用' : ''
|
|
||||||
const dingtalkMissing = person.source_userid?.trim() ? '' : ' · 未同步钉钉身份'
|
|
||||||
return `${name}${jobNumber}${inactive}${dingtalkMissing}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPersonDingTalkReady = (person: Person) =>
|
|
||||||
person.status === 'active' && Boolean(person.source_userid?.trim())
|
|
||||||
|
|
||||||
const getBoundPersonLabel = (user: User) => {
|
const getBoundPersonLabel = (user: User) => {
|
||||||
if (user.person) {
|
if (user.person) {
|
||||||
const name = user.person.nickname && user.person.nickname !== user.person.real_name
|
const name = user.person.nickname && user.person.nickname !== user.person.real_name
|
||||||
@ -555,14 +535,12 @@ const getBoundPersonLabel = (user: User) => {
|
|||||||
return user.dingtalk_ready === false ? `${name} · 钉钉身份不可用` : name
|
return user.dingtalk_ready === false ? `${name} · 钉钉身份不可用` : name
|
||||||
}
|
}
|
||||||
if (user.person_id) {
|
if (user.person_id) {
|
||||||
const person = personOptions.value.find((item) => item.id === user.person_id)
|
return `员工 #${user.person_id}`
|
||||||
return person ? formatPersonOption(person) : `员工 #${user.person_id}`
|
|
||||||
}
|
}
|
||||||
return '未绑定'
|
return '未绑定'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
fetchPersonOptions()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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