From 920fa1289db7af66e1834ace7d52b9ff5b04c754 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 16 Jul 2026 23:12:59 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E6=B5=81=E8=BD=AC=E5=AE=89=E5=85=A8=E4=B8=8E=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- .../precheck.sql | 138 +++++-- .../migration.sql | 24 ++ .../__tests__/deployPrecheckSafety.test.js | 58 ++- .../borrowController.privacy.test.ts | 116 ++++++ .../disposalController.state-machine.test.ts | 97 ++++- .../equipmentController.search.test.ts | 7 + .../maintenanceController.concurrency.test.ts | 64 ++- .../personController.privacy.test.ts | 75 ++++ .../userController.admin-safety.test.ts | 116 ++++++ backend/src/controllers/batchController.ts | 87 +--- backend/src/controllers/borrowController.ts | 258 +++--------- backend/src/controllers/disposalController.ts | 69 +++- .../src/controllers/equipmentController.ts | 8 +- backend/src/controllers/exportController.ts | 4 +- .../src/controllers/maintenanceController.ts | 35 +- backend/src/controllers/personController.ts | 64 ++- backend/src/controllers/printController.ts | 18 +- backend/src/controllers/userController.ts | 121 +++++- backend/src/index.ts | 17 +- backend/src/middleware/auth.ts | 3 +- backend/src/routes/auth.ts | 5 +- backend/src/routes/persons.ts | 4 +- backend/src/routes/printJobs.ts | 8 +- backend/src/routes/printTerminals.ts | 12 +- .../assetMovementService.concurrency.test.ts | 369 +++++++++++++++++ .../printService.authorization.test.ts | 58 +++ backend/src/services/assetMovementService.ts | 384 ++++++++++++++++++ backend/src/services/inventoryService.ts | 13 +- backend/src/services/printService.ts | 49 ++- backend/src/utils/__tests__/authMode.test.ts | 62 +++ .../src/utils/__tests__/permissions.test.ts | 3 + .../utils/__tests__/stage1Migration.test.ts | 30 ++ backend/src/utils/authMode.ts | 54 +++ backend/src/utils/keycloak.ts | 17 +- backend/src/utils/permissions.ts | 3 + deploy.sh | 234 ++++++++--- docker-compose.yml | 12 +- frontend/Dockerfile | 6 + frontend/scripts/test-return-record-search.ts | 5 +- frontend/src/api/borrow.ts | 1 - frontend/src/api/person.ts | 41 +- frontend/src/api/print.ts | 26 ++ frontend/src/auth/permissions.ts | 1 + frontend/src/components/BarcodePrint.vue | 93 +++-- .../src/components/BorrowAnomalyHandler.vue | 71 +++- .../src/components/PersonBindingPicker.vue | 231 +++++++++++ frontend/src/views/borrow/BorrowScan.vue | 19 +- frontend/src/views/borrow/ReturnScan.vue | 1 - .../views/borrow/manualBorrowPersonSearch.ts | 4 +- .../views/borrow/manualReturnRecordSearch.ts | 24 +- .../src/views/borrow/manualReturnSearch.ts | 1 - .../src/views/equipment/EquipmentForm.vue | 2 +- .../src/views/equipment/EquipmentList.vue | 29 +- .../src/views/inventory/InventoryDetail.vue | 73 +++- .../views/inventory/inventoryPublicAsset.ts | 5 + .../notifications/NotificationCenter.vue | 1 - .../settings/components/UserManagement.vue | 78 ++-- .../tests/deploy-disposal-precheck-test.sh | 95 +++++ 59 files changed, 2857 insertions(+), 651 deletions(-) create mode 100644 backend/prisma/migrations/20260716_mainline_stage1_safety/migration.sql create mode 100644 backend/src/controllers/__tests__/borrowController.privacy.test.ts create mode 100644 backend/src/controllers/__tests__/personController.privacy.test.ts create mode 100644 backend/src/controllers/__tests__/userController.admin-safety.test.ts create mode 100644 backend/src/services/__tests__/printService.authorization.test.ts create mode 100644 backend/src/utils/__tests__/authMode.test.ts create mode 100644 backend/src/utils/__tests__/stage1Migration.test.ts create mode 100644 backend/src/utils/authMode.ts create mode 100644 frontend/src/components/PersonBindingPicker.vue create mode 100644 scripts/tests/deploy-disposal-precheck-test.sh diff --git a/.env.example b/.env.example index fd40a7c..65f2129 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,12 @@ SESSION_SECRET=change-me-session-secret SESSION_COOKIE_SECURE=false CORS_ALLOWED_ORIGINS=http://localhost:5173 AUTH_MODE=local +KEYCLOAK_URL= +KEYCLOAK_REALM=equipment-management +KEYCLOAK_CLIENT_ID=equipment-client +KEYCLOAK_CLIENT_SECRET= PORT=3000 VITE_API_BASE_URL=/api -VITE_AUTH_MODE=local SQLSERVER_HOST= SQLSERVER_PORT=1433 SQLSERVER_DATABASE= diff --git a/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql b/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql index 30b7ea2..99c7d5c 100644 --- a/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql +++ b/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql @@ -1,32 +1,42 @@ --- 只读核查:在执行迁移前,列出所有未完成的历史报废单。 --- 如查询有结果,应先由业务负责人确认当前挂载树是否就是本次报废范围。 -SELECT - disposal.`id` AS `disposal_id`, - disposal.`status`, - disposal.`equipment_id` AS `root_equipment_id`, - disposal.`equipment_code` AS `root_equipment_code`, - disposal.`applied_at`, - disposal.`applied_by` -FROM `disposal_record` disposal -WHERE disposal.`status` IN ('pending', 'approved') -ORDER BY disposal.`applied_at`, disposal.`id`; +-- 迁移前只读核查必须在一个一致性快照中同时生成: +-- 1. 未完成报废单数量;2. 缺失根资产/资产树异常数量;3. 供人工核对及计算指纹的完整资产树。 +-- deploy.sh 会展示并哈希同一批 REVIEW_ROW,禁止把展示与指纹拆成两次查询。 +SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; +START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY; --- 只读核查:展开未完成报废单的当前资产树。 --- cycle_detected = 1 表示存在环;depth = 100 表示已达自动补录上限,两者都必须先修复数据。 -WITH RECURSIVE `active_disposal_tree` AS ( +WITH RECURSIVE `active_disposals` AS ( + SELECT + disposal.`id`, + disposal.`status`, + disposal.`equipment_id`, + disposal.`equipment_code`, + disposal.`applied_at`, + disposal.`applied_by`, + disposal.`created_at`, + disposal.`updated_at` + FROM `disposal_record` disposal + WHERE disposal.`status` IN ('pending', 'approved') +), `active_disposal_tree` AS ( SELECT disposal.`id` AS `disposal_id`, equipment.`id` AS `equipment_id`, equipment.`equipment_code`, equipment.`equipment_name`, + equipment.`equipment_model`, + equipment.`equipment_type`, equipment.`parent_id`, + parent.`equipment_code` AS `parent_equipment_code`, equipment.`status` AS `current_status`, + equipment.`department_id`, + equipment.`storage_location_id`, + equipment.`mount_status`, + equipment.`updated_at` AS `equipment_updated_at`, 0 AS `depth`, CAST(CONCAT('/', equipment.`id`, '/') AS CHAR(4096)) AS `visited_path`, 0 AS `cycle_detected` - FROM `disposal_record` disposal + FROM `active_disposals` disposal INNER JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id` - WHERE disposal.`status` IN ('pending', 'approved') + LEFT JOIN `equipment` parent ON parent.`id` = equipment.`parent_id` UNION ALL @@ -35,8 +45,15 @@ WITH RECURSIVE `active_disposal_tree` AS ( child.`id`, child.`equipment_code`, child.`equipment_name`, + child.`equipment_model`, + child.`equipment_type`, child.`parent_id`, + tree.`equipment_code`, child.`status`, + child.`department_id`, + child.`storage_location_id`, + child.`mount_status`, + child.`updated_at`, tree.`depth` + 1, CONCAT(tree.`visited_path`, child.`id`, '/'), LOCATE(CONCAT('/', child.`id`, '/'), tree.`visited_path`) > 0 @@ -44,15 +61,80 @@ WITH RECURSIVE `active_disposal_tree` AS ( INNER JOIN `equipment` child ON child.`parent_id` = tree.`equipment_id` WHERE tree.`depth` < 100 AND tree.`cycle_detected` = 0 +), `review_rows` AS ( + SELECT + 1 AS `sort_group`, + CONCAT('D:', LPAD(disposal.`id`, 20, '0')) AS `sort_key`, + CAST(JSON_OBJECT( + 'record_type', 'disposal', + 'disposal_id', disposal.`id`, + 'status', disposal.`status`, + 'root_equipment_id', disposal.`equipment_id`, + 'root_equipment_code', disposal.`equipment_code`, + 'applied_at', DATE_FORMAT(disposal.`applied_at`, '%Y-%m-%dT%H:%i:%s.%f'), + 'applied_by', disposal.`applied_by`, + 'created_at', DATE_FORMAT(disposal.`created_at`, '%Y-%m-%dT%H:%i:%s.%f'), + 'updated_at', DATE_FORMAT(disposal.`updated_at`, '%Y-%m-%dT%H:%i:%s.%f') + ) AS CHAR) AS `payload` + FROM `active_disposals` disposal + + UNION ALL + + SELECT + 2 AS `sort_group`, + CONCAT( + 'T:', LPAD(tree.`disposal_id`, 20, '0'), ':', + LPAD(tree.`depth`, 3, '0'), ':', LPAD(tree.`equipment_id`, 20, '0') + ) AS `sort_key`, + CAST(JSON_OBJECT( + 'record_type', 'asset_tree', + 'disposal_id', tree.`disposal_id`, + 'equipment_id', tree.`equipment_id`, + 'equipment_code', tree.`equipment_code`, + 'equipment_name', tree.`equipment_name`, + 'equipment_model', tree.`equipment_model`, + 'equipment_type', tree.`equipment_type`, + 'parent_id', tree.`parent_id`, + 'parent_equipment_code', tree.`parent_equipment_code`, + 'current_status', tree.`current_status`, + 'department_id', tree.`department_id`, + 'storage_location_id', tree.`storage_location_id`, + 'mount_status', tree.`mount_status`, + 'equipment_updated_at', DATE_FORMAT(tree.`equipment_updated_at`, '%Y-%m-%dT%H:%i:%s.%f'), + 'depth', tree.`depth`, + 'cycle_detected', tree.`cycle_detected` + ) AS CHAR) AS `payload` + FROM `active_disposal_tree` tree +), `precheck_output` AS ( + SELECT 0 AS `sort_group`, '1' AS `sort_key`, 'MISSING_ROOT_COUNT' AS `record_type`, + CAST(COUNT(*) AS CHAR) AS `payload` + FROM `active_disposals` disposal + LEFT JOIN `equipment` equipment ON equipment.`id` = disposal.`equipment_id` + WHERE equipment.`id` IS NULL + + UNION ALL + + SELECT 0, '2', 'TREE_ANOMALY_COUNT', CAST(COUNT(*) AS CHAR) + FROM `active_disposal_tree` + WHERE `cycle_detected` = 1 OR `depth` >= 100 + + UNION ALL + + SELECT 0, '3', 'ACTIVE_DISPOSAL_COUNT', CAST(COUNT(*) AS CHAR) + FROM `active_disposals` + + UNION ALL + + SELECT 0, '4', 'REVIEW_ROW_COUNT', CAST(COUNT(*) AS CHAR) + FROM `review_rows` + + UNION ALL + + SELECT `sort_group`, `sort_key`, 'REVIEW_ROW', `payload` + FROM `review_rows` ) -SELECT - `disposal_id`, - `equipment_id`, - `equipment_code`, - `equipment_name`, - `parent_id`, - `current_status`, - `depth`, - `cycle_detected` -FROM `active_disposal_tree` -ORDER BY `disposal_id`, `depth`, `equipment_id`; +SELECT `record_type`, `payload` +FROM `precheck_output` +ORDER BY `sort_group`, `sort_key`; + +COMMIT; diff --git a/backend/prisma/migrations/20260716_mainline_stage1_safety/migration.sql b/backend/prisma/migrations/20260716_mainline_stage1_safety/migration.sql new file mode 100644 index 0000000..71c70f7 --- /dev/null +++ b/backend/prisma/migrations/20260716_mainline_stage1_safety/migration.sql @@ -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` + ); diff --git a/backend/scripts/__tests__/deployPrecheckSafety.test.js b/backend/scripts/__tests__/deployPrecheckSafety.test.js index 9bfc118..97fbbd2 100644 --- a/backend/scripts/__tests__/deployPrecheckSafety.test.js +++ b/backend/scripts/__tests__/deployPrecheckSafety.test.js @@ -4,7 +4,12 @@ const fs = require('node:fs') const path = require('node:path') const deployPath = path.resolve(__dirname, '../../../deploy.sh') +const precheckPath = path.resolve( + __dirname, + '../../prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql' +) const deployScript = fs.readFileSync(deployPath, 'utf8') +const precheckSql = fs.readFileSync(precheckPath, 'utf8') test('标准部署在 build/up 前必须通过报废迁移预检', () => { const gateCall = deployScript.lastIndexOf('\nrun_disposal_migration_precheck\n') @@ -32,16 +37,19 @@ test('预检只连接固定资产 MySQL,不在命令行暴露密码', () => { }) test('无法确认或存在未核对报废单时默认阻断,仅允许本次显式确认', () => { - assert.ok( - deployScript.includes( - "SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;" - ), - '预检必须稳定读取待审批和已审批报废单的指纹字段' - ) - assert.match(deployScript, /printf '%s\\n' "\$active_rows" \| sha256sum/) + assert.match(precheckSql, /SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;/) + assert.match(precheckSql, /START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;/) + assert.match(precheckSql, /'ACTIVE_DISPOSAL_COUNT'/) + assert.match(precheckSql, /'REVIEW_ROW'/) + assert.match(precheckSql, /COMMIT;/) assert.match( deployScript, - /CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "\$active_fingerprint"/ + /snapshot_output="\$\([\s\S]*run_equipment_mysql --batch --skip-column-names --raw < "\$DISPOSAL_PRECHECK_SQL"[\s\S]*\)"/ + ) + assert.match(deployScript, /printf '%s\\n' "\$review_rows" \| sha256sum/) + assert.match( + deployScript, + /CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "\$CURRENT_DISPOSAL_FINGERPRINT"/ ) assert.match(deployScript, /已默认阻止部署/) }) @@ -49,11 +57,41 @@ test('无法确认或存在未核对报废单时默认阻断,仅允许本次 test('未完成报废资产树存在环或深度达到 100 时禁止 override', () => { const anomalyBlock = deployScript.indexOf('if [ "$tree_anomaly_count" -gt 0 ]') const overrideCheck = deployScript.indexOf( - 'if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]' + 'if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$CURRENT_DISPOSAL_FINGERPRINT" ]' ) - assert.match(deployScript, /WHERE cycle_detected = 1 OR depth >= 100;/) + assert.match(precheckSql, /WHERE `cycle_detected` = 1 OR `depth` >= 100/) assert.ok(anomalyBlock > 0, '必须对资产树异常数量做硬阻断') assert.ok(overrideCheck > anomalyBlock, '资产树异常阻断必须早于指纹 override') assert.match(deployScript, /不允许通过确认指纹绕过/) }) + +test('人工展示与指纹来自同一次数据库一致性快照', () => { + const functionBody = deployScript.slice( + deployScript.indexOf('calculate_active_disposal_fingerprint()'), + deployScript.indexOf('\nrun_disposal_migration_precheck()') + ) + const mysqlCalls = functionBody.match(/run_equipment_mysql/g) || [] + + assert.equal(mysqlCalls.length, 1, '一次指纹计算只能打开一个数据库会话') + assert.match(functionBody, /review_rows=.*snapshot_output/) + assert.match(functionBody, /printf '%s\\n' "\$review_rows"/) + assert.doesNotMatch( + deployScript.slice( + deployScript.indexOf('run_disposal_migration_precheck()'), + deployScript.indexOf('\nrecheck_disposal_migration_precheck()') + ), + /run_equipment_mysql\s+<\s+"\$DISPOSAL_PRECHECK_SQL"/ + ) +}) + +test('同名迁移同时存在完成和未完成记录时必须优先判为 incomplete', () => { + const stateQuery = deployScript.slice( + deployScript.indexOf('SELECT CASE'), + deployScript.indexOf('END\nFROM _prisma_migrations') + ) + assert.ok( + stateQuery.indexOf("THEN 'incomplete'") < stateQuery.indexOf("THEN 'applied'"), + '未完成记录必须优先阻断,不能被旧的已完成记录掩盖' + ) +}) diff --git a/backend/src/controllers/__tests__/borrowController.privacy.test.ts b/backend/src/controllers/__tests__/borrowController.privacy.test.ts new file mode 100644 index 0000000..48e916a --- /dev/null +++ b/backend/src/controllers/__tests__/borrowController.privacy.test.ts @@ -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 + } +}) diff --git a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts index 3f2842b..24ea0fa 100644 --- a/backend/src/controllers/__tests__/disposalController.state-machine.test.ts +++ b/backend/src/controllers/__tests__/disposalController.state-machine.test.ts @@ -8,7 +8,9 @@ import { buildDisposalApprovedDingTalkContent, buildDisposalCompletedDingTalkContent, buildDisposalRejectedDingTalkContent, - createDisposal + createDisposal, + getDisposalDetail, + getDisposalList } from '../disposalController' const now = new Date('2026-07-14T03:00:00.000Z') @@ -349,3 +351,96 @@ test('驳回报废时按每个 item.pre_status 分别恢复', async () => { ;(prisma.operationLog.create as any) = originalLogCreate } }) + +test('行政已处理列表包含全部已审批、已驳回和已完成记录,不按处理人收窄', async () => { + const originalFindMany = prisma.disposalRecord.findMany + const originalCount = prisma.disposalRecord.count + let capturedWhere: any = null + + ;(prisma.disposalRecord.findMany as any) = async (args: any) => { + capturedWhere = args.where + return [{ + id: 119, + status: 'completed', + notes: '旧版处置备注', + completion_notes: null + }] + } + ;(prisma.disposalRecord.count as any) = async () => 1 + + try { + const { res, state } = createResponse('administrative') + await getDisposalList( + { query: { scope: 'processed', page: '1', pageSize: '10' } } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.deepEqual(capturedWhere.AND[0], { + status: { in: ['approved', 'rejected', 'completed'] } + }) + assert.doesNotMatch(JSON.stringify(capturedWhere), /approved_by|completed_by/) + assert.equal(state.body.data.list[0].notes, '旧版处置备注') + assert.equal(state.body.data.list[0].historical_notes, '旧版处置备注') + } finally { + ;(prisma.disposalRecord.findMany as any) = originalFindMany + ;(prisma.disposalRecord.count as any) = originalCount + } +}) + +test('IT即使请求all范围也只能查询本人发起的报废记录', async () => { + const originalFindMany = prisma.disposalRecord.findMany + const originalCount = prisma.disposalRecord.count + let capturedWhere: any = null + + ;(prisma.disposalRecord.findMany as any) = async (args: any) => { + capturedWhere = args.where + return [] + } + ;(prisma.disposalRecord.count as any) = async () => 0 + + try { + const { res, state } = createResponse('it') + await getDisposalList( + { query: { scope: 'all', page: '1', pageSize: '10' } } as any, + res as any + ) + + assert.equal(state.statusCode, 200) + assert.deepEqual(capturedWhere.AND[0], { applied_by: 6 }) + } finally { + ;(prisma.disposalRecord.findMany as any) = originalFindMany + ;(prisma.disposalRecord.count as any) = originalCount + } +}) + +test('IT不能通过详情接口查看他人申请,行政可以查看全部流程详情', async () => { + const originalFindUnique = prisma.disposalRecord.findUnique + ;(prisma.disposalRecord.findUnique as any) = async () => ({ + id: 120, + applied_by: 99, + status: 'completed', + notes: '历史备注', + completion_notes: null + }) + + try { + const itResponse = createResponse('it') + await getDisposalDetail( + { params: { id: '120' } } as any, + itResponse.res as any + ) + assert.equal(itResponse.state.statusCode, 403) + + const administrativeResponse = createResponse('administrative') + await getDisposalDetail( + { params: { id: '120' } } as any, + administrativeResponse.res as any + ) + assert.equal(administrativeResponse.state.statusCode, 200) + assert.equal(administrativeResponse.state.body.data.notes, '历史备注') + assert.equal(administrativeResponse.state.body.data.historical_notes, '历史备注') + } finally { + ;(prisma.disposalRecord.findUnique as any) = originalFindUnique + } +}) diff --git a/backend/src/controllers/__tests__/equipmentController.search.test.ts b/backend/src/controllers/__tests__/equipmentController.search.test.ts index 0cc7e4d..cf1391e 100644 --- a/backend/src/controllers/__tests__/equipmentController.search.test.ts +++ b/backend/src/controllers/__tests__/equipmentController.search.test.ts @@ -29,8 +29,10 @@ test('资产列表搜索不应再按借用人身份证号或历史 person_identi const originalCount = prisma.equipment.count let capturedWhere: any = null + let capturedArgs: any = null ;(prisma.equipment.findMany as any) = async (args: any) => { + capturedArgs = args capturedWhere = args.where return [] } @@ -66,6 +68,11 @@ test('资产列表搜索不应再按借用人身份证号或历史 person_identi false, '资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中' ) + assert.equal( + JSON.stringify(capturedArgs).includes('person_identifier'), + false, + '资产接口也不应读取历史 person_identifier 后再返回给行政或 IT' + ) assert.equal( serializedWhere.includes('job_number'), true, diff --git a/backend/src/controllers/__tests__/maintenanceController.concurrency.test.ts b/backend/src/controllers/__tests__/maintenanceController.concurrency.test.ts index 79e77b1..6374cfb 100644 --- a/backend/src/controllers/__tests__/maintenanceController.concurrency.test.ts +++ b/backend/src/controllers/__tests__/maintenanceController.concurrency.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import prisma from '../../utils/prisma' -import { completeMaintenance, createMaintenance } from '../maintenanceController' +import { completeMaintenance, createMaintenance, updateMaintenance } from '../maintenanceController' const createMockResponse = () => { const responseBody = { statusCode: 200, body: null as any } @@ -129,3 +129,65 @@ test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', asyn ;(prisma.$transaction as any) = originalTransaction } }) + +test('维修资料更新只命中 in_progress,且不再接受未落库的 notes 字段', async () => { + const originalUpdateMany = prisma.maintenanceRecord.updateMany + const originalFindUnique = prisma.maintenanceRecord.findUnique + let updateArgs: any = null + + ;(prisma.maintenanceRecord.updateMany as any) = async (args: any) => { + updateArgs = args + return { count: 1 } + } + ;(prisma.maintenanceRecord.findUnique as any) = async () => ({ + id: 20, + status: 'in_progress', + repair_vendor: '测试维修商' + }) + + try { + const { res, responseBody } = createMockResponse() + await updateMaintenance( + { + params: { id: '20' }, + body: { + repair_vendor: '测试维修商', + estimated_cost: 200, + notes: '这个字段不属于维修表,不能静默接收' + } + } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 200) + assert.deepEqual(updateArgs.where, { id: 20, status: 'in_progress' }) + assert.equal(updateArgs.data.repair_vendor, '测试维修商') + assert.equal(updateArgs.data.estimated_cost, 200) + assert.equal(Object.prototype.hasOwnProperty.call(updateArgs.data, 'notes'), false) + } finally { + ;(prisma.maintenanceRecord.updateMany as any) = originalUpdateMany + ;(prisma.maintenanceRecord.findUnique as any) = originalFindUnique + } +}) + +test('维修资料更新与完成并发时条件更新失败并返回409', async () => { + const originalUpdateMany = prisma.maintenanceRecord.updateMany + const originalFindUnique = prisma.maintenanceRecord.findUnique + + ;(prisma.maintenanceRecord.updateMany as any) = async () => ({ count: 0 }) + ;(prisma.maintenanceRecord.findUnique as any) = async () => ({ id: 20, status: 'completed' }) + + try { + const { res, responseBody } = createMockResponse() + await updateMaintenance( + { params: { id: '20' }, body: { repair_vendor: '并发写入' } } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 409) + assert.match(responseBody.body.message, /已被处理/) + } finally { + ;(prisma.maintenanceRecord.updateMany as any) = originalUpdateMany + ;(prisma.maintenanceRecord.findUnique as any) = originalFindUnique + } +}) diff --git a/backend/src/controllers/__tests__/personController.privacy.test.ts b/backend/src/controllers/__tests__/personController.privacy.test.ts new file mode 100644 index 0000000..7c20271 --- /dev/null +++ b/backend/src/controllers/__tests__/personController.privacy.test.ts @@ -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 + } +}) diff --git a/backend/src/controllers/__tests__/userController.admin-safety.test.ts b/backend/src/controllers/__tests__/userController.admin-safety.test.ts new file mode 100644 index 0000000..582faa5 --- /dev/null +++ b/backend/src/controllers/__tests__/userController.admin-safety.test.ts @@ -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) => { + 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/) +}) diff --git a/backend/src/controllers/batchController.ts b/backend/src/controllers/batchController.ts index cb5b767..51acfd2 100644 --- a/backend/src/controllers/batchController.ts +++ b/backend/src/controllers/batchController.ts @@ -3,10 +3,10 @@ import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' -import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment' import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' import { + borrowAssetTree, finalizeNormalReturnState, recordReturnToStorage, transferAssetTree @@ -21,83 +21,26 @@ const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => { : notes || undefined } -const getChildrenSnapshot = async (equipmentId: number) => { - const children = await prisma.equipment.findMany({ - where: { parent_id: equipmentId }, - select: { - id: true, - equipment_code: true, - equipment_name: true, - equipment_model: true, - equipment_type: true, - unit_price: true - } - }) - - return children.length > 0 ? JSON.stringify(children) : undefined -} - const batchBorrowTransaction = async ( - equipment: any, + equipmentId: number, person: any, expectedReturnTime?: string, isLongTerm?: boolean, notes?: string ) => { - const childrenSnapshot = await getChildrenSnapshot(equipment.id) const finalNotes = buildBorrowNotes(notes, isLongTerm) - - return prisma.$transaction(async (tx) => { - await tx.borrowRecord.create({ - data: { - equipment_id: equipment.id, - equipment_code: equipment.equipment_code, - person_id: person.id, - person_name: person.real_name, - person_nickname: person.nickname, - person_identifier: person.job_number, - expected_return_time: isLongTerm ? null : (expectedReturnTime ? new Date(expectedReturnTime) : null), - is_long_term: Boolean(isLongTerm), - notes: finalNotes, - status: '已借出', - children_snapshot: childrenSnapshot - } - }) - - const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0) - const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出' - const values = calculateEquipmentValues({ - unitPrice: Number(equipment.unit_price), - totalQuantity: Number(equipment.total_quantity), - remainingQuantity: nextRemainingQuantity, - status: nextStatus - }) - const updated = await tx.equipment.updateMany({ - where: { - id: equipment.id, - status: '可用', - remaining_quantity: equipment.remaining_quantity - }, - data: { - remaining_quantity: nextRemainingQuantity, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value, - status: nextStatus - } - }) - - if (updated.count !== 1) { - throw new Error('资产库存已变化,请刷新后重试') - } - - await syncMountedChildrenState( - tx, - equipment.id, - '已借出', - person.main_department_id ? { departmentId: person.main_department_id } : {} - ) - }) + return prisma.$transaction( + (tx) => borrowAssetTree(tx, { + rootEquipmentId: equipmentId, + person, + expectedReturnTime: isLongTerm + ? null + : (expectedReturnTime ? new Date(expectedReturnTime) : null), + isLongTerm, + notes: finalNotes + }), + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted } + ) } const batchReturnTransaction = async ( @@ -182,7 +125,7 @@ export const batchBorrow = async (req: Request, res: Response) => { } await batchBorrowTransaction( - equipment, + equipment.id, person, expected_return_time, is_long_term, diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index 64dc40e..dfe58f6 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -1,24 +1,24 @@ import { Request, Response } from 'express' +import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { - calculateEquipmentValues, - getDisplayEquipmentValue, - syncMountedChildrenState + getDisplayEquipmentValue } from '../utils/assetValue' import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly' -import { - assertReturnStorageDepartmentContract, - resolveBorrowDepartmentUpdate -} from '../utils/borrowDepartment' +import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment' import { buildParentAssetFirstOrderBy, sortParentAssetsFirst } from '../utils/equipmentSearchPriority' import { logOperation } from './operationLogController' import { + buildRecursiveBorrowSnapshot, + borrowAssetTree, finalizeBorrowAnomalyReturnState, finalizeNormalReturnState, + loadAssetTree, + registerBorrowAnomalyAssetTree, recordReturnToStorage } from '../services/assetMovementService' @@ -206,93 +206,23 @@ const appendLongTermNotes = (notes?: string, isLongTerm?: boolean) => { : notes || undefined } -const getChildrenSnapshot = async (equipmentId: number) => { - const children = await prisma.equipment.findMany({ - where: { parent_id: equipmentId }, - select: { - id: true, - equipment_code: true, - equipment_name: true, - equipment_model: true, - equipment_type: true, - unit_price: true - } - }) - - return { - children, - snapshot: children.length > 0 ? JSON.stringify(children) : undefined - } -} - const borrowEquipmentInTransaction = async ( - equipment: any, + equipmentId: number, person: any, expectedReturn: Date | null, combinedNotes?: string, - isLongTerm?: boolean, - childrenSnapshot?: string + isLongTerm?: boolean ) => { - return prisma.$transaction(async (tx) => { - const record = await tx.borrowRecord.create({ - data: { - equipment_id: equipment.id, - equipment_code: equipment.equipment_code, - person_id: person.id, - person_name: person.real_name, - person_nickname: person.nickname, - person_identifier: person.job_number, - expected_return_time: expectedReturn, - is_long_term: Boolean(isLongTerm), - notes: combinedNotes, - status: '已借出', - children_snapshot: childrenSnapshot - } - }) - - const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0) - const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出' - const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id) - const values = calculateEquipmentValues({ - unitPrice: Number(equipment.unit_price), - totalQuantity: Number(equipment.total_quantity), - remainingQuantity: nextRemainingQuantity, - status: nextStatus - }) - const updateResult = await tx.equipment.updateMany({ - where: { - id: equipment.id, - status: '可用', - remaining_quantity: equipment.remaining_quantity - }, - data: { - remaining_quantity: nextRemainingQuantity, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value, - status: nextStatus, - ...(departmentUpdateData || {}) - } - }) - - if (updateResult.count !== 1) { - throw new Error('资产库存已变化,请刷新后重试') - } - - await syncMountedChildrenState( - tx, - equipment.id, - '已借出', - departmentUpdateData ? { departmentId: departmentUpdateData.department_id } : {} - ) - - const updatedEquipment = await tx.equipment.findUnique({ - where: { id: equipment.id }, - include: equipmentInclude - }) - - return { record, updatedEquipment } - }) + return prisma.$transaction( + (tx) => borrowAssetTree(tx, { + rootEquipmentId: equipmentId, + person, + expectedReturnTime: expectedReturn, + isLongTerm, + notes: combinedNotes + }), + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted } + ) } const findActiveBorrowRecord = async ( @@ -343,17 +273,11 @@ const getChildrenDiff = async (equipmentId: number, borrowRecord: any) => { } const borrowedChildren = JSON.parse(borrowRecord.children_snapshot) as any[] - const currentChildren = await prisma.equipment.findMany({ - where: { parent_id: equipmentId }, - select: { - id: true, - equipment_code: true, - equipment_name: true, - equipment_model: true, - equipment_type: true, - unit_price: true - } - }) + const currentTree = await prisma.$transaction( + (tx) => loadAssetTree(tx, equipmentId), + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted } + ) + const currentChildren = buildRecursiveBorrowSnapshot(currentTree) const borrowedIds = new Set(borrowedChildren.map((child) => child.id)) const currentIds = new Set(currentChildren.map((child) => child.id)) @@ -434,81 +358,26 @@ const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any, operate } const registerBorrowAnomalyInTransaction = async ( - equipment: any, + equipmentId: number, person: any, borrowTime: Date, notes?: string ) => { - return prisma.$transaction(async (tx) => { - const { children, snapshot } = await getChildrenSnapshot(equipment.id) - const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id) - const nextRemainingQuantity = Math.max(Number(equipment.total_quantity) - 1, 0) - const values = calculateEquipmentValues({ - unitPrice: Number(equipment.unit_price), - totalQuantity: Number(equipment.total_quantity), - remainingQuantity: nextRemainingQuantity, - status: '已借出' - }) - - const record = await tx.borrowRecord.create({ - data: { - equipment_id: equipment.id, - equipment_code: equipment.equipment_code, - person_id: person.id, - person_name: person.real_name, - person_nickname: person.nickname, - person_identifier: person.job_number, - borrow_time: borrowTime, - notes: notes?.trim() || null, - status: '已借出', - children_snapshot: snapshot - } - }) - - const updated = await tx.equipment.updateMany({ - where: { - id: equipment.id, - parent_id: equipment.parent_id, - department_id: equipment.department_id, - status: equipment.status, - mount_status: equipment.mount_status, - total_quantity: equipment.total_quantity, - remaining_quantity: equipment.remaining_quantity, - unit_price: equipment.unit_price, - updated_at: equipment.updated_at - }, - data: { - status: '已借出', - remaining_quantity: nextRemainingQuantity, - total_value: values.total_value, - borrowed_value: values.borrowed_value, - available_value: values.available_value, - ...(departmentUpdateData || {}) - } - }) - if (updated.count !== 1) { - throw new Error('资产状态已变化,请刷新后重试') - } - - const updatedEquipment = await tx.equipment.findUnique({ - where: { id: equipment.id }, - include: equipmentInclude - }) - if (!updatedEquipment) throw new Error('资产不存在') - - await syncMountedChildrenState( - tx, - equipment.id, - '已借出', - departmentUpdateData ? { departmentId: departmentUpdateData.department_id } : {} - ) - - return { - record, - updatedEquipment, - childrenCount: children.length - } + const result = await prisma.$transaction( + (tx) => registerBorrowAnomalyAssetTree(tx, { + rootEquipmentId: equipmentId, + person, + borrowTime, + notes + }), + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted } + ) + const updatedEquipment = await prisma.equipment.findUnique({ + where: { id: result.updatedRoot.id }, + include: equipmentInclude }) + if (!updatedEquipment) throw new Error('资产不存在') + return { ...result, updatedEquipment } } const mapBorrowRecord = (record: any) => ({ @@ -524,10 +393,8 @@ const mapBorrowRecord = (record: any) => ({ nickname: record.person.nickname, real_name: record.person.real_name, job_number: record.person.job_number, - id_number: record.person.id_number, department: record.person.mainDepartment?.name || record.person.department, main_department: record.person.mainDepartment || null, - phone: record.person.phone, status: record.person.status } : { @@ -538,7 +405,6 @@ const mapBorrowRecord = (record: any) => ({ person_id: record.person_id, person_name: record.person_name, person_nickname: record.person_nickname, - person_identifier: record.person_identifier, borrow_time: record.borrow_time, expected_return_time: record.expected_return_time, is_long_term: record.is_long_term, @@ -582,14 +448,12 @@ export const borrowEquipment = async (req: Request, res: Response) => { const longTerm = Boolean(is_long_term) const expectedReturn = longTerm ? null : parseDate(expected_return_time) const combinedNotes = appendLongTermNotes(notes, longTerm) - const { children, snapshot } = await getChildrenSnapshot(equipment.id) - const { record: borrowRecord } = await borrowEquipmentInTransaction( - equipment, + const { record: borrowRecord, childrenCount } = await borrowEquipmentInTransaction( + equipment.id, person, expectedReturn, combinedNotes, - longTerm, - snapshot + longTerm ) successResponse( @@ -597,7 +461,7 @@ export const borrowEquipment = async (req: Request, res: Response) => { { id: borrowRecord.id, borrow_time: borrowRecord.borrow_time, - children_count: children.length + children_count: childrenCount }, '借用成功' ) @@ -608,7 +472,7 @@ export const borrowEquipment = async (req: Request, res: Response) => { body: req.body, user: res.locals.user }) - errorResponse(res, error.message || '借用资产失败') + errorResponse(res, error.message || '借用资产失败', error.status || 500) } } @@ -638,14 +502,12 @@ export const scanBorrow = async (req: Request, res: Response) => { const longTerm = Boolean(is_long_term) const expectedReturn = longTerm ? null : parseDate(expected_return_time) const combinedNotes = appendLongTermNotes(notes, longTerm) - const { children, snapshot } = await getChildrenSnapshot(equipment.id) - const { record: borrowRecord, updatedEquipment } = await borrowEquipmentInTransaction( - equipment, + const { record: borrowRecord, updatedRoot, childrenCount } = await borrowEquipmentInTransaction( + equipment.id, person, expectedReturn, combinedNotes, - longTerm, - snapshot + longTerm ) successResponse( @@ -656,11 +518,11 @@ export const scanBorrow = async (req: Request, res: Response) => { equipment_name: equipment.equipment_name, borrow_time: borrowRecord.borrow_time, status: '已借出', - remaining_quantity: updatedEquipment?.remaining_quantity ?? 0, - equipment_status: updatedEquipment?.status ?? '已借出', + remaining_quantity: updatedRoot.remaining_quantity, + equipment_status: updatedRoot.status, storage_location_id: equipment.storage_location_id, storageLocation: equipment.storageLocation, - children_count: children.length + children_count: childrenCount }, '出库成功' ) @@ -671,7 +533,7 @@ export const scanBorrow = async (req: Request, res: Response) => { body: req.body, user: res.locals.user }) - errorResponse(res, error.message || '出库失败') + errorResponse(res, error.message || '出库失败', error.status || 500) } } @@ -721,14 +583,12 @@ export const scanBorrowBatch = async (req: Request, res: Response) => { continue } - const { snapshot } = await getChildrenSnapshot(equipment.id) await borrowEquipmentInTransaction( - equipment, + equipment.id, person, expectedReturn, combinedNotes, - longTerm, - snapshot + longTerm ) results.push({ equipment_code: code, status: 'success', message: '出库成功' }) @@ -1126,8 +986,7 @@ export const getBorrowRecordList = async (req: Request, res: Response) => { filters.push({ OR: [ { person_name: { contains: kw } }, - { person_nickname: { contains: kw } }, - { person_identifier: { contains: kw } } + { person_nickname: { contains: kw } } ] }) } @@ -1250,7 +1109,6 @@ export const searchReturnAssets = async (req: Request, res: Response) => { id: true, person_name: true, person_nickname: true, - person_identifier: true, person: { select: { department: true, @@ -1276,17 +1134,14 @@ export const searchReturnAssets = async (req: Request, res: Response) => { const list = sortParentAssetsFirst(equipmentList).map((equipment) => { const borrowerNames: string[] = [] - const borrowerIdentifiers: string[] = [] const borrowerDepartments: string[] = [] for (const record of equipment.borrow_records) { const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ') - const borrowerIdentifier = record.person_identifier || null const borrowerDepartment = record.person?.mainDepartment?.name || record.person?.department || null pushUnique(borrowerNames, borrowerName) - pushUnique(borrowerIdentifiers, borrowerIdentifier) pushUnique(borrowerDepartments, borrowerDepartment) } @@ -1304,7 +1159,6 @@ export const searchReturnAssets = async (req: Request, res: Response) => { storageLocation: equipment.storageLocation, active_borrow_count: activeBorrowCount, borrower_names: borrowerNames, - borrower_identifiers: borrowerIdentifiers, borrower_departments: borrowerDepartments, can_return: activeBorrowCount > 0, can_handle_borrow_anomaly: anomalyMeta.canHandle, @@ -1399,7 +1253,7 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => { } const { record, updatedEquipment, childrenCount } = await registerBorrowAnomalyInTransaction( - equipment, + equipment.id, person, borrowTime, typeof notes === 'string' ? notes : undefined @@ -1431,6 +1285,6 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => { '已补登记借出记录' ) } catch (error: any) { - errorResponse(res, error.message || '补登记借出失败') + errorResponse(res, error.message || '补登记借出失败', error.status || 500) } } diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index fefe41f..3a35cb0 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -202,6 +202,18 @@ const getCurrentUser = (res: Response) => ({ role: String(res.locals.user?.role || '') }) +const withHistoricalDisposalNotes = (record: T) => ({ + ...record, + historical_notes: + record.status === 'completed' && record.notes && !record.completion_notes + ? record.notes + : null +}) + export const createDisposal = async (req: Request, res: Response) => { try { const { @@ -382,39 +394,48 @@ export const getDisposalList = async (req: Request, res: Response) => { : currentUser.role === 'administrative' ? 'pending' : 'mine')) - const where: Prisma.DisposalRecordWhereInput = {} + const validScopes = ['all', 'mine', 'pending', 'processed'] + if (!validScopes.includes(resolvedScope)) { + return errorResponse(res, '报废列表范围无效', 422) + } + const scopeConditions: Prisma.DisposalRecordWhereInput[] = [] if (currentUser.role === 'it') { scopeConditions.push({ applied_by: currentUser.id }) - } else if (resolvedScope === 'mine') { - scopeConditions.push({ applied_by: currentUser.id }) - } else if (resolvedScope === 'pending') { - scopeConditions.push({ status: 'pending' }) - } else if (resolvedScope === 'processed') { - scopeConditions.push({ - OR: [ - { approved_by: currentUser.id }, - { completed_by: currentUser.id } - ] - }) - } else if (resolvedScope === 'all' && currentUser.role !== 'admin') { - return errorResponse(res, '只有管理员可以查看全部报废流程', 403) - } else if (!['all', 'mine', 'pending', 'processed'].includes(resolvedScope)) { - return errorResponse(res, '报废列表范围无效', 422) + } else if (currentUser.role === 'administrative') { + if (resolvedScope === 'pending') { + scopeConditions.push({ status: 'pending' }) + } else if (resolvedScope === 'processed') { + scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } }) + } else { + return errorResponse(res, '行政仅可查看全部待审批和全部已处理报废流程', 403) + } + } else if (currentUser.role === 'admin') { + if (resolvedScope === 'mine') { + scopeConditions.push({ applied_by: currentUser.id }) + } else if (resolvedScope === 'pending') { + scopeConditions.push({ status: 'pending' }) + } else if (resolvedScope === 'processed') { + scopeConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } }) + } + } else { + return errorResponse(res, '无权查看报废流程', 403) } - if (scopeConditions.length > 0) where.AND = scopeConditions - if (status) where.status = String(status) - if (equipment_id) where.equipment_id = Number(equipment_id) + if (status) scopeConditions.push({ status: String(status) }) + if (equipment_id) scopeConditions.push({ equipment_id: Number(equipment_id) }) if (keyword) { const keywordWhere = [ { equipment_code: { contains: String(keyword) } }, { disposal_reason: { contains: String(keyword) } }, { items: { some: { equipment_code: { contains: String(keyword) } } } } ] - where.AND = [...(Array.isArray(where.AND) ? where.AND : []), { OR: keywordWhere }] + scopeConditions.push({ OR: keywordWhere }) } + const where: Prisma.DisposalRecordWhereInput = scopeConditions.length > 0 + ? { AND: scopeConditions } + : {} const skip = (Number(page) - 1) * Number(pageSize) const [list, total] = await Promise.all([ @@ -433,7 +454,11 @@ export const getDisposalList = async (req: Request, res: Response) => { }), prisma.disposalRecord.count({ where }) ]) - successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功') + successResponse( + res, + { list: list.map(withHistoricalDisposalNotes), total, page: Number(page), pageSize: Number(pageSize) }, + '获取成功' + ) } catch (error: any) { errorResponse(res, error.message || '获取报废记录列表失败') } @@ -450,7 +475,7 @@ export const getDisposalDetail = async (req: Request, res: Response) => { if (currentUser.role === 'it' && disposal.applied_by !== currentUser.id) { return errorResponse(res, '无权查看该报废申请', 403) } - successResponse(res, disposal, '获取成功') + successResponse(res, withHistoricalDisposalNotes(disposal), '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取报废记录详情失败') } diff --git a/backend/src/controllers/equipmentController.ts b/backend/src/controllers/equipmentController.ts index 0c82d5e..8ed74c4 100644 --- a/backend/src/controllers/equipmentController.ts +++ b/backend/src/controllers/equipmentController.ts @@ -76,7 +76,6 @@ const activeBorrowSelect = { person_id: true, person_name: true, person_nickname: true, - person_identifier: true, borrow_time: true, status: true, person: { @@ -84,8 +83,7 @@ const activeBorrowSelect = { id: true, nickname: true, real_name: true, - job_number: true, - id_number: true + job_number: true } } } @@ -226,7 +224,6 @@ const mapCurrentBorrow = (borrowRecords: any[] | undefined) => { person_id: record.person_id, person_name: record.person_name, person_nickname: record.person_nickname, - person_identifier: record.person_identifier, borrow_time: record.borrow_time, status: record.status, person: record.person @@ -234,8 +231,7 @@ const mapCurrentBorrow = (borrowRecords: any[] | undefined) => { id: record.person.id, nickname: record.person.nickname, real_name: record.person.real_name, - job_number: record.person.job_number, - id_number: record.person.id_number + job_number: record.person.job_number } : null } diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index f7ddd93..0d5d6a0 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -319,7 +319,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { where.OR = [ { person_name: { contains: String(person_name) } }, { person_nickname: { contains: String(person_name) } }, - { person_identifier: { contains: String(person_name) } } + { person: { is: { job_number: { contains: String(person_name) } } } } ] } if (start_date || end_date) { @@ -356,7 +356,6 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { { header: '资产名称', key: 'equipment_name', width: 25 }, { header: '使用者', key: 'person_name', width: 15 }, { header: '使用者昵称', key: 'person_nickname', width: 15 }, - { header: '借用时工号', key: 'person_identifier', width: 18 }, { header: '当前工号', key: 'current_job_number', width: 18 }, { header: '部门', key: 'department', width: 15 }, { header: '借用时间', key: 'borrow_time', width: 20 }, @@ -377,7 +376,6 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { equipment_name: record.equipment?.equipment_name || '', person_name: record.person_name, person_nickname: record.person_nickname, - person_identifier: record.person_identifier || '', current_job_number: record.person?.job_number || '', department: record.person?.mainDepartment?.name || record.person?.department || '', borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '', diff --git a/backend/src/controllers/maintenanceController.ts b/backend/src/controllers/maintenanceController.ts index 6c346c0..5d78126 100644 --- a/backend/src/controllers/maintenanceController.ts +++ b/backend/src/controllers/maintenanceController.ts @@ -192,32 +192,39 @@ export const getMaintenanceDetail = async (req: Request, res: Response) => { export const updateMaintenance = async (req: Request, res: Response) => { try { const { id } = req.params - const { repair_vendor, estimated_cost, expected_end_date, notes } = req.body + const { repair_vendor, estimated_cost, expected_end_date } = req.body + const maintenanceId = Number(id) - const maintenance = await prisma.maintenanceRecord.findUnique({ - where: { id: Number(id) } - }) - - if (!maintenance) { - return errorResponse(res, '维修记录不存在', 404) + if (!Number.isInteger(maintenanceId) || maintenanceId <= 0) { + return errorResponse(res, '维修记录ID无效', 422) } - if (maintenance.status !== 'in_progress') { - return errorResponse(res, '只能更新进行中的维修记录') - } - - const updated = await prisma.maintenanceRecord.update({ - where: { id: Number(id) }, + const result = await prisma.maintenanceRecord.updateMany({ + where: { id: maintenanceId, status: 'in_progress' }, data: { repair_vendor, estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : undefined, expected_end_date: expected_end_date ? new Date(expected_end_date) : undefined } }) + if (result.count !== 1) { + const current = await prisma.maintenanceRecord.findUnique({ + where: { id: maintenanceId }, + select: { id: true, status: true } + }) + if (!current) { + return errorResponse(res, '维修记录不存在', 404) + } + throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看') + } + + const updated = await prisma.maintenanceRecord.findUnique({ + where: { id: maintenanceId } + }) successResponse(res, updated, '更新成功') } catch (error: any) { - errorResponse(res, error.message || '更新维修记录失败') + errorResponse(res, error.message || '更新维修记录失败', error.status || 500) } } diff --git a/backend/src/controllers/personController.ts b/backend/src/controllers/personController.ts index 9548697..706a2f4 100644 --- a/backend/src/controllers/personController.ts +++ b/backend/src/controllers/personController.ts @@ -97,6 +97,10 @@ const sanitizePerson = (person: any) => { department_sync_bucket: person.department_sync_bucket, manager_source_userid: person.manager_source_userid, source_employee_status: person.source_employee_status, + dingtalk_ready: Boolean( + person.status === 'active' && + person.source_userid?.trim() + ), last_synced_at: person.last_synced_at, is_synced: Boolean(person.source_system), created_at: person.created_at, @@ -104,6 +108,43 @@ const sanitizePerson = (person: any) => { } } +const sanitizeBusinessPerson = (person: any) => { + const departments = collectDepartments(person) + const mainDepartment = person.mainDepartment || departments[0] || null + + return { + id: person.id, + nickname: person.nickname, + real_name: person.real_name, + job_number: person.job_number, + department: mainDepartment?.name || person.department || null, + main_department: mainDepartment + ? { + id: mainDepartment.id, + name: mainDepartment.name, + status: mainDepartment.status + } + : null, + departments: departments.map((department) => ({ + id: department.id, + name: department.name, + status: department.status + })), + department_names: departments.map((department) => department.name), + position: person.position, + status: person.status, + dingtalk_ready: Boolean( + person.status === 'active' && + person.source_userid?.trim() + ) + } +} + +const parsePositiveInteger = (value: unknown, fallback: number) => { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + const buildPersonWhere = (keyword?: unknown, status?: unknown) => { const where: any = {} @@ -465,15 +506,30 @@ export const deletePerson = async (req: Request, res: Response) => { export const getPersonOptions = async (req: Request, res: Response) => { try { const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true' - const persons = await prisma.person.findMany({ - where: includeInactive ? undefined : { status: 'active' }, + const page = parsePositiveInteger(req.query.page, 1) + const requestedPageSize = req.query.page_size ?? req.query.pageSize + const pageSize = Math.min(parsePositiveInteger(requestedPageSize, 50), 100) + const status = includeInactive ? undefined : (req.query.status || 'active') + const where = buildPersonWhere(req.query.keyword, status) + const [persons, total] = await Promise.all([ + prisma.person.findMany({ + where, + skip: (page - 1) * pageSize, + take: pageSize, orderBy: includeInactive ? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }] : [{ nickname: 'asc' }, { real_name: 'asc' }], include: personInclude - }) + }), + prisma.person.count({ where }) + ]) - successResponse(res, persons.map(sanitizePerson), '获取成功') + successResponse(res, { + list: persons.map(sanitizeBusinessPerson), + total, + page, + page_size: pageSize + }, '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取使用者选项失败') } diff --git a/backend/src/controllers/printController.ts b/backend/src/controllers/printController.ts index cc9baf3..a0888d2 100644 --- a/backend/src/controllers/printController.ts +++ b/backend/src/controllers/printController.ts @@ -5,6 +5,7 @@ import { createAssetLabelPrintJob as createAssetLabelPrintJobService, createPrintTerminalPairCode as createPrintTerminalPairCodeService, heartbeatPrintTerminalAgent, + getDefaultPrintTerminalReadiness, isPrintServiceError, listPrintJobs, listPrintTerminals, @@ -53,6 +54,15 @@ export const getPrintTerminals = async (req: Request, res: Response) => { } } +export const getPrintReadiness = async (_req: Request, res: Response) => { + try { + const terminal = await getDefaultPrintTerminalReadiness() + successResponse(res, { terminal }, '获取打印就绪状态成功') + } catch (error) { + handlePrintServiceError(res, error, '获取打印就绪状态失败') + } +} + export const createPrintTerminalPairCode = async (req: Request, res: Response) => { const userId = getCurrentUserId(res) if (!userId) { @@ -142,9 +152,15 @@ export const cancelPrintJob = async (req: Request, res: Response) => { if (!jobId) { return errorResponse(res, '打印任务编号无效', 422) } + const userId = getCurrentUserId(res) + if (!userId) { + return errorResponse(res, '当前登录用户无效', 401) + } + const canManageAll = Array.isArray(res.locals.user?.permissions) && + res.locals.user.permissions.includes('system.settings') try { - await cancelQueuedPrintJob(jobId) + await cancelQueuedPrintJob(jobId, userId, canManageAll) successResponse(res, null, '打印任务已取消') } catch (error) { handlePrintServiceError(res, error, '取消打印任务失败') diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index 66dbadf..58b657c 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -1,10 +1,12 @@ import { Request, Response } from 'express' import bcrypt from 'bcryptjs' +import type { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { getPermissionsForRole, isUserRole } from '../utils/permissions' const SALT_ROUNDS = 10 +const USER_STATUSES = ['active', 'inactive'] as const class UserValidationError extends Error { constructor(message: string, public readonly status = 422) { @@ -66,6 +68,38 @@ const normalizePersonId = (value: unknown) => { return personId } +const normalizeUserStatus = (value: unknown, fallback?: string) => { + const status = value === undefined || value === null || value === '' + ? fallback + : String(value).trim() + if (!status || !USER_STATUSES.includes(status as (typeof USER_STATUSES)[number])) { + throw new UserValidationError('状态必须是 active 或 inactive') + } + return status +} + +const getCurrentUserId = (res: Response) => { + const userId = Number((res as any).locals?.user?.id) + return Number.isInteger(userId) && userId > 0 ? userId : null +} + +const lockActiveAdministrators = async (tx: Prisma.TransactionClient) => { + return tx.$queryRawUnsafe>( + "SELECT id FROM `user` WHERE role = 'admin' AND status = 'active' ORDER BY id FOR UPDATE" + ) +} + +const assertActiveAdministratorRemains = async ( + tx: Prisma.TransactionClient, + targetUserId: number +) => { + const activeAdministrators = await lockActiveAdministrators(tx) + const targetIsActiveAdministrator = activeAdministrators.some((item) => Number(item.id) === targetUserId) + if (targetIsActiveAdministrator && activeAdministrators.length <= 1) { + throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409) + } +} + const ensurePersonCanBind = async (personId: number, excludeUserId?: number) => { const person = await prisma.person.findUnique({ where: { id: personId } }) if (!person) { @@ -176,6 +210,7 @@ export const createUser = async (req: Request, res: Response) => { if (!isUserRole(primaryRole)) { return errorResponse(res, '角色必须是 admin、administrative、it 或 user', 422) } + const normalizedStatus = normalizeUserStatus(status, 'active') const personId = normalizePersonId(person_id) if (typeof personId === 'number') { @@ -192,7 +227,7 @@ export const createUser = async (req: Request, res: Response) => { email, phone, role: primaryRole, - status: status || 'active', + status: normalizedStatus, person_id: personId ?? null }, include: { person: { select: personSelect } } @@ -228,8 +263,18 @@ export const updateUser = async (req: Request, res: Response) => { updateData.role = newRole } - if (status) { - updateData.status = status + if (status !== undefined) { + updateData.status = normalizeUserStatus(status) + } + + const currentUserId = getCurrentUserId(res) + if (currentUserId === user.id) { + if (newRole && newRole !== 'admin') { + return errorResponse(res, '不能降低当前登录管理员自己的角色', 422) + } + if (updateData.status && updateData.status !== 'active') { + return errorResponse(res, '不能停用当前登录管理员自己的账户', 422) + } } const personId = normalizePersonId(person_id) @@ -240,11 +285,34 @@ export const updateUser = async (req: Request, res: Response) => { updateData.person_id = null } - const updated = await prisma.user.update({ - where: { id: Number(id) }, - data: updateData, - include: { person: { select: personSelect } } - }) + const changesRoleOrStatus = Boolean(updateData.role || updateData.status) + + const updated = changesRoleOrStatus + ? await prisma.$transaction(async (tx) => { + const activeAdministrators = await lockActiveAdministrators(tx) + const current = await tx.user.findUnique({ where: { id: user.id } }) + if (!current) { + throw new UserValidationError('用户不存在', 404) + } + const finalRole = updateData.role || current.role + const finalStatus = updateData.status || current.status + const removesActiveAdministrator = activeAdministrators.some( + (item) => Number(item.id) === current.id + ) && (finalRole !== 'admin' || finalStatus !== 'active') + if (removesActiveAdministrator && activeAdministrators.length <= 1) { + throw new UserValidationError('系统必须至少保留一个启用的管理员账户', 409) + } + return tx.user.update({ + where: { id: current.id }, + data: updateData, + include: { person: { select: personSelect } } + }) + }) + : await prisma.user.update({ + where: { id: user.id }, + data: updateData, + include: { person: { select: personSelect } } + }) successResponse(res, sanitizeUser(updated), '更新成功') } catch (error: any) { @@ -284,24 +352,33 @@ export const updateUserStatus = async (req: Request, res: Response) => { const { id } = req.params const { status } = req.body as { status?: string } - if (!status) { - return errorResponse(res, '状态不能为空', 422) - } + const normalizedStatus = normalizeUserStatus(status) const user = await prisma.user.findUnique({ where: { id: Number(id) } }) if (!user) { return errorResponse(res, '用户不存在', 404) } - const updated = await prisma.user.update({ - where: { id: Number(id) }, - data: { status }, - include: { person: { select: personSelect } } + if (getCurrentUserId(res) === user.id && normalizedStatus !== 'active') { + return errorResponse(res, '不能停用当前登录管理员自己的账户', 422) + } + + const updated = await prisma.$transaction(async (tx) => { + if (normalizedStatus !== 'active') { + await assertActiveAdministratorRemains(tx, user.id) + } else { + await lockActiveAdministrators(tx) + } + return tx.user.update({ + where: { id: user.id }, + data: { status: normalizedStatus }, + include: { person: { select: personSelect } } + }) }) successResponse(res, sanitizeUser(updated), '状态更新成功') } catch (error: any) { - errorResponse(res, error.message || '更新用户状态失败') + errorResponse(res, error.message || '更新用户状态失败', error.status || 500) } } @@ -311,6 +388,10 @@ export const deleteUser = async (req: Request, res: Response) => { const { id } = req.params const userId = Number(id) + if (getCurrentUserId(res) === userId) { + return errorResponse(res, '不能删除当前登录账户', 422) + } + const user = await prisma.user.findUnique({ where: { id: userId }, include: { @@ -333,11 +414,6 @@ export const deleteUser = async (req: Request, res: Response) => { return errorResponse(res, '用户不存在', 404) } - // 不允许删除 admin 用户 - if (user.username === 'admin') { - return errorResponse(res, '不能删除管理员账户') - } - if ( user._count.site_configs > 0 || user._count.mount_histories > 0 || @@ -353,6 +429,7 @@ export const deleteUser = async (req: Request, res: Response) => { } await prisma.$transaction(async (tx) => { + await assertActiveAdministratorRemains(tx, userId) await tx.operationLog.deleteMany({ where: { user_id: userId } }) @@ -362,6 +439,6 @@ export const deleteUser = async (req: Request, res: Response) => { successResponse(res, null, '删除成功') } catch (error: any) { - errorResponse(res, error.message || '删除用户失败') + errorResponse(res, error.message || '删除用户失败', error.status || 500) } } diff --git a/backend/src/index.ts b/backend/src/index.ts index 24b4171..f4ebbdb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,7 @@ +import 'dotenv/config' import path from 'path' import express from 'express' import cors from 'cors' -import dotenv from 'dotenv' import session from 'express-session' import equipmentRoutes from './routes/equipment' import borrowRoutes, { returnRouter } from './routes/borrow' @@ -33,19 +33,18 @@ import printDownloadRoutes from './routes/printDownloads' import myAssetRoutes from './routes/myAssets' import dingtalkRoutes from './routes/dingtalk' import { keycloak, memoryStore } from './utils/keycloak' -import { protect, requirePermission } from './middleware/auth' -import { PERMISSIONS } from './utils/permissions' +import { protect } from './middleware/auth' import { startHrSyncScheduler } from './services/hrSyncService' import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService' import { startDingTalkDeliveryWorker } from './services/dingtalkDeliveryService' import { isCorsOriginAllowed } from './utils/corsPolicy' import { unsafeOriginGuard } from './middleware/originGuard' - -dotenv.config() +import { assertSupportedAuthMode, resolveSessionSecret } from './utils/authMode' const app = express() const PORT = process.env.PORT || 3000 -const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secret' +const AUTH_MODE = assertSupportedAuthMode() +const SESSION_SECRET = resolveSessionSecret() const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === undefined ? process.env.NODE_ENV === 'production' : process.env.SESSION_COOKIE_SECURE === 'true' @@ -73,7 +72,7 @@ app.use( } }) ) -if (process.env.AUTH_MODE !== 'local') { +if (AUTH_MODE === 'keycloak') { app.use(keycloak.middleware({ logout: '/api/auth/logout', admin: '/keycloak' })) } @@ -104,8 +103,8 @@ app.use('/api/sync', syncRoutes) app.use('/api/notifications', notificationRoutes) app.use('/api/inventory', inventoryRoutes) app.use('/api/public/inventory', publicInventoryRoutes) -app.use('/api/print-terminals', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), printTerminalRoutes) -app.use('/api/print-jobs', protect(), requirePermission(PERMISSIONS.SYSTEM_SETTINGS), printJobRoutes) +app.use('/api/print-terminals', protect(), printTerminalRoutes) +app.use('/api/print-jobs', protect(), printJobRoutes) app.use('/api/print-agent', printAgentRoutes) app.use('/api/my-assets', myAssetRoutes) app.use('/api/dingtalk', dingtalkRoutes) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 3cad3e8..1102d3b 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -7,8 +7,9 @@ import { } from '../utils/authIdentity' import { PERMISSIONS, type Permission } from '../utils/permissions' import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService' +import { resolveAuthMode } from '../utils/authMode' -const isLocalAuth = () => process.env.AUTH_MODE === 'local' +const isLocalAuth = () => resolveAuthMode() === 'local' const hasRealBearerToken = (req: Request) => { const authorization = req.headers.authorization diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 4a80fa6..9eab895 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -6,6 +6,7 @@ import bcrypt from 'bcryptjs' import prisma from '../utils/prisma' import { protect } from '../middleware/auth' import { resolveAuthUserById } from '../utils/authIdentity' +import { resolveAuthMode } from '../utils/authMode' const router = Router() @@ -14,7 +15,7 @@ router.get('/user', protect(), (_req, res) => successResponse(res, res.locals.us // Keycloak 配置信息(前端使用) router.get('/config', (req, res) => { - const authMode = process.env.AUTH_MODE === 'keycloak' ? 'keycloak' : 'local' + const authMode = resolveAuthMode() successResponse(res, { authMode, url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '', @@ -47,7 +48,7 @@ router.post('/logout', async (req, res) => { // 本地登录模式(不跳转 Keycloak) router.post('/local/login', async (req: Request, res: Response) => { try { - if (process.env.AUTH_MODE !== 'local') { + if (resolveAuthMode() !== 'local') { return errorResponse(res, '本地登录未启用', 400) } const { username, password } = req.body as { username?: string; password?: string } diff --git a/backend/src/routes/persons.ts b/backend/src/routes/persons.ts index e49edcc..4695a0e 100644 --- a/backend/src/routes/persons.ts +++ b/backend/src/routes/persons.ts @@ -15,9 +15,9 @@ const router = Router() router.use(protect()) -router.get('/', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonList) +router.get('/', requirePermission(PERMISSIONS.USERS_MANAGE), getPersonList) router.get('/options', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonOptions) -router.get('/:id', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getPersonDetail) +router.get('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), getPersonDetail) router.post('/', requirePermission(PERMISSIONS.USERS_MANAGE), createPerson) router.put('/:id', requirePermission(PERMISSIONS.USERS_MANAGE), updatePerson) diff --git a/backend/src/routes/printJobs.ts b/backend/src/routes/printJobs.ts index 5a85885..51838fc 100644 --- a/backend/src/routes/printJobs.ts +++ b/backend/src/routes/printJobs.ts @@ -4,11 +4,13 @@ import { createAssetLabelPrintJob, getPrintJobs } from '../controllers/printController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.post('/asset-labels', createAssetLabelPrintJob) -router.get('/', getPrintJobs) -router.patch('/:id/cancel', cancelPrintJob) +router.post('/asset-labels', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), createAssetLabelPrintJob) +router.get('/', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getPrintJobs) +router.patch('/:id/cancel', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), cancelPrintJob) export default router diff --git a/backend/src/routes/printTerminals.ts b/backend/src/routes/printTerminals.ts index e2d619e..56fedfb 100644 --- a/backend/src/routes/printTerminals.ts +++ b/backend/src/routes/printTerminals.ts @@ -1,16 +1,20 @@ import { Router } from 'express' import { createPrintTerminalPairCode, + getPrintReadiness, getPrintTerminals, setDefaultPrintTerminal, updatePrintTerminalStatus } from '../controllers/printController' +import { requirePermission } from '../middleware/auth' +import { PERMISSIONS } from '../utils/permissions' const router = Router() -router.get('/', getPrintTerminals) -router.post('/pair-codes', createPrintTerminalPairCode) -router.patch('/:id/default', setDefaultPrintTerminal) -router.patch('/:id/status', updatePrintTerminalStatus) +router.get('/readiness', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), getPrintReadiness) +router.get('/', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getPrintTerminals) +router.post('/pair-codes', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), createPrintTerminalPairCode) +router.patch('/:id/default', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), setDefaultPrintTerminal) +router.patch('/:id/status', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), updatePrintTerminalStatus) export default router diff --git a/backend/src/services/__tests__/assetMovementService.concurrency.test.ts b/backend/src/services/__tests__/assetMovementService.concurrency.test.ts index ed019a5..b8b8a9f 100644 --- a/backend/src/services/__tests__/assetMovementService.concurrency.test.ts +++ b/backend/src/services/__tests__/assetMovementService.concurrency.test.ts @@ -2,7 +2,9 @@ import test from 'node:test' import assert from 'node:assert/strict' import { + borrowAssetTree, finalizeBorrowAnomalyReturnState, + registerBorrowAnomalyAssetTree, transferAssetTree } from '../assetMovementService' @@ -153,3 +155,370 @@ test('异常归还在无活动借用时按锁后快照 CAS 恢复资产', async assert.equal(updates[0].data.remaining_quantity, 1) assert.equal(updates[0].data.department_id, null) }) + +const borrowPerson = { + id: 8, + real_name: '测试员工', + nickname: '员工', + job_number: 'E0008', + main_department_id: 9 +} + +const borrowRoot = { + ...rootEquipment, + equipment_model: 'M720', + equipment_type: '主机' +} + +test('借出时锁定递归资产树并同步根资产、配件部门及递归快照', async () => { + const child = { + ...childEquipment, + equipment_model: '32G', + equipment_type: '内存条' + } + const grandchild = { + ...child, + id: 22, + equipment_code: 'JS-RAM-0022', + equipment_name: '内存子配件', + equipment_model: '16G', + parent_id: child.id + } + const updates: any[] = [] + let borrowRecordData: any = null + const childrenForParents = (parentIds: number[]) => { + if (parentIds.includes(borrowRoot.id)) return [child] + if (parentIds.includes(child.id)) return [grandchild] + return [] + } + const client = { + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) return [] + if (query.sql.includes('FROM borrow_record')) return [] + if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...borrowRoot }] + if (query.sql.includes('WHERE equipment.parent_id IN')) { + return childrenForParents(query.values as number[]).map((item) => ({ ...item })) + } + return [] + }, + equipment: { + findUnique: async () => ({ ...borrowRoot }), + findMany: async (args: any) => childrenForParents(args.where.parent_id.in).map((item) => ({ ...item })), + updateMany: async (args: any) => { + updates.push(args) + return { count: 1 } + } + }, + borrowRecord: { + create: async ({ data }: any) => { + borrowRecordData = data + return { id: 701, borrow_time: now, ...data } + } + }, + maintenanceRecord: { findFirst: async () => null }, + disposalRecord: { findFirst: async () => null } + } + + const result = await borrowAssetTree(client as any, { + rootEquipmentId: borrowRoot.id, + person: borrowPerson, + expectedReturnTime: null, + isLongTerm: true, + notes: '在职长期' + }) + + assert.equal(result.childrenCount, 2) + assert.equal(updates.length, 3) + assert.deepEqual(updates.map((item) => item.data.department_id), [9, 9, 9]) + assert.equal(updates[0].data.status, '已借出') + assert.equal(updates[0].data.remaining_quantity, 0) + assert.deepEqual(updates.slice(1).map((item) => item.data.remaining_quantity), [0, 0]) + const snapshot = JSON.parse(borrowRecordData.children_snapshot) + assert.deepEqual(snapshot.map((item: any) => ({ id: item.id, parent_id: item.parent_id, depth: item.depth })), [ + { id: 21, parent_id: 20, depth: 1 }, + { id: 22, parent_id: 21, depth: 2 } + ]) +}) + +test('借出初读后并发调拨部门时中止且不创建借用记录', async () => { + let transferredAfterInitialRead = false + let equipmentWriteCount = 0 + let borrowCreateCount = 0 + const client = { + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) { + transferredAfterInitialRead = true + return [] + } + if (query.sql.includes('WHERE equipment.id = ?')) { + return [{ ...borrowRoot, department_id: transferredAfterInitialRead ? 12 : borrowRoot.department_id }] + } + if (query.sql.includes('WHERE equipment.parent_id IN')) return [] + if (query.sql.includes('FROM borrow_record')) return [] + return [] + }, + equipment: { + findUnique: async () => ({ ...borrowRoot }), + findMany: async () => [], + updateMany: async () => { + equipmentWriteCount += 1 + return { count: 1 } + } + }, + borrowRecord: { + create: async () => { + borrowCreateCount += 1 + return { id: 1 } + } + } + } + + await assert.rejects( + borrowAssetTree(client as any, { + rootEquipmentId: borrowRoot.id, + person: borrowPerson + }), + /资产树状态或挂载关系已变化/ + ) + assert.equal(equipmentWriteCount, 0) + assert.equal(borrowCreateCount, 0) +}) + +test('借出初读后并发挂载新配件时中止且不创建借用记录', async () => { + let mountedAfterInitialRead = false + let equipmentWriteCount = 0 + let borrowCreateCount = 0 + const newChild = { + ...childEquipment, + equipment_model: '16G', + equipment_type: '内存条' + } + const client = { + $queryRaw: async (query: any) => { + if (query.sql.startsWith('SELECT id FROM equipment')) { + mountedAfterInitialRead = true + return [] + } + if (query.sql.includes('WHERE equipment.id = ?')) return [{ ...borrowRoot }] + if (query.sql.includes('WHERE equipment.parent_id IN')) { + return mountedAfterInitialRead && query.values.includes(borrowRoot.id) ? [{ ...newChild }] : [] + } + if (query.sql.includes('FROM borrow_record')) return [] + return [] + }, + equipment: { + findUnique: async () => ({ ...borrowRoot }), + findMany: async () => [], + updateMany: async () => { + equipmentWriteCount += 1 + return { count: 1 } + } + }, + borrowRecord: { + create: async () => { + borrowCreateCount += 1 + return { id: 1 } + } + } + } + + await assert.rejects( + borrowAssetTree(client as any, { + rootEquipmentId: borrowRoot.id, + person: borrowPerson + }), + /资产树状态或挂载关系已变化/ + ) + assert.equal(equipmentWriteCount, 0) + assert.equal(borrowCreateCount, 0) +}) + +test('同一资产并发重复借出时只有一个请求成功', async () => { + let state = { ...borrowRoot } + const records: any[] = [] + let lockCount = 0 + let releaseSecondLock!: () => void + const secondLockGate = new Promise((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) +}) diff --git a/backend/src/services/__tests__/printService.authorization.test.ts b/backend/src/services/__tests__/printService.authorization.test.ts new file mode 100644 index 0000000..2105781 --- /dev/null +++ b/backend/src/services/__tests__/printService.authorization.test.ts @@ -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 + } +}) diff --git a/backend/src/services/assetMovementService.ts b/backend/src/services/assetMovementService.ts index ab475a6..e83cca0 100644 --- a/backend/src/services/assetMovementService.ts +++ b/backend/src/services/assetMovementService.ts @@ -7,6 +7,8 @@ export type AssetTreeItem = { id: number equipment_code: string equipment_name: string + equipment_model: string | null + equipment_type: string parent_id: number | null department_id: number | null storage_location_id: number | null @@ -22,6 +24,8 @@ const treeSelect = { id: true, equipment_code: true, equipment_name: true, + equipment_model: true, + equipment_type: true, parent_id: true, department_id: true, storage_location_id: true, @@ -64,7 +68,9 @@ const getAssetTreeFingerprint = (items: AssetTreeItem[]) => JSON.stringify( department_id: item.department_id, status: item.status, mount_status: item.mount_status, + total_quantity: item.total_quantity, remaining_quantity: item.remaining_quantity, + unit_price: item.unit_price.toString(), updated_at: item.updated_at.getTime() })) .sort((left, right) => left.id - right.id) @@ -82,6 +88,8 @@ const normalizeRawAssetTreeItem = (item: Record): AssetTreeItem id: Number(item.id), equipment_code: String(item.equipment_code || ''), equipment_name: String(item.equipment_name || ''), + equipment_model: item.equipment_model === null ? null : String(item.equipment_model || ''), + equipment_type: String(item.equipment_type || ''), parent_id: item.parent_id === null ? null : Number(item.parent_id), department_id: item.department_id === null ? null : Number(item.department_id), storage_location_id: item.storage_location_id === null @@ -150,6 +158,8 @@ const loadAssetTreeForUpdate = async ( equipment.id, equipment.equipment_code, equipment.equipment_name, + equipment.equipment_model, + equipment.equipment_type, equipment.parent_id, equipment.department_id, equipment.storage_location_id, @@ -181,6 +191,8 @@ const loadAssetTreeForUpdate = async ( equipment.id, equipment.equipment_code, equipment.equipment_name, + equipment.equipment_model, + equipment.equipment_type, equipment.parent_id, equipment.department_id, equipment.storage_location_id, @@ -225,6 +237,378 @@ export const loadAndLockAssetTree = async ( return afterLock } +export type BorrowAssetSnapshotItem = { + id: number + equipment_code: string + equipment_name: string + equipment_model: string | null + equipment_type: string + unit_price: number + parent_id: number | null + parent_equipment_id: number | null + depth: number + status: string + department_id: number | null +} + +export const buildRecursiveBorrowSnapshot = ( + items: AssetTreeItem[] +): BorrowAssetSnapshotItem[] => { + if (items.length <= 1) return [] + + const depthById = new Map([[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>(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>(Prisma.sql` + SELECT id, equipment_id + FROM borrow_record + WHERE equipment_id IN (${Prisma.join(equipmentIds)}) AND status = ${'已借出'} + ORDER BY id + FOR UPDATE + `) + if (activeBorrowRows.length > 0) { + throw new AssetTreeConflictError('资产树中已存在未归还借用记录,不能重复补登记') + } + + const [activeMaintenance, activeDisposal] = await Promise.all([ + client.maintenanceRecord.findFirst({ + where: { equipment_id: { in: equipmentIds }, status: 'in_progress' }, + select: { id: true } + }), + client.disposalRecord.findFirst({ + where: { + status: { in: ['pending', 'approved'] }, + OR: [ + { equipment_id: { in: equipmentIds } }, + { items: { some: { equipment_id: { in: equipmentIds } } } } + ] + }, + select: { id: true } + }) + ]) + if (activeMaintenance) { + throw new AssetTreeConflictError('资产树存在进行中的维修记录,不能补登记借用') + } + if (activeDisposal) { + throw new AssetTreeConflictError('资产树存在进行中的报废申请,不能补登记借用') + } + + const targetDepartmentId = input.person.main_department_id ?? root.department_id + for (const item of items) { + const values = calculateEquipmentValues({ + unitPrice: Number(item.unit_price), + totalQuantity: item.total_quantity, + remainingQuantity: 0, + status: '已借出' + }) + const updated = await client.equipment.updateMany({ + where: { + id: item.id, + parent_id: item.parent_id, + department_id: item.department_id, + status: item.status, + mount_status: item.mount_status, + total_quantity: item.total_quantity, + remaining_quantity: item.remaining_quantity, + unit_price: item.unit_price, + updated_at: item.updated_at + }, + data: { + status: '已借出', + remaining_quantity: 0, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value, + department_id: targetDepartmentId + } + }) + if (updated.count !== 1) { + throw new AssetTreeConflictError( + `资产 ${item.equipment_code} 的状态、归属或挂载关系已变化,请刷新后重试` + ) + } + } + + const snapshotItems = buildRecursiveBorrowSnapshot(items) + const record = await client.borrowRecord.create({ + data: { + equipment_id: root.id, + equipment_code: root.equipment_code, + person_id: input.person.id, + person_name: input.person.real_name, + person_nickname: input.person.nickname, + person_identifier: input.person.job_number || null, + borrow_time: input.borrowTime, + notes: input.notes?.trim() || null, + status: '已借出', + children_snapshot: snapshotItems.length > 0 ? JSON.stringify(snapshotItems) : null + } + }) + + return { + record, + items, + snapshotItems, + childrenCount: snapshotItems.length, + updatedRoot: { + ...root, + department_id: targetDepartmentId, + status: '已借出', + remaining_quantity: 0 + } + } +} + const getDepartmentName = async (client: TransactionClient, departmentId: number | null) => { if (!departmentId) return null const department = await client.department.findUnique({ diff --git a/backend/src/services/inventoryService.ts b/backend/src/services/inventoryService.ts index 1dcb8ce..31664ad 100644 --- a/backend/src/services/inventoryService.ts +++ b/backend/src/services/inventoryService.ts @@ -10,6 +10,7 @@ import { } from '../utils/inventoryScope' import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft' import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle' +import { PUBLIC_ASSET_PERSON_SOURCE_USERID } from '../utils/systemReservedPerson' type ScopeEntity = { id: number @@ -27,6 +28,14 @@ type FrozenParticipantDraft = { department_name: string | null } +const sanitizeInventoryParticipant = (participant: T) => { + const { person_source_userid: sourceUserId, ...safeParticipant } = participant + return { + ...safeParticipant, + is_public_asset_person: sourceUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID + } +} + type FrozenItemDraft = { participant_person_id: number | null equipment_id: number @@ -400,7 +409,7 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => { unassigned_count: unassignedCount, department_breakdown: buildDepartmentBreakdown(scope.items), asset_type_breakdown: buildAssetTypeBreakdown(scope.items), - participants_sample: scope.participants.slice(0, 10), + participants_sample: scope.participants.slice(0, 10).map(sanitizeInventoryParticipant), items_sample: scope.items.slice(0, 10) } } @@ -787,7 +796,7 @@ export const getInventoryTaskDetail = async (taskId: number) => { return { ...serializeTaskListItem(task), ...serializedScope, - participants: task.participants, + participants: task.participants.map(sanitizeInventoryParticipant), items: task.items, recent_actions: task.actions, department_breakdown: departmentBreakdown, diff --git a/backend/src/services/printService.ts b/backend/src/services/printService.ts index 642fc19..fc07294 100644 --- a/backend/src/services/printService.ts +++ b/backend/src/services/printService.ts @@ -1080,6 +1080,34 @@ export const listPrintTerminals = async () => { ) } +export const getDefaultPrintTerminalReadiness = async () => { + await markStalePrintTerminalsOffline() + + const terminals = await prisma.printTerminal.findMany({ + where: { is_default: true }, + orderBy: DEFAULT_TERMINAL_ORDER_BY + }) + const preferred = resolvePreferredDefaultTerminal(terminals) + if (!preferred) return null + + const mapped = mapTerminalRecord({ + ...preferred, + recentFailedJob: null + }, { + preferredDefaultTerminalId: preferred.id + }) + + return { + id: mapped.id, + name: mapped.name, + status: mapped.status, + is_default: true, + last_seen_at: mapped.last_seen_at, + selected_printer_name: mapped.selected_printer_name, + readiness: mapped.readiness + } +} + export const setDefaultPrintTerminal = async (terminalId: number) => { const existing = await prisma.printTerminal.findUnique({ where: { id: terminalId } @@ -1346,7 +1374,11 @@ export const listPrintJobs = async ( } } -export const cancelQueuedPrintJob = async (jobId: number) => { +export const cancelQueuedPrintJob = async ( + jobId: number, + requestedBy: number, + allowAnyRequester: boolean = false +) => { const existing = await prisma.printJob.findUnique({ where: { id: jobId } }) @@ -1359,12 +1391,23 @@ export const cancelQueuedPrintJob = async (jobId: number) => { throw new PrintServiceError('只有排队中的任务可以取消', 422) } - await prisma.printJob.update({ - where: { id: jobId }, + if (!allowAnyRequester && existing.requested_by !== requestedBy) { + throw new PrintServiceError('只能取消本人提交的排队任务', 403) + } + + const cancelled = await prisma.printJob.updateMany({ + where: { + id: jobId, + status: PRINT_JOB_STATUS.queued, + ...(!allowAnyRequester ? { requested_by: requestedBy } : {}) + }, data: { status: PRINT_JOB_STATUS.cancelled } }) + if (cancelled.count !== 1) { + throw new PrintServiceError('打印任务状态已变化,请刷新后重试', 409) + } await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json)) } diff --git a/backend/src/utils/__tests__/authMode.test.ts b/backend/src/utils/__tests__/authMode.test.ts new file mode 100644 index 0000000..4c17326 --- /dev/null +++ b/backend/src/utils/__tests__/authMode.test.ts @@ -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) +}) diff --git a/backend/src/utils/__tests__/permissions.test.ts b/backend/src/utils/__tests__/permissions.test.ts index 40d7f3f..1d60299 100644 --- a/backend/src/utils/__tests__/permissions.test.ts +++ b/backend/src/utils/__tests__/permissions.test.ts @@ -8,11 +8,13 @@ test('管理员拥有完整权限集合', () => { assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY)) assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_VIEW_OWN)) + assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS)) }) test('行政可以登记和审批但不能借还、调拨或挂载', () => { const permissions = getPermissionsForRole('administrative') assert.ok(permissions.includes(PERMISSIONS.ASSETS_CREATE)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_CORRECT)) + assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS)) assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) assert.ok(!permissions.includes(PERMISSIONS.ASSETS_BORROW)) assert.ok(!permissions.includes(PERMISSIONS.ASSETS_TRANSFER)) @@ -23,6 +25,7 @@ test('IT可以流转和发起报废但不能审批或管理用户', () => { const permissions = getPermissionsForRole('it') assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW)) assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER)) + assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS)) assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY)) assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE)) assert.ok(!permissions.includes(PERMISSIONS.USERS_MANAGE)) diff --git a/backend/src/utils/__tests__/stage1Migration.test.ts b/backend/src/utils/__tests__/stage1Migration.test.ts new file mode 100644 index 0000000..1c2b206 --- /dev/null +++ b/backend/src/utils/__tests__/stage1Migration.test.ts @@ -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) +}) diff --git a/backend/src/utils/authMode.ts b/backend/src/utils/authMode.ts new file mode 100644 index 0000000..a19f837 --- /dev/null +++ b/backend/src/utils/authMode.ts @@ -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 = 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' +} diff --git a/backend/src/utils/keycloak.ts b/backend/src/utils/keycloak.ts index 9c77168..a38f2ae 100644 --- a/backend/src/utils/keycloak.ts +++ b/backend/src/utils/keycloak.ts @@ -1,5 +1,6 @@ import session from 'express-session' import Keycloak from 'keycloak-connect' +import { assertKeycloakConfiguration } from './authMode' type KeycloakEnvConfig = { serverUrl: string @@ -8,19 +9,13 @@ type KeycloakEnvConfig = { clientSecret?: string } -const requiredEnv = ['KEYCLOAK_URL', 'KEYCLOAK_REALM', 'KEYCLOAK_CLIENT_ID'] as const - -requiredEnv.forEach((key) => { - if (!process.env[key]) { - console.warn(`[Keycloak] 环境变量 ${key} 未设置,Keycloak 认证可能无法正常工作。`) - } -}) +const validatedKeycloakConfig = assertKeycloakConfiguration() const keycloakConfig: KeycloakEnvConfig = { - serverUrl: process.env.KEYCLOAK_URL || 'http://localhost:8080', - realm: process.env.KEYCLOAK_REALM || 'equipment-management', - clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client', - clientSecret: process.env.KEYCLOAK_CLIENT_SECRET + serverUrl: validatedKeycloakConfig?.serverUrl || 'http://localhost:8080', + realm: validatedKeycloakConfig?.realm || 'equipment-management', + clientId: validatedKeycloakConfig?.clientId || 'equipment-client', + clientSecret: validatedKeycloakConfig?.clientSecret } const memoryStore = new session.MemoryStore() diff --git a/backend/src/utils/permissions.ts b/backend/src/utils/permissions.ts index 7c72705..24372b5 100644 --- a/backend/src/utils/permissions.ts +++ b/backend/src/utils/permissions.ts @@ -13,6 +13,7 @@ export const PERMISSIONS = { ASSETS_TRANSFER: 'assets.transfer', ASSETS_MAINTENANCE: 'assets.maintenance', ASSETS_MOUNT: 'assets.mount', + ASSETS_PRINT_LABELS: 'assets.print_labels', DISPOSALS_APPLY: 'disposals.apply', DISPOSALS_APPROVE: 'disposals.approve', DISPOSALS_COMPLETE: 'disposals.complete', @@ -35,6 +36,7 @@ const ROLE_PERMISSIONS: Record = { PERMISSIONS.ASSETS_CREATE, PERMISSIONS.ASSETS_IMPORT, PERMISSIONS.ASSETS_CORRECT, + PERMISSIONS.ASSETS_PRINT_LABELS, PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_COMPLETE, PERMISSIONS.INVENTORY_MANAGE, @@ -47,6 +49,7 @@ const ROLE_PERMISSIONS: Record = { PERMISSIONS.ASSETS_TRANSFER, PERMISSIONS.ASSETS_MAINTENANCE, PERMISSIONS.ASSETS_MOUNT, + PERMISSIONS.ASSETS_PRINT_LABELS, PERMISSIONS.DISPOSALS_APPLY, PERMISSIONS.NOTIFICATIONS_VIEW, PERMISSIONS.REPORTS_VIEW diff --git a/deploy.sh b/deploy.sh index a3d3d62..0850a81 100644 --- a/deploy.sh +++ b/deploy.sh @@ -8,6 +8,7 @@ COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml" PROJECT_NAME="equipment-management" BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh" DISPOSAL_PRECHECK_SQL="$PROJECT_DIR/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql" +DISPOSAL_MIGRATION_NAME="20260714_asset_permissions_disposal_dingtalk" MYSQL_CONTAINER="equipment-mysql" SKIP_BACKUP="${SKIP_BACKUP:-0}" CONFIRM_ACTIVE_DISPOSALS_REVIEWED="${CONFIRM_ACTIVE_DISPOSALS_REVIEWED:-}" @@ -15,6 +16,10 @@ HEALTHCHECK_ATTEMPTS="${HEALTHCHECK_ATTEMPTS:-12}" HEALTHCHECK_INTERVAL_SECONDS="${HEALTHCHECK_INTERVAL_SECONDS:-5}" HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}" EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql") +DISPOSAL_PRECHECK_REQUIRED=0 +INITIAL_DISPOSAL_FINGERPRINT="" +CURRENT_DISPOSAL_FINGERPRINT="" +CURRENT_ACTIVE_DISPOSAL_COUNT=0 get_container_status() { local container="$1" @@ -77,62 +82,72 @@ run_equipment_mysql() { ' sh "$@" } -run_disposal_migration_precheck() { - local mysql_status tree_anomaly_count active_rows active_count active_fingerprint +get_disposal_migration_state() { + local migrations_table_exists state + + migrations_table_exists="$( + printf '%s\n' \ + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '_prisma_migrations';" | + run_equipment_mysql --batch --skip-column-names --raw + )" + migrations_table_exists="$(printf '%s' "$migrations_table_exists" | tr -d '[:space:]')" + if [ "$migrations_table_exists" = "0" ]; then + printf 'pending\n' + return 0 + fi + if [ "$migrations_table_exists" != "1" ]; then + echo "无法确认 Prisma 迁移表状态。" >&2 + return 1 + fi + + state="$(run_equipment_mysql --batch --skip-column-names --raw < 0 THEN 'incomplete' + WHEN SUM(CASE WHEN finished_at IS NOT NULL AND rolled_back_at IS NULL THEN 1 ELSE 0 END) > 0 THEN 'applied' + ELSE 'pending' +END +FROM _prisma_migrations +WHERE migration_name = '$DISPOSAL_MIGRATION_NAME'; +SQL + )" + state="$(printf '%s' "$state" | tr -d '[:space:]')" + case "$state" in + applied|pending|incomplete) printf '%s\n' "$state" ;; + *) echo "报废迁移状态不可解析: $state" >&2; return 1 ;; + esac +} + +calculate_active_disposal_fingerprint() { + local display_rows="${1:-0}" + local snapshot_output missing_root_count tree_anomaly_count review_row_count + local actual_review_row_count review_rows active_fingerprint active_count - echo ">>> 执行报废流程迁移前只读核查..." if [ ! -r "$DISPOSAL_PRECHECK_SQL" ]; then echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2 return 1 fi - - mysql_status="$(get_container_status "$MYSQL_CONTAINER")" - if [ "$mysql_status" != "running" ]; then - echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2 - return 1 - fi - - if ! run_equipment_mysql < "$DISPOSAL_PRECHECK_SQL"; then - echo "报废流程迁移前核查执行失败,已阻止部署。" >&2 - return 1 - fi - - if ! tree_anomaly_count="$(run_equipment_mysql --batch --skip-column-names --raw <<'SQL' -WITH RECURSIVE active_disposal_tree AS ( - SELECT - disposal.id AS disposal_id, - equipment.id AS equipment_id, - equipment.parent_id, - 0 AS depth, - CAST(CONCAT('/', equipment.id, '/') AS CHAR(4096)) AS visited_path, - 0 AS cycle_detected - FROM disposal_record disposal - INNER JOIN equipment equipment ON equipment.id = disposal.equipment_id - WHERE disposal.status IN ('pending', 'approved') - - UNION ALL - - SELECT - tree.disposal_id, - child.id, - child.parent_id, - tree.depth + 1, - CONCAT(tree.visited_path, child.id, '/'), - LOCATE(CONCAT('/', child.id, '/'), tree.visited_path) > 0 - FROM active_disposal_tree tree - INNER JOIN equipment child ON child.parent_id = tree.equipment_id - WHERE tree.depth < 100 - AND tree.cycle_detected = 0 -) -SELECT COUNT(*) -FROM active_disposal_tree -WHERE cycle_detected = 1 OR depth >= 100; -SQL + if ! snapshot_output="$( + run_equipment_mysql --batch --skip-column-names --raw < "$DISPOSAL_PRECHECK_SQL" )"; then - echo "无法确认未完成报废资产树是否存在环或超深层级,已阻止部署。" >&2 + echo "报废流程迁移前一致性快照核查执行失败,已阻止部署。" >&2 return 1 fi - tree_anomaly_count="$(printf '%s' "$tree_anomaly_count" | tr -d '[:space:]')" + + missing_root_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "MISSING_ROOT_COUNT" { print $2 }')" + tree_anomaly_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "TREE_ANOMALY_COUNT" { print $2 }')" + active_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "ACTIVE_DISPOSAL_COUNT" { print $2 }')" + review_row_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW_COUNT" { print $2 }')" + review_rows="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW" { sub(/^[^\t]*\t/, ""); print }')" + + if ! [[ "$missing_root_count" =~ ^[0-9]+$ ]]; then + echo "缺失根资产数量不可解析,已阻止部署。" >&2 + return 1 + fi + if [ "$missing_root_count" -gt 0 ]; then + echo "检测到 $missing_root_count 条未完成报废单缺少根资产,已阻止部署。" >&2 + return 1 + fi + if ! [[ "$tree_anomaly_count" =~ ^[0-9]+$ ]]; then echo "资产树异常数量不可解析,已阻止部署。" >&2 return 1 @@ -143,31 +158,81 @@ SQL return 1 fi - if ! active_rows="$( - printf '%s\n' \ - "SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;" | - run_equipment_mysql --batch --skip-column-names --raw - )"; then - echo "无法读取未完成报废单指纹数据,已阻止部署。" >&2 + if ! [[ "$active_count" =~ ^[0-9]+$ ]]; then + echo "未完成报废单数量不可解析,已阻止部署。" >&2 + return 1 + fi + if ! [[ "$review_row_count" =~ ^[0-9]+$ ]]; then + echo "人工核对行数量不可解析,已阻止部署。" >&2 + return 1 + fi + actual_review_row_count="$( + printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW" { count += 1 } END { print count + 0 }' + )" + if [ "$actual_review_row_count" -ne "$review_row_count" ] || \ + { [ "$active_count" -gt 0 ] && [ "$review_row_count" -lt "$active_count" ]; }; then + echo "一致性快照的统计与人工核对行不一致,已阻止部署。" >&2 return 1 fi - active_count="$(printf '%s\n' "$active_rows" | awk 'NF { count += 1 } END { print count + 0 }')" - if [ "$active_count" -gt 0 ]; then - if ! command -v sha256sum >/dev/null 2>&1; then - echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2 - return 1 + if [ "$display_rows" = "1" ]; then + echo ">>> 同一一致性快照中的未完成报废单及完整资产树:" + if [ "$review_row_count" -gt 0 ]; then + printf '%s\n' "$review_rows" + else + echo " (无未完成报废单)" fi - active_fingerprint="$(printf '%s\n' "$active_rows" | sha256sum | awk '{ print $1 }')" - if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then - echo "未完成报废单指纹不可解析,已阻止部署。" >&2 + fi + + if ! command -v sha256sum >/dev/null 2>&1; then + echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2 + return 1 + fi + # 人工展示与指纹使用同一个 snapshot_output 中的 REVIEW_ROW,避免二次查询产生漂移。 + active_fingerprint="$(printf '%s\n' "$review_rows" | sha256sum | awk '{ print $1 }')" + if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then + echo "未完成报废单指纹不可解析,已阻止部署。" >&2 + return 1 + fi + + CURRENT_ACTIVE_DISPOSAL_COUNT="$active_count" + CURRENT_DISPOSAL_FINGERPRINT="$active_fingerprint" +} + +run_disposal_migration_precheck() { + local mysql_status migration_state + + mysql_status="$(get_container_status "$MYSQL_CONTAINER")" + if [ "$mysql_status" != "running" ]; then + echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2 + return 1 + fi + + migration_state="$(get_disposal_migration_state)" + case "$migration_state" in + applied) + echo ">>> 报废流程迁移已应用,跳过一次性迁移前核查。" + return 0 + ;; + incomplete) + echo "检测到未完成且未回滚的报废流程迁移记录,已阻止部署;请先恢复 Prisma 迁移状态。" >&2 return 1 - fi - echo "检测到 $active_count 条未完成报废单。" >&2 - echo "本次未完成报废单指纹: $active_fingerprint" >&2 - if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]; then + ;; + pending) + DISPOSAL_PRECHECK_REQUIRED=1 + ;; + esac + + echo ">>> 执行报废流程迁移前只读核查..." + calculate_active_disposal_fingerprint 1 + INITIAL_DISPOSAL_FINGERPRINT="$CURRENT_DISPOSAL_FINGERPRINT" + + if [ "$CURRENT_ACTIVE_DISPOSAL_COUNT" -gt 0 ]; then + echo "检测到 $CURRENT_ACTIVE_DISPOSAL_COUNT 条未完成报废单。" >&2 + echo "本次完整资产树指纹: $CURRENT_DISPOSAL_FINGERPRINT" >&2 + if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$CURRENT_DISPOSAL_FINGERPRINT" ]; then echo "已默认阻止部署。请业务负责人逐单核对上方资产树。" >&2 - echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$active_fingerprint。" >&2 + echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$CURRENT_DISPOSAL_FINGERPRINT。" >&2 return 1 fi echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。" @@ -176,6 +241,39 @@ SQL fi } +recheck_disposal_migration_precheck() { + local migration_state + if [ "$DISPOSAL_PRECHECK_REQUIRED" != "1" ]; then + return 0 + fi + + echo ">>> 镜像构建后复核报废资产树是否变化..." + migration_state="$(get_disposal_migration_state)" + case "$migration_state" in + applied) + echo "报废流程迁移已由其他发布成功应用,允许继续。" + return 0 + ;; + incomplete) + echo "构建期间出现未完成的报废流程迁移记录,已阻止更新容器。" >&2 + return 1 + ;; + esac + + calculate_active_disposal_fingerprint + if [ "$CURRENT_DISPOSAL_FINGERPRINT" != "$INITIAL_DISPOSAL_FINGERPRINT" ]; then + echo "镜像构建期间未完成报废单或完整资产树发生变化,已阻止更新容器。" >&2 + echo "构建前指纹: $INITIAL_DISPOSAL_FINGERPRINT" >&2 + echo "当前指纹: $CURRENT_DISPOSAL_FINGERPRINT" >&2 + return 1 + fi + echo "报废单及完整资产树指纹未变化。" +} + +if [ "${DEPLOY_SH_FUNCTIONS_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + if docker compose version >/dev/null 2>&1; then COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE") elif command -v docker-compose >/dev/null 2>&1; then @@ -228,6 +326,8 @@ run_disposal_migration_precheck echo ">>> 先构建固定资产系统镜像..." "${COMPOSE_CMD[@]}" build +recheck_disposal_migration_precheck + echo ">>> 镜像构建成功,开始更新固定资产系统容器..." "${COMPOSE_CMD[@]}" up -d --remove-orphans diff --git a/docker-compose.yml b/docker-compose.yml index 8748036..52d9112 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,10 +33,15 @@ services: - "3000:3000" environment: DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4 - SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret} + NODE_ENV: production + SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} AUTH_MODE: ${AUTH_MODE:-local} + KEYCLOAK_URL: ${KEYCLOAK_URL:-} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-} + KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-} + KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-} PORT: ${PORT:-3000} SQLSERVER_HOST: ${SQLSERVER_HOST:-} SQLSERVER_PORT: ${SQLSERVER_PORT:-1433} @@ -79,7 +84,10 @@ services: dockerfile: Dockerfile args: VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} - VITE_AUTH_MODE: ${VITE_AUTH_MODE:-local} + VITE_AUTH_MODE: ${AUTH_MODE:-local} + VITE_KEYCLOAK_URL: ${KEYCLOAK_URL:-} + VITE_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-} + VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-} container_name: equipment-frontend restart: always ports: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 64d4738..5dfbf4e 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,10 +5,16 @@ WORKDIR /app # 构建参数 ARG VITE_API_BASE_URL=http://localhost:3000/api ARG VITE_AUTH_MODE=local +ARG VITE_KEYCLOAK_URL= +ARG VITE_KEYCLOAK_REALM=equipment-management +ARG VITE_KEYCLOAK_CLIENT_ID=equipment-client # 设置环境变量 ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ENV VITE_AUTH_MODE=$VITE_AUTH_MODE +ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL +ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM +ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID # 安装依赖 COPY package*.json ./ diff --git a/frontend/scripts/test-return-record-search.ts b/frontend/scripts/test-return-record-search.ts index 09c60da..a0932f5 100644 --- a/frontend/scripts/test-return-record-search.ts +++ b/frontend/scripts/test-return-record-search.ts @@ -54,7 +54,10 @@ const byRealName = filterManualReturnRecordCandidates(sampleRecords as any, '周 assert.equal(byRealName[0].id, 102, '姓名搜索应支持') const byIdentifier = filterManualReturnRecordCandidates(sampleRecords as any, 'A1001') -assert.equal(byIdentifier[0].id, 101, '工号搜索应支持') +assert.equal(byIdentifier.length, 0, '历史身份证或旧标识不应作为业务搜索条件') + +const byPhone = filterManualReturnRecordCandidates(sampleRecords as any, '13800000001') +assert.equal(byPhone.length, 0, '手机号不应作为业务搜索条件') const byDepartment = filterManualReturnRecordCandidates(sampleRecords as any, '设计') assert.equal(byDepartment[0].id, 101, '部门搜索应支持') diff --git a/frontend/src/api/borrow.ts b/frontend/src/api/borrow.ts index 772acf5..3af2493 100644 --- a/frontend/src/api/borrow.ts +++ b/frontend/src/api/borrow.ts @@ -29,7 +29,6 @@ export interface ReturnSearchAssetCandidate { storageLocation?: StorageLocation | null active_borrow_count: number borrower_names?: string[] - borrower_identifiers?: string[] borrower_departments?: string[] can_return?: boolean can_handle_borrow_anomaly?: boolean diff --git a/frontend/src/api/person.ts b/frontend/src/api/person.ts index 52c21bf..12972ae 100644 --- a/frontend/src/api/person.ts +++ b/frontend/src/api/person.ts @@ -1,6 +1,37 @@ import api from './index' import type { ApiResponse, PaginatedResponse, Person } from '@/types' +export type PersonOption = Pick< + Person, + | 'id' + | 'nickname' + | 'real_name' + | 'job_number' + | 'department' + | 'main_department' + | 'departments' + | 'department_names' + | 'position' + | 'status' +> & { + dingtalk_ready: boolean +} + +export interface PersonOptionListData { + list: PersonOption[] + total: number + page: number + page_size: number +} + +export interface PersonOptionListParams { + page?: number + pageSize?: number + keyword?: string + status?: 'active' | 'inactive' + includeInactive?: boolean +} + export const getPersonList = async (params: { page?: number pageSize?: number @@ -62,10 +93,14 @@ export const deletePerson = async (id: number) => { return response.data } -export const getPersonOptions = async (params?: { includeInactive?: boolean }) => { - const response = await api.get>('/persons/options', { +export const getPersonOptions = async (params: PersonOptionListParams = {}) => { + const response = await api.get>('/persons/options', { params: { - include_inactive: params?.includeInactive ? 'true' : undefined + page: params.page, + page_size: params.pageSize, + keyword: params.keyword, + status: params.status, + include_inactive: params.includeInactive ? 'true' : undefined } }) return response.data diff --git a/frontend/src/api/print.ts b/frontend/src/api/print.ts index de56f1e..26a9a88 100644 --- a/frontend/src/api/print.ts +++ b/frontend/src/api/print.ts @@ -326,6 +326,32 @@ export const getPrintTerminals = async () => { return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal) } +export const getPrintTerminalReadiness = async (): Promise => { + const response = await api.get>('/print-terminals/readiness') + const data = toRecord(response.data.data) + const rawTerminal = toRecord(data.default_terminal ?? data.terminal ?? data) + const terminalId = toNumber(rawTerminal.id ?? rawTerminal.terminal_id, 0) + + if (!terminalId) { + return null + } + + const readinessValue = rawTerminal.readiness ?? data.readiness ?? { + is_ready: rawTerminal.is_ready ?? data.is_ready, + reason: rawTerminal.readiness_reason ?? data.readiness_reason, + message: rawTerminal.readiness_message ?? data.readiness_message + } + + return normalizePrintTerminal({ + ...rawTerminal, + id: terminalId, + name: rawTerminal.name ?? rawTerminal.terminal_name, + is_default: true, + selected_printer_name: rawTerminal.selected_printer_name ?? rawTerminal.printer_name, + readiness: readinessValue + }) +} + export const createAssetLabelPrintJob = async ( payload: CreateAssetLabelPrintJobPayload ): Promise => { diff --git a/frontend/src/auth/permissions.ts b/frontend/src/auth/permissions.ts index ee5eca1..b43104d 100644 --- a/frontend/src/auth/permissions.ts +++ b/frontend/src/auth/permissions.ts @@ -11,6 +11,7 @@ export const PERMISSIONS = { ASSETS_TRANSFER: 'assets.transfer', ASSETS_MAINTENANCE: 'assets.maintenance', ASSETS_MOUNT: 'assets.mount', + ASSETS_PRINT_LABELS: 'assets.print_labels', DISPOSALS_APPLY: 'disposals.apply', DISPOSALS_APPROVE: 'disposals.approve', DISPOSALS_COMPLETE: 'disposals.complete', diff --git a/frontend/src/components/BarcodePrint.vue b/frontend/src/components/BarcodePrint.vue index 83838a9..db8c7eb 100644 --- a/frontend/src/components/BarcodePrint.vue +++ b/frontend/src/components/BarcodePrint.vue @@ -46,6 +46,29 @@

{{ terminalError }}

+
+
+
+

本次远程打印任务 #{{ latestRemoteJob.id }}

+ + {{ getJobStatusText(latestRemoteJob.status) }} + +
+

+ {{ latestRemoteJob.target_terminal_name || defaultTerminal?.name || '默认打印电脑' }} · {{ equipmentList.length }} 张标签 +

+
+ +
+
>({}) const loading = ref(false) const terminalLoading = ref(false) const remoteSubmitting = ref(false) +const cancelSubmitting = ref(false) const defaultTerminal = ref(null) const terminalError = ref(null) +const latestRemoteJob = ref(null) const DISPLAY_FONT = '"Arial Black", "Bahnschrift", "Segoe UI", sans-serif' const BODY_FONT = '"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif' @@ -183,24 +208,7 @@ const getProfileText = (terminal?: PrintTerminal | null) => { return terminal.readiness.message } - const profile = terminal.label_profile - if (!profile) { - return '未完成 60x40 配置' - } - - if (profile.message) { - return profile.message - } - - if (profile.is_ready === true || profile.configured === true) { - return profile.paper_size ? `${profile.paper_size} 已就绪` : '标签配置已完成' - } - - if (profile.paper_size) { - return `${profile.paper_size} 待确认` - } - - return '未完成 60x40 配置' + return terminal.readiness?.is_ready ? '60 × 40 已就绪' : '待管理员检查配置' } const remoteStatusBadgeClass = computed(() => { @@ -225,7 +233,6 @@ const remoteStatusText = computed(() => { const remotePrinterName = computed(() => { return ( defaultTerminal.value?.selected_printer_name || - defaultTerminal.value?.printer_snapshot?.[0]?.name || '未选择打印机' ) }) @@ -240,11 +247,11 @@ const remoteStatusDescription = computed(() => { } if (terminalError.value) { - return '暂时无法读取办公室打印状态,建议先刷新系统设置里的打印终端。' + return '暂时无法读取办公室打印状态,请联系管理员检查打印终端。' } if (!defaultTerminal.value) { - return '当前还没有设置默认打印电脑,请先到系统设置里的“打印终端”完成配对。' + return '当前还没有设置默认打印电脑,请联系管理员完成终端配置。' } if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) { @@ -268,6 +275,23 @@ const remoteActionDisabled = computed(() => { return defaultTerminal.value.status === 'disabled' }) +const getJobStatusText = (status?: string | null) => { + if (status === 'queued') return '排队中' + if (status === 'claimed') return '已领取' + if (status === 'printing') return '打印中' + if (status === 'completed') return '已完成' + if (status === 'failed') return '失败' + if (status === 'cancelled') return '已取消' + return status || '未知' +} + +const getJobStatusBadgeClass = (status?: string | null) => { + if (status === 'completed') return 'badge-success' + if (status === 'failed') return 'badge-error' + if (status === 'queued' || status === 'claimed' || status === 'printing') return 'badge-warning' + return 'badge-neutral' +} + const loadImage = (src: string) => new Promise((resolve, reject) => { const image = new Image() @@ -677,8 +701,7 @@ const fetchDefaultTerminal = async () => { terminalError.value = null try { - const terminals = await getPrintTerminals() - defaultTerminal.value = terminals.find((item) => item.is_default) || null + defaultTerminal.value = await getPrintTerminalReadiness() } catch (error: any) { console.error('获取打印终端失败:', error) terminalError.value = error?.message || '获取打印终端失败' @@ -717,7 +740,7 @@ const handleRemotePrint = async () => { } if (!defaultTerminal.value) { - alert('当前还没有配置默认打印电脑,请先到系统设置里的“打印终端”完成配对') + alert('当前还没有配置默认打印电脑,请联系管理员处理') return } @@ -744,6 +767,7 @@ const handleRemotePrint = async () => { rendered_labels: renderedLabels, target_terminal_id: targetTerminalId }) + latestRemoteJob.value = result.job const targetTerminalName = result.job.target_terminal_name || defaultTerminal.value.name alert(result.queue_notice || `标签已发送到 ${targetTerminalName}`) @@ -757,6 +781,23 @@ const handleRemotePrint = async () => { } } +const handleCancelRemoteJob = async () => { + const job = latestRemoteJob.value + if (!job || job.status !== 'queued') return + if (!window.confirm(`确定要取消本次打印任务 #${job.id} 吗?`)) return + + cancelSubmitting.value = true + try { + await cancelQueuedPrintJob(job.id) + latestRemoteJob.value = { ...job, status: 'cancelled' } + alert('本次打印任务已取消') + } catch (error: any) { + alert(error?.message || '取消打印任务失败') + } finally { + cancelSubmitting.value = false + } +} + const handlePrint = async () => { if (equipmentList.value.length === 0) { alert('没有可打印的二维码') diff --git a/frontend/src/components/BorrowAnomalyHandler.vue b/frontend/src/components/BorrowAnomalyHandler.vue index bcc9511..1e83128 100644 --- a/frontend/src/components/BorrowAnomalyHandler.vue +++ b/frontend/src/components/BorrowAnomalyHandler.vue @@ -90,7 +90,7 @@ v-model.trim="personKeyword" type="text" class="input" - placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索" + placeholder="输入花名 / 姓名 / 工号 / 部门搜索" @keyup.enter="handlePersonKeywordSubmit" />

@@ -99,7 +99,7 @@

-

支持花名、姓名、工号、部门和手机号搜索。

+

支持花名、姓名、工号和部门搜索。

{{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }}

@@ -129,7 +129,6 @@

{{ person.department || '未填写部门' }} - · {{ person.phone }} · {{ person.job_number }}

@@ -205,13 +204,12 @@ diff --git a/frontend/src/components/PersonBindingPicker.vue b/frontend/src/components/PersonBindingPicker.vue new file mode 100644 index 0000000..ec55b5c --- /dev/null +++ b/frontend/src/components/PersonBindingPicker.vue @@ -0,0 +1,231 @@ + + + diff --git a/frontend/src/views/borrow/BorrowScan.vue b/frontend/src/views/borrow/BorrowScan.vue index f0141a5..0c524eb 100644 --- a/frontend/src/views/borrow/BorrowScan.vue +++ b/frontend/src/views/borrow/BorrowScan.vue @@ -198,7 +198,7 @@ v-model.trim="personKeyword" type="text" class="input" - placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索" + placeholder="输入花名 / 姓名 / 工号 / 部门搜索" @keyup.enter="handlePersonKeywordSubmit" />

@@ -206,7 +206,7 @@

-

支持花名、姓名、工号、部门和手机号搜索。

+

支持花名、姓名、工号和部门搜索。

{{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }}

@@ -225,7 +225,6 @@

{{ person.nickname }} · {{ person.real_name }}

{{ person.department || '未填写部门' }} - · {{ person.phone }} · {{ person.job_number }}

@@ -320,9 +319,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { getEquipmentList, scanEquipment } from '@/api/equipment' import { scanBorrow } from '@/api/borrow' -import { getPersonList } from '@/api/person' +import { getPersonOptions, type PersonOption } from '@/api/person' import { getChildren } from '@/api/mount' -import type { Equipment, Person } from '@/types' +import type { Equipment } from '@/types' import type { ChildEquipment } from '@/api/mount' import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format' import CameraScannerModal from '@/components/CameraScannerModal.vue' @@ -366,7 +365,7 @@ const manualSearchResults = ref([]) const manualSearchLoading = ref(false) const personKeyword = ref('') const selectedPersonId = ref(null) -const persons = ref([]) +const persons = ref([]) let equipmentSearchTimer: number | null = null let personSearchTimer: number | null = null let latestEquipmentRequestId = 0 @@ -517,7 +516,7 @@ const selectManualSearchResult = async (equipment: Equipment) => { await handleScan(equipment.equipment_code) } -const selectPerson = (person: Person) => { +const selectPerson = (person: PersonOption) => { selectedPersonId.value = person.id personKeyword.value = buildManualBorrowPersonDisplay(person) } @@ -625,14 +624,14 @@ const fetchPersons = async (keyword = '') => { const requestId = ++latestPersonRequestId personSearchLoading.value = true try { - const response = await getPersonList({ + const response = await getPersonOptions({ page: 1, - pageSize: keyword ? 20 : 100, + pageSize: 20, keyword: keyword || undefined, status: 'active' }) if (response.data && requestId === latestPersonRequestId) { - persons.value = response.data.list + persons.value = response.data.list || [] } } catch (error) { console.error('获取使用者列表失败', error) diff --git a/frontend/src/views/borrow/ReturnScan.vue b/frontend/src/views/borrow/ReturnScan.vue index b47f0f0..5e38c86 100644 --- a/frontend/src/views/borrow/ReturnScan.vue +++ b/frontend/src/views/borrow/ReturnScan.vue @@ -210,7 +210,6 @@

{{ record.person?.department || '未填写部门' }} - · {{ record.person_identifier }} · 已离职/停用 · 借用于 {{ formatDateTime(record.borrow_time) }}

diff --git a/frontend/src/views/borrow/manualBorrowPersonSearch.ts b/frontend/src/views/borrow/manualBorrowPersonSearch.ts index d44e5dc..21e6d9d 100644 --- a/frontend/src/views/borrow/manualBorrowPersonSearch.ts +++ b/frontend/src/views/borrow/manualBorrowPersonSearch.ts @@ -5,7 +5,6 @@ export interface ManualBorrowSearchPerson { job_number?: string | null department?: string | null department_names?: string[] - phone?: string | null status: string } @@ -66,8 +65,7 @@ export const filterManualBorrowPersons = ( includesKeyword(person.nickname, normalizedKeyword) || includesKeyword(person.real_name, normalizedKeyword) || includesKeyword(person.job_number, normalizedKeyword) || - includesKeyword(buildDepartmentText(person), normalizedKeyword) || - includesKeyword(person.phone, normalizedKeyword) + includesKeyword(buildDepartmentText(person), normalizedKeyword) ) }) diff --git a/frontend/src/views/borrow/manualReturnRecordSearch.ts b/frontend/src/views/borrow/manualReturnRecordSearch.ts index fb20b45..11835a1 100644 --- a/frontend/src/views/borrow/manualReturnRecordSearch.ts +++ b/frontend/src/views/borrow/manualReturnRecordSearch.ts @@ -3,7 +3,6 @@ export interface ManualReturnRecordPerson { real_name?: string | null department?: string | null department_names?: string[] - phone?: string | null status?: string | null } @@ -11,7 +10,6 @@ export interface ManualReturnRecordCandidate { id: number person_name: string person_nickname: string - person_identifier?: string | null person?: ManualReturnRecordPerson | null } @@ -33,17 +31,13 @@ const buildDepartmentText = (record: ManualReturnRecordCandidate) => { const getMatchScore = (record: ManualReturnRecordCandidate, keyword: string) => { const nickname = normalize(record.person_nickname) const realName = normalize(record.person_name) - const identifier = normalize(record.person_identifier) - if (nickname === keyword) return 0 if (realName === keyword) return 1 - if (identifier === keyword) return 2 - if (nickname.startsWith(keyword)) return 3 - if (realName.startsWith(keyword)) return 4 - if (identifier.startsWith(keyword)) return 5 - if (nickname.includes(keyword)) return 6 - if (realName.includes(keyword)) return 7 - return 8 + if (nickname.startsWith(keyword)) return 2 + if (realName.startsWith(keyword)) return 3 + if (nickname.includes(keyword)) return 4 + if (realName.includes(keyword)) return 5 + return 6 } export const filterManualReturnRecordCandidates = ( @@ -60,9 +54,7 @@ export const filterManualReturnRecordCandidates = { return ( normalize(record.person_nickname) === normalizedKeyword || - normalize(record.person_name) === normalizedKeyword || - normalize(record.person_identifier) === normalizedKeyword + normalize(record.person_name) === normalizedKeyword ) }) @@ -123,7 +114,6 @@ export const isManualReturnSelectedRecordKeyword = authStore.canAny([ PERMISSIONS.DISPOSALS_APPROVE, PERMISSIONS.DISPOSALS_COMPLETE ])) -const canPrintLabels = computed(() => canCreate.value || canCorrect.value) +const canPrintLabels = computed(() => authStore.can(PERMISSIONS.ASSETS_PRINT_LABELS)) const canOverrideCorrectionRestrictions = computed(() => authStore.can(PERMISSIONS.SYSTEM_SETTINGS)) const hasActiveMaintenance = computed(() => maintenanceRecords.value.some((record) => record.status === 'in_progress')) const hasActiveDisposal = computed(() => disposalRecords.value.some((record) => record.status === 'pending' || record.status === 'approved')) diff --git a/frontend/src/views/equipment/EquipmentList.vue b/frontend/src/views/equipment/EquipmentList.vue index 1d12aaa..91d1409 100644 --- a/frontend/src/views/equipment/EquipmentList.vue +++ b/frontend/src/views/equipment/EquipmentList.vue @@ -500,7 +500,7 @@ v-model.trim="borrowPersonKeyword" type="text" class="input" - placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索" + placeholder="输入花名 / 姓名 / 工号 / 部门搜索" @keyup.enter="handleBorrowPersonKeywordSubmit" />

输入关键词搜索,点选即可;如果只匹配到 1 人,按回车也能直接选中。

@@ -508,7 +508,7 @@
-

支持花名、姓名、工号、部门和手机号搜索。

+

支持花名、姓名、工号和部门搜索。

{{ borrowPersonLoading ? '搜索中...' : `共 ${filteredBorrowPersons.length} 条` }}

@@ -528,7 +528,6 @@

{{ person.nickname }} · {{ person.real_name }}

{{ person.department || '未填写部门' }} - · {{ person.phone }} · {{ person.job_number }}

@@ -664,7 +663,6 @@

{{ record.person_nickname }} · {{ record.person_name }}

{{ record.person?.department || '未填写部门' }} - · {{ record.person_identifier }} · 已离职/停用 · 借用于 {{ formatDateTime(record.borrow_time) }}

@@ -839,7 +837,7 @@ import { PERMISSIONS } from '@/auth/permissions' import { ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline' import * as XLSX from 'xlsx' import { getEquipmentList, deleteEquipment, scanEquipment } from '@/api/equipment' -import { getPersonOptions, getPersonList } from '@/api/person' +import { getPersonOptions, type PersonOption } from '@/api/person' import { getDepartmentOptions } from '@/api/department' import { getAssetTypeOptions } from '@/api/assetType' import { getStorageLocationOptions } from '@/api/storageLocation' @@ -854,7 +852,7 @@ import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue' import CameraScannerModal from '@/components/CameraScannerModal.vue' import MountHistory from '@/components/MountHistory.vue' import MountManager from '@/components/MountManager.vue' -import type { Equipment, Person, Department, AssetType, StorageLocation, BorrowRecord } from '@/types' +import type { Equipment, Department, AssetType, StorageLocation, BorrowRecord } from '@/types' import type { ReturnSearchAssetCandidate } from '@/api/borrow' // 导入搜索辅助函数 @@ -885,7 +883,7 @@ const canMount = computed(() => authStore.can(PERMISSIONS.ASSETS_MOUNT)) const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE)) const canApplyDisposal = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPLY)) const canExport = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW)) -const canPrintLabels = computed(() => canCreate.value || canCorrect.value) +const canPrintLabels = computed(() => authStore.can(PERMISSIONS.ASSETS_PRINT_LABELS)) const canSelectAssets = computed(() => canBorrow.value || canTransfer.value || canPrintLabels.value) const list = ref([]) const total = ref(0) @@ -895,7 +893,6 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.v const selectedIds = ref([]) const expandedIds = ref(new Set()) -const persons = ref([]) const departments = ref([]) const assetTypes = ref([]) const storageLocations = ref([]) @@ -956,7 +953,7 @@ const borrowForm = ref({ const borrowPersonKeyword = ref('') const borrowSelectedPersonId = ref(null) const borrowPersonLoading = ref(false) -const borrowPersons = ref([]) +const borrowPersons = ref([]) const borrowSubmitting = ref(false) let borrowPersonSearchTimer: number | null = null let latestBorrowPersonRequestId = 0 @@ -1117,14 +1114,14 @@ const fetchBorrowPersons = async (keyword = '') => { const requestId = ++latestBorrowPersonRequestId borrowPersonLoading.value = true try { - const response = await getPersonList({ + const response = await getPersonOptions({ page: 1, - pageSize: keyword ? 20 : 100, + pageSize: 20, keyword: keyword || undefined, status: 'active' }) if (response.data && requestId === latestBorrowPersonRequestId) { - borrowPersons.value = response.data.list + borrowPersons.value = response.data.list || [] } } catch (error) { console.error('获取使用者列表失败', error) @@ -1135,7 +1132,7 @@ const fetchBorrowPersons = async (keyword = '') => { } } -const selectBorrowPerson = (person: Person) => { +const selectBorrowPerson = (person: PersonOption) => { borrowSelectedPersonId.value = person.id borrowPersonKeyword.value = buildManualBorrowPersonDisplay(person) } @@ -1396,13 +1393,11 @@ const fetchData = async () => { } const fetchOptions = async () => { - const [p, d, t, s] = await Promise.all([ - getPersonOptions(), + const [d, t, s] = await Promise.all([ getDepartmentOptions(), getAssetTypeOptions(), getStorageLocationOptions() ]) - if (p.data) persons.value = p.data if (d.data) departments.value = d.data if (t.data) assetTypes.value = t.data if (s.data) storageLocations.value = s.data @@ -1868,7 +1863,7 @@ const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?: return `${nickname}(${realName})` } - return nickname || realName || currentBorrow.person_identifier || '使用者待补充' + return nickname || realName || '使用者待补充' } const getCurrentBorrowerMeta = (equipment: { diff --git a/frontend/src/views/inventory/InventoryDetail.vue b/frontend/src/views/inventory/InventoryDetail.vue index ee51736..94cd546 100644 --- a/frontend/src/views/inventory/InventoryDetail.vue +++ b/frontend/src/views/inventory/InventoryDetail.vue @@ -441,6 +441,20 @@ {{ formatNumber(unassignedItems.length) }} 件待分配
+
+ + +

+ {{ personOptionLoading ? '正在搜索...' : personOptionError || `已加载 ${personOptions.length} / ${personOptionTotal} 人` }} +

+
+
diff --git a/frontend/src/views/inventory/inventoryPublicAsset.ts b/frontend/src/views/inventory/inventoryPublicAsset.ts index 7df8a0d..94ee13e 100644 --- a/frontend/src/views/inventory/inventoryPublicAsset.ts +++ b/frontend/src/views/inventory/inventoryPublicAsset.ts @@ -2,6 +2,7 @@ const PUBLIC_ASSET_SOURCE_USERID = '__public_asset__' const PUBLIC_ASSET_NAME = '公共资产' type PublicAssetLike = { + is_public_asset_person?: boolean person_source_userid?: string | null person_nickname?: string | null person_name?: string | null @@ -19,6 +20,10 @@ export const isInventoryPublicAssetPerson = (value?: PublicAssetLike | null) => return false } + if (value.is_public_asset_person) { + return true + } + if (normalizeText(value.person_source_userid) === PUBLIC_ASSET_SOURCE_USERID) { return true } diff --git a/frontend/src/views/notifications/NotificationCenter.vue b/frontend/src/views/notifications/NotificationCenter.vue index 8234f55..4679ca3 100644 --- a/frontend/src/views/notifications/NotificationCenter.vue +++ b/frontend/src/views/notifications/NotificationCenter.vue @@ -365,7 +365,6 @@

{{ record.person?.department || '未填写部门' }} - · {{ record.person_identifier }} · 已离职/停用

diff --git a/frontend/src/views/settings/components/UserManagement.vue b/frontend/src/views/settings/components/UserManagement.vue index 6e26aba..b198b7a 100644 --- a/frontend/src/views/settings/components/UserManagement.vue +++ b/frontend/src/views/settings/components/UserManagement.vue @@ -195,17 +195,11 @@
- +

仅可绑定在职且已同步钉钉身份的员工,不会按姓名自动关联。

@@ -261,17 +255,12 @@
- +
@@ -305,7 +294,7 @@ diff --git a/scripts/tests/deploy-disposal-precheck-test.sh b/scripts/tests/deploy-disposal-precheck-test.sh new file mode 100644 index 0000000..9333a45 --- /dev/null +++ b/scripts/tests/deploy-disposal-precheck-test.sh @@ -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