From f280380720fc98c608630261d2cf53aa600d0911 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 10 Jul 2026 11:30:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B5=84=E4=BA=A7=E6=B5=81=E7=A8=8B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=B7=A5=E5=8F=B7=E5=80=9F=E8=BF=98=E7=9B=98?= =?UTF-8?q?=E7=82=B9=E4=B8=8E=E6=89=93=E5=8D=B0=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 5 + backend/prisma/schema.prisma | 1 + .../sheet2PublicAssetImportUtils.test.js | 58 + .../sheet2PublicSupplementUtils.test.js | 113 ++ .../sheet2RemoteScriptSafety.test.js | 80 + .../scripts/backfillBorrowRecordJobNumber.sql | 34 + .../scripts/importSheet2PublicAssetsToLive.js | 613 ++++++++ .../scripts/prepareSheet2SupplementDryRun.js | 385 +++-- .../scripts/sheet2PublicAssetImportUtils.js | 51 + .../scripts/sheet2PublicSupplementUtils.js | 231 +++ .../equipmentController.search.test.ts | 167 ++ .../equipmentController.update-status.test.ts | 102 ++ backend/src/controllers/batchController.ts | 2 +- .../src/controllers/batchImportController.ts | 21 +- backend/src/controllers/borrowController.ts | 29 +- .../src/controllers/equipmentController.ts | 218 ++- backend/src/controllers/exportController.ts | 15 +- .../src/controllers/inventoryController.ts | 33 +- backend/src/controllers/personController.ts | 46 +- backend/src/controllers/searchController.ts | 9 +- backend/src/index.ts | 9 +- backend/src/routes/inventory.ts | 4 +- .../services/__tests__/hrSyncService.test.ts | 45 +- .../services/__tests__/printService.test.ts | 156 +- backend/src/services/hrSyncHelpers.ts | 5 +- backend/src/services/inventoryService.ts | 192 ++- backend/src/services/printService.ts | 334 +++- .../services/systemReservedPersonService.ts | 44 + .../__tests__/equipmentSearchPriority.test.ts | 29 + .../__tests__/inventoryTaskDraft.test.ts | 155 ++ .../__tests__/systemReservedPerson.test.ts | 41 + backend/src/utils/equipmentSearchPriority.ts | 17 + backend/src/utils/inventoryTaskDraft.ts | 86 ++ backend/src/utils/systemReservedPerson.ts | 29 + downloads/print-agent/latest/manifest.json | 16 +- .../print-agent/releases/0.2.5/manifest.json | 16 +- .../print-agent/releases/0.2.6/manifest.json | 9 + frontend/scripts/test-borrow-person-search.ts | 12 +- .../scripts/test-inventory-public-asset.ts | 33 + frontend/scripts/test-local-batch-print.ts | 41 + frontend/src/api/batchImport.ts | 1 + frontend/src/api/disposal.ts | 5 +- frontend/src/api/equipment.ts | 1 + frontend/src/api/inventory.ts | 10 + frontend/src/api/maintenance.ts | 5 +- frontend/src/api/person.ts | 10 +- frontend/src/api/print.ts | 4 +- frontend/src/components/BarcodePrint.vue | 75 +- .../src/components/BorrowAnomalyHandler.vue | 28 +- frontend/src/types/index.ts | 12 +- frontend/src/utils/localBatchPrint.ts | 123 ++ frontend/src/views/borrow/BorrowList.vue | 2 +- frontend/src/views/borrow/BorrowScan.vue | 10 +- .../views/borrow/manualBorrowPersonSearch.ts | 29 +- .../src/views/equipment/EquipmentForm.vue | 3 +- .../src/views/equipment/EquipmentList.vue | 1343 +++++++++++++++-- .../src/views/inventory/InventoryCreate.vue | 28 +- .../src/views/inventory/InventoryDetail.vue | 143 +- .../views/inventory/inventoryPublicAsset.ts | 32 + .../settings/components/PersonManagement.vue | 31 +- print-agent/package-lock.json | 21 +- print-agent/package.json | 2 +- print-agent/src/config/storage.js | 34 +- print-agent/src/index.html | 2 +- print-agent/src/main.js | 27 +- print-agent/src/services/jobProcessor.js | 17 + print-agent/src/services/labelDocument.js | 75 +- print-agent/tests/agent-runtime.test.mjs | 2 +- print-agent/tests/config-store.test.mjs | 16 +- print-agent/tests/job-processor.test.mjs | 48 + print-agent/tests/label-document.test.mjs | 12 + 71 files changed, 5001 insertions(+), 636 deletions(-) create mode 100644 backend/prisma/migrations/20260328_add_person_job_number/migration.sql create mode 100644 backend/scripts/__tests__/sheet2PublicAssetImportUtils.test.js create mode 100644 backend/scripts/__tests__/sheet2PublicSupplementUtils.test.js create mode 100644 backend/scripts/__tests__/sheet2RemoteScriptSafety.test.js create mode 100644 backend/scripts/backfillBorrowRecordJobNumber.sql create mode 100644 backend/scripts/importSheet2PublicAssetsToLive.js create mode 100644 backend/scripts/sheet2PublicAssetImportUtils.js create mode 100644 backend/scripts/sheet2PublicSupplementUtils.js create mode 100644 backend/src/controllers/__tests__/equipmentController.search.test.ts create mode 100644 backend/src/controllers/__tests__/equipmentController.update-status.test.ts create mode 100644 backend/src/services/systemReservedPersonService.ts create mode 100644 backend/src/utils/__tests__/equipmentSearchPriority.test.ts create mode 100644 backend/src/utils/__tests__/inventoryTaskDraft.test.ts create mode 100644 backend/src/utils/__tests__/systemReservedPerson.test.ts create mode 100644 backend/src/utils/equipmentSearchPriority.ts create mode 100644 backend/src/utils/inventoryTaskDraft.ts create mode 100644 backend/src/utils/systemReservedPerson.ts create mode 100644 downloads/print-agent/releases/0.2.6/manifest.json create mode 100644 frontend/scripts/test-inventory-public-asset.ts create mode 100644 frontend/scripts/test-local-batch-print.ts create mode 100644 frontend/src/utils/localBatchPrint.ts create mode 100644 frontend/src/views/inventory/inventoryPublicAsset.ts diff --git a/backend/prisma/migrations/20260328_add_person_job_number/migration.sql b/backend/prisma/migrations/20260328_add_person_job_number/migration.sql new file mode 100644 index 0000000..bf3fa92 --- /dev/null +++ b/backend/prisma/migrations/20260328_add_person_job_number/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE `person` + ADD COLUMN `job_number` VARCHAR(50) NULL; + +CREATE UNIQUE INDEX `person_job_number_key` + ON `person`(`job_number`); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1148e59..5339c3e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -268,6 +268,7 @@ model Person { id Int @id @default(autoincrement()) nickname String @db.VarChar(50) real_name String @db.VarChar(50) + job_number String? @unique @db.VarChar(50) id_number String? @unique @db.VarChar(50) department String? @db.VarChar(100) main_department_id Int? diff --git a/backend/scripts/__tests__/sheet2PublicAssetImportUtils.test.js b/backend/scripts/__tests__/sheet2PublicAssetImportUtils.test.js new file mode 100644 index 0000000..5200a81 --- /dev/null +++ b/backend/scripts/__tests__/sheet2PublicAssetImportUtils.test.js @@ -0,0 +1,58 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + buildAssignableExistingSheet2PublicEquipmentWhere, + PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES, + buildPublicAssetBorrowState, + isSheet2PublicEquipmentDescription +} = require('../sheet2PublicAssetImportUtils') + +test('isSheet2PublicEquipmentDescription 只识别总表待导入 Sheet2 公共设备描述', () => { + assert.equal( + isSheet2PublicEquipmentDescription( + 'source_workbook=docs/总表待导入.xlsx; source_sheet=Sheet2; source_row=66' + ), + true + ) + assert.equal( + isSheet2PublicEquipmentDescription( + 'source_workbook=docs/8.xlsx; source_sheet=Sheet1; source_row=66' + ), + false + ) + assert.equal(isSheet2PublicEquipmentDescription(''), false) +}) + +test('buildPublicAssetBorrowState 应把公共资产挂名状态统一转成已借出', () => { + assert.deepEqual(buildPublicAssetBorrowState(0), { + equipmentStatus: '已借出', + remainingQuantity: 0, + totalValue: 0, + borrowedValue: 0, + availableValue: 0 + }) + + assert.deepEqual(buildPublicAssetBorrowState('1280.50'), { + equipmentStatus: '已借出', + remainingQuantity: 0, + totalValue: 1280.5, + borrowedValue: 1280.5, + availableValue: 0 + }) +}) + +test('PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES 只允许当前可用的既有公共设备自动挂名', () => { + assert.deepEqual(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES, ['可用']) +}) + +test('buildAssignableExistingSheet2PublicEquipmentWhere 只筛可用且未在借的 Sheet2 公共设备', () => { + assert.deepEqual(buildAssignableExistingSheet2PublicEquipmentWhere(), { + AND: [ + { description: { contains: 'source_workbook=docs/总表待导入.xlsx' } }, + { description: { contains: 'source_sheet=Sheet2' } } + ], + status: { in: ['可用'] }, + borrow_records: { none: { status: '已借出' } } + }) +}) diff --git a/backend/scripts/__tests__/sheet2PublicSupplementUtils.test.js b/backend/scripts/__tests__/sheet2PublicSupplementUtils.test.js new file mode 100644 index 0000000..9bd061e --- /dev/null +++ b/backend/scripts/__tests__/sheet2PublicSupplementUtils.test.js @@ -0,0 +1,113 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + mergeCandidateRowsWithSourceCodes, + resolveSheet2LocationMatch +} = require('../sheet2PublicSupplementUtils') + +const buildLocationByName = (names) => + new Map( + names.map((name, index) => [ + String(name).trim().toUpperCase(), + { + id: index + 1, + name + } + ]) + ) + +test('mergeCandidateRowsWithSourceCodes 应补回候选集中漏掉的显式编码且不重复已有记录', () => { + const candidateRows = [ + { + 来源工作表: 'Sheet2', + 资产编码: 'JS-DZ-0172', + 资产名称: '打印机' + } + ] + + const sourceRows = [ + { + 来源工作表: 'Sheet2', + 资产编码: 'JS-DZ-0168', + 资产名称: '打印机-喷墨' + }, + { + 来源工作表: 'Sheet2', + 资产编码: 'JS-DZ-0172', + 资产名称: '打印机' + } + ] + + const result = mergeCandidateRowsWithSourceCodes(candidateRows, sourceRows, [ + 'JS-DZ-0168', + 'JS-DZ-0172', + 'JS-DZ-9999' + ]) + + assert.deepEqual( + result.rows.map((row) => row.资产编码), + ['JS-DZ-0172', 'JS-DZ-0168'] + ) + assert.deepEqual(result.missingCodes, ['JS-DZ-9999']) + assert.deepEqual(result.duplicateSourceCodes, []) +}) + +test('mergeCandidateRowsWithSourceCodes 遇到源表同编码多行冲突时不应自动选第一条', () => { + const result = mergeCandidateRowsWithSourceCodes( + [], + [ + { + 来源工作表: 'Sheet2', + 资产编码: 'JS-DQ-0373', + 资产名称: '冰箱', + 地址: '佛山顺德仓' + }, + { + 来源工作表: 'Sheet2', + 资产编码: 'JS-DQ-0373', + 资产名称: '空调', + 地址: '天安' + } + ], + ['JS-DQ-0373'] + ) + + assert.deepEqual(result.rows, []) + assert.deepEqual(result.missingCodes, []) + assert.deepEqual(result.duplicateSourceCodes.map((item) => item.code), ['JS-DQ-0373']) + assert.equal(result.duplicateSourceCodes[0].rows.length, 2) +}) + +test('resolveSheet2LocationMatch 应识别带仓储前缀的苏北仓地址', () => { + const result = resolveSheet2LocationMatch( + '仓储一部 苏北仓', + buildLocationByName(['苏北仓库', '常州-仓库']) + ) + + assert.equal(result.normalizedValue, '苏北仓库') + assert.equal(result.match?.name, '苏北仓库') + assert.equal(result.rule, 'sheet2-prefix-override') +}) + +test('resolveSheet2LocationMatch 应识别带仓储前缀的崔桥工厂仓地址', () => { + const result = resolveSheet2LocationMatch( + '仓储二部 崔桥工厂仓', + buildLocationByName(['常州-仓库']) + ) + + assert.equal(result.normalizedValue, '常州-仓库') + assert.equal(result.match?.name, '常州-仓库') + assert.equal(result.rule, 'sheet2-prefix-alias') +}) + +test('resolveSheet2LocationMatch 对未明确确认的地址不应猜测映射', () => { + const result = resolveSheet2LocationMatch( + '仓储二部 山东仓', + buildLocationByName(['济南', '常州-仓库']) + ) + + assert.equal(result.match, null) + assert.equal(result.normalizedValue, '山东仓') + assert.equal(result.rule, 'sheet2-prefix-stripped') +}) diff --git a/backend/scripts/__tests__/sheet2RemoteScriptSafety.test.js b/backend/scripts/__tests__/sheet2RemoteScriptSafety.test.js new file mode 100644 index 0000000..664ed4f --- /dev/null +++ b/backend/scripts/__tests__/sheet2RemoteScriptSafety.test.js @@ -0,0 +1,80 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const prepareScript = require('../prepareSheet2SupplementDryRun') +const importScript = require('../importSheet2PublicAssetsToLive') + +const explicitConnectionArgs = [ + '--plink-path', + 'plink-test.exe', + '--ssh-host', + 'deploy@example.invalid', + '--ssh-hostkey', + 'SHA256:test-host-key', + '--ssh-password', + 'test-secret-value' +] + +test('Sheet2 导入脚本默认只能 preview,只有显式 --apply 才允许写入', () => { + assert.deepEqual(importScript.parseArgs([]), { + inputDir: '', + apply: false, + dryRun: true, + plinkPath: '', + sshHost: '', + sshHostkey: '', + sshPassword: '' + }) + + const applyOptions = importScript.parseArgs(['--apply']) + assert.equal(applyOptions.apply, true) + assert.equal(applyOptions.dryRun, false) + + const previewOptions = importScript.parseArgs(['--dry-run']) + assert.equal(previewOptions.apply, false) + assert.equal(previewOptions.dryRun, true) +}) + +test('Sheet2 导入脚本拒绝冲突或拼错的写入参数', () => { + assert.throws( + () => importScript.parseArgs(['--apply', '--dry-run']), + /不能同时使用/ + ) + assert.throws(() => importScript.parseArgs(['--aply']), /不支持的参数/) + assert.throws(() => prepareScript.parseArgs(['--ssh-host']), /缺少参数值/) +}) + +test('两个 Sheet2 脚本缺少连接参数时应在联网前明确失败且不包含秘密', () => { + for (const script of [prepareScript, importScript]) { + const options = script.parseArgs(['--ssh-password', 'test-secret-value']) + assert.throws( + () => script.resolveRemoteConnection(options, {}), + (error) => { + assert.match(error.message, /缺少固定资产远程连接参数/) + assert.match(error.message, /FIXED_ASSET_SSH_HOST/) + assert.match(error.message, /未发起网络连接/) + assert.doesNotMatch(error.message, /test-secret-value/) + return true + } + ) + } +}) + +test('两个 Sheet2 脚本支持显式连接参数且 CLI 优先于环境变量', () => { + const env = { + FIXED_ASSET_PLINK_PATH: 'env-plink.exe', + FIXED_ASSET_SSH_HOST: 'env@example.invalid', + FIXED_ASSET_SSH_HOSTKEY: 'SHA256:env-key', + FIXED_ASSET_SSH_PASSWORD: 'env-secret' + } + + for (const script of [prepareScript, importScript]) { + const options = script.parseArgs(explicitConnectionArgs) + assert.deepEqual(script.resolveRemoteConnection(options, env), { + plinkPath: 'plink-test.exe', + sshHost: 'deploy@example.invalid', + sshHostkey: 'SHA256:test-host-key', + sshPassword: 'test-secret-value' + }) + } +}) diff --git a/backend/scripts/backfillBorrowRecordJobNumber.sql b/backend/scripts/backfillBorrowRecordJobNumber.sql new file mode 100644 index 0000000..a215751 --- /dev/null +++ b/backend/scripts/backfillBorrowRecordJobNumber.sql @@ -0,0 +1,34 @@ +-- 仅用于“100% 可确定”的历史借用记录工号回填。 +-- 执行前必须先确认: +-- 1. person.job_number 已由可信 HR 同步写入 +-- 2. 本次只允许回填当前 person_identifier 为空的记录 +-- 3. 只处理 source_system = 'ding_hr' 且 source_userid 非空的可信同步人员 + +SELECT + br.id, + br.equipment_code, + br.person_id, + br.person_name, + br.person_nickname, + br.person_identifier AS current_identifier, + p.job_number AS target_job_number, + p.source_userid, + p.source_system +FROM borrow_record br +JOIN person p ON p.id = br.person_id +WHERE (br.person_identifier IS NULL OR TRIM(br.person_identifier) = '') + AND p.job_number IS NOT NULL + AND TRIM(p.job_number) <> '' + AND p.source_system = 'ding_hr' + AND p.source_userid IS NOT NULL +ORDER BY br.id; + +-- 确认上面的候选数据无误后,再手动执行下面的 UPDATE。 +-- UPDATE borrow_record br +-- JOIN person p ON p.id = br.person_id +-- SET br.person_identifier = p.job_number +-- WHERE (br.person_identifier IS NULL OR TRIM(br.person_identifier) = '') +-- AND p.job_number IS NOT NULL +-- AND TRIM(p.job_number) <> '' +-- AND p.source_system = 'ding_hr' +-- AND p.source_userid IS NOT NULL; diff --git a/backend/scripts/importSheet2PublicAssetsToLive.js b/backend/scripts/importSheet2PublicAssetsToLive.js new file mode 100644 index 0000000..18b9455 --- /dev/null +++ b/backend/scripts/importSheet2PublicAssetsToLive.js @@ -0,0 +1,613 @@ +let PrismaClient + +try { + ;({ PrismaClient } = require('@prisma/client')) +} catch { + ;({ PrismaClient } = require('/app/node_modules/@prisma/client')) +} + +const fs = require('fs') +const path = require('path') +const { spawnSync } = require('child_process') +const { + buildAssignableExistingSheet2PublicEquipmentWhere, + PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES, + buildPublicAssetBorrowState, + isSheet2PublicEquipmentDescription, + normalizeText +} = require('./sheet2PublicAssetImportUtils') + +const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') +const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node' + +const DEFAULT_PUBLIC_PERSON = { + nickname: '公共资产', + real_name: '公共资产', + job_number: null, + id_number: null, + department: null, + main_department_id: null, + position: '系统保留', + phone: null, + email: null, + status: 'active', + remarks: '系统保留人员:用于承接公共设备,不参与 HR 组织同步。', + source_userid: '__public_asset__', + source_system: 'system_reserved', + source_main_department_id: null, + source_dept_ids: null, + source_department_name: null, + source_main_department_name: null, + department_sync_bucket: null, + manager_source_userid: null, + source_employee_status: null, + last_synced_at: null +} + +const requireArgValue = (args, index, argumentName) => { + const value = args[index + 1] + if (!value || String(value).startsWith('--')) { + throw new Error(`${argumentName} 缺少参数值`) + } + return value +} + +const parseArgs = (args = process.argv.slice(2)) => { + const options = { + inputDir: '', + apply: false, + dryRun: true, + plinkPath: '', + sshHost: '', + sshHostkey: '', + sshPassword: '' + } + let explicitDryRun = false + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--input-dir') { + options.inputDir = requireArgValue(args, index, arg) + index += 1 + } else if (arg === '--dry-run') { + explicitDryRun = true + } else if (arg === '--apply') { + options.apply = true + } else if (arg === '--plink-path') { + options.plinkPath = requireArgValue(args, index, arg) + index += 1 + } else if (arg === '--ssh-host') { + options.sshHost = requireArgValue(args, index, arg) + index += 1 + } else if (arg === '--ssh-hostkey') { + options.sshHostkey = requireArgValue(args, index, arg) + index += 1 + } else if (arg === '--ssh-password') { + options.sshPassword = requireArgValue(args, index, arg) + index += 1 + } else { + throw new Error(`不支持的参数: ${arg}`) + } + } + + if (options.apply && explicitDryRun) { + throw new Error('--apply 与 --dry-run 不能同时使用') + } + + options.dryRun = !options.apply + + return options +} + +const resolveRemoteConnection = (options, env = process.env) => { + const connection = { + plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH), + sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST), + sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY), + sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || '' + } + const missing = [] + + if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path') + if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host') + if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey') + if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password') + + if (missing.length > 0) { + throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`) + } + + return connection +} + +const ensureOutputDir = (prefix) => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`) + fs.mkdirSync(outputDir, { recursive: true }) + return outputDir +} + +const resolveLatestSheet2Dir = () => { + const candidates = fs + .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('sheet2_public_dry_run_')) + .map((entry) => entry.name) + .sort() + + if (!candidates.length) { + throw new Error(`未找到 Sheet2 公共设备 dry-run 输出目录: ${OUTPUT_ROOT}`) + } + + return path.join(OUTPUT_ROOT, candidates[candidates.length - 1]) +} + +const resolveInputDir = (inputDirArg) => { + if (!inputDirArg) { + return resolveLatestSheet2Dir() + } + + const resolved = path.isAbsolute(inputDirArg) + ? inputDirArg + : path.resolve(process.cwd(), inputDirArg) + + if (!fs.existsSync(resolved)) { + throw new Error(`指定的输入目录不存在: ${resolved}`) + } + + return resolved +} + +const writeJson = (filePath, value) => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) + +const runRemoteNodeScript = (script, connection) => { + const result = spawnSync( + connection.plinkPath, + [ + '-batch', + '-ssh', + '-hostkey', + connection.sshHostkey, + '-pw', + connection.sshPassword, + connection.sshHost, + REMOTE_NODE_COMMAND + ], + { + encoding: 'utf8', + input: script, + maxBuffer: 64 * 1024 * 1024, + windowsHide: true + } + ) + + if (result.error) { + throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。') + } + + if (result.status !== 0) { + throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。') + } + + const stdout = normalizeText(result.stdout) + if (!stdout) { + throw new Error('远程脚本返回为空') + } + + try { + return JSON.parse(stdout) + } catch { + throw new Error('远程脚本返回格式无效') + } +} + +const buildRemotePreviewScript = (payload) => { + const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') + const seedBase64 = Buffer.from(JSON.stringify(DEFAULT_PUBLIC_PERSON), 'utf8').toString('base64') + const assignableExistingWhereBase64 = Buffer.from( + JSON.stringify( + buildAssignableExistingSheet2PublicEquipmentWhere(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES) + ), + 'utf8' + ).toString('base64') + + return ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8')) +const publicSeed = JSON.parse(Buffer.from('${seedBase64}', 'base64').toString('utf8')) +const assignableExistingWhere = JSON.parse(Buffer.from('${assignableExistingWhereBase64}', 'base64').toString('utf8')) + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\\r/g, ' ') + .replace(/\\n/g, ' ') + .replace(/\\s+/g, ' ') + .trim() + +;(async () => { + const payloadCodes = payload.map((item) => normalizeText(item.legacyCode).toUpperCase()).filter(Boolean) + const [existingCodes, assignableExisting, publicAssetPerson] = await Promise.all([ + prisma.equipment.findMany({ + where: { equipment_code: { in: payloadCodes } }, + select: { equipment_code: true, equipment_name: true, status: true }, + orderBy: { equipment_code: 'asc' } + }), + prisma.equipment.findMany({ + where: assignableExistingWhere, + select: { id: true, equipment_code: true, equipment_name: true, status: true }, + orderBy: { equipment_code: 'asc' } + }), + prisma.person.findFirst({ + where: { source_userid: publicSeed.source_userid }, + select: { id: true, nickname: true, status: true, source_system: true } + }) + ]) + + console.log(JSON.stringify({ + payloadCount: payload.length, + existingPayloadCodeCount: existingCodes.length, + existingPayloadCodes: existingCodes, + assignableExistingCount: assignableExisting.length, + assignableExistingSample: assignableExisting.slice(0, 30), + publicAssetPerson + })) + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` +} + +const buildRemoteApplyScript = (payload) => { + const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') + const seedBase64 = Buffer.from(JSON.stringify(DEFAULT_PUBLIC_PERSON), 'utf8').toString('base64') + const assignableExistingWhereBase64 = Buffer.from( + JSON.stringify( + buildAssignableExistingSheet2PublicEquipmentWhere(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES) + ), + 'utf8' + ).toString('base64') + + return ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8')) +const publicSeed = JSON.parse(Buffer.from('${seedBase64}', 'base64').toString('utf8')) +const assignableExistingWhere = JSON.parse(Buffer.from('${assignableExistingWhereBase64}', 'base64').toString('utf8')) + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\\r/g, ' ') + .replace(/\\n/g, ' ') + .replace(/\\s+/g, ' ') + .trim() + +const buildBorrowState = (unitPrice) => { + const normalizedUnitPrice = Number(unitPrice || 0) + return { + equipmentStatus: '已借出', + remainingQuantity: 0, + totalValue: normalizedUnitPrice, + borrowedValue: normalizedUnitPrice, + availableValue: 0 + } +} + +const importResults = [] +const skippedExistingImports = [] +const assignedExisting = [] + +;(async () => { + const publicAssetPerson = await prisma.$transaction(async (tx) => { + const person = await tx.person.upsert({ + where: { source_userid: publicSeed.source_userid }, + create: publicSeed, + update: { + nickname: publicSeed.nickname, + real_name: publicSeed.real_name, + department: null, + main_department_id: null, + position: publicSeed.position, + status: 'active', + remarks: publicSeed.remarks, + source_system: publicSeed.source_system, + source_main_department_id: null, + source_dept_ids: null, + source_department_name: null, + source_main_department_name: null, + department_sync_bucket: null, + manager_source_userid: null, + source_employee_status: null, + last_synced_at: null + } + }) + + await tx.personDepartment.deleteMany({ + where: { person_id: person.id } + }) + + return person + }) + + for (const item of payload) { + const legacyCode = normalizeText(item.legacyCode).toUpperCase() + if (!legacyCode) { + continue + } + + const existing = await prisma.equipment.findUnique({ + where: { equipment_code: legacyCode }, + select: { id: true, equipment_code: true, equipment_name: true, status: true } + }) + if (existing) { + skippedExistingImports.push(existing) + continue + } + + const [assetType, department, location] = await Promise.all([ + prisma.assetType.findFirst({ + where: { name: normalizeText(item.equipmentType), status: 'active' }, + select: { id: true, name: true } + }), + normalizeText(item.departmentName) + ? prisma.department.findFirst({ + where: { name: normalizeText(item.departmentName), status: 'active' }, + select: { id: true, name: true } + }) + : Promise.resolve(null), + normalizeText(item.storageLocationName) + ? prisma.storageLocation.findFirst({ + where: { name: normalizeText(item.storageLocationName), status: 'active' }, + select: { id: true, name: true } + }) + : Promise.resolve(null) + ]) + + if (!assetType) { + throw new Error('资产类型不存在或未启用: ' + normalizeText(item.equipmentType) + ' code=' + legacyCode) + } + if (normalizeText(item.departmentName) && !department) { + throw new Error('部门不存在或未启用: ' + normalizeText(item.departmentName) + ' code=' + legacyCode) + } + if (normalizeText(item.storageLocationName) && !location) { + throw new Error('地址不存在或未启用: ' + normalizeText(item.storageLocationName) + ' code=' + legacyCode) + } + + const unitPrice = Number(item.unitPrice || 0) + const purchaseDate = normalizeText(item.purchaseDate) ? new Date(item.purchaseDate) : null + const borrowTime = purchaseDate || new Date() + const borrowState = buildBorrowState(unitPrice) + + const created = await prisma.$transaction(async (tx) => { + const equipment = await tx.equipment.create({ + data: { + equipment_code: legacyCode, + equipment_name: normalizeText(item.equipmentName), + equipment_model: normalizeText(item.equipmentModel) || null, + equipment_type: assetType.name, + code_generation_mode: 'manual', + status: borrowState.equipmentStatus, + total_quantity: 1, + remaining_quantity: borrowState.remainingQuantity, + unit_price: unitPrice, + total_value: borrowState.totalValue, + borrowed_value: borrowState.borrowedValue, + available_value: borrowState.availableValue, + total_value_with_children: borrowState.totalValue, + description: normalizeText(item.description) || null, + purchase_date: purchaseDate, + supplier: normalizeText(item.supplier) || null, + storage_location_id: location?.id || null, + asset_type_id: assetType.id, + department_id: department?.id || null, + mount_status: 'none' + } + }) + + await tx.borrowRecord.create({ + data: { + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + person_id: publicAssetPerson.id, + person_name: publicAssetPerson.real_name, + person_nickname: publicAssetPerson.nickname, + person_identifier: publicAssetPerson.job_number || null, + borrow_time: borrowTime, + expected_return_time: null, + is_long_term: true, + return_time: null, + status: '已借出', + return_condition: '正常', + notes: '总表待导入 Sheet2 公共设备补导;系统挂名到公共资产' + } + }) + + return { + id: equipment.id, + equipment_code: equipment.equipment_code, + equipment_name: equipment.equipment_name, + status: equipment.status + } + }) + + importResults.push(created) + } + + const existingCandidates = await prisma.equipment.findMany({ + where: assignableExistingWhere, + select: { + id: true, + equipment_code: true, + equipment_name: true, + unit_price: true, + purchase_date: true, + status: true + }, + orderBy: { equipment_code: 'asc' } + }) + + for (const equipment of existingCandidates) { + const borrowState = buildBorrowState(equipment.unit_price) + const borrowTime = equipment.purchase_date || new Date() + + const assigned = await prisma.$transaction(async (tx) => { + const updatedEquipment = await tx.equipment.update({ + where: { id: equipment.id }, + data: { + status: borrowState.equipmentStatus, + remaining_quantity: borrowState.remainingQuantity, + total_value: borrowState.totalValue, + borrowed_value: borrowState.borrowedValue, + available_value: borrowState.availableValue, + total_value_with_children: borrowState.totalValue + } + }) + + await tx.borrowRecord.create({ + data: { + equipment_id: updatedEquipment.id, + equipment_code: updatedEquipment.equipment_code, + person_id: publicAssetPerson.id, + person_name: publicAssetPerson.real_name, + person_nickname: publicAssetPerson.nickname, + person_identifier: publicAssetPerson.job_number || null, + borrow_time: borrowTime, + expected_return_time: null, + is_long_term: true, + return_time: null, + status: '已借出', + return_condition: '正常', + notes: '总表待导入 Sheet2 公共设备补挂;系统挂名到公共资产' + } + }) + + return { + id: updatedEquipment.id, + equipment_code: updatedEquipment.equipment_code, + equipment_name: updatedEquipment.equipment_name, + status: updatedEquipment.status + } + }) + + assignedExisting.push(assigned) + } + + const verification = { + publicAssetPerson: { + id: publicAssetPerson.id, + nickname: publicAssetPerson.nickname, + realName: publicAssetPerson.real_name + }, + importedCount: importResults.length, + skippedExistingImportCount: skippedExistingImports.length, + assignedExistingCount: assignedExisting.length, + sheet2PublicEquipmentCount: await prisma.equipment.count({ + where: { + AND: [ + { description: { contains: 'source_workbook=docs/总表待导入.xlsx' } }, + { description: { contains: 'source_sheet=Sheet2' } } + ] + } + }), + activePublicBorrowCount: await prisma.borrowRecord.count({ + where: { + person_id: publicAssetPerson.id, + status: '已借出', + equipment: { + AND: [ + { description: { contains: 'source_workbook=docs/总表待导入.xlsx' } }, + { description: { contains: 'source_sheet=Sheet2' } } + ] + } + } + }) + } + + console.log(JSON.stringify({ + publicAssetPerson: verification.publicAssetPerson, + importedCount: importResults.length, + skippedExistingImportCount: skippedExistingImports.length, + assignedExistingCount: assignedExisting.length, + importedSample: importResults.slice(0, 30), + assignedExistingSample: assignedExisting.slice(0, 30), + skippedExistingImportSample: skippedExistingImports.slice(0, 30), + verification + })) + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` +} + +const run = () => { + const options = parseArgs() + const connection = resolveRemoteConnection(options) + const inputDir = resolveInputDir(options.inputDir) + const payloadPath = path.join(inputDir, 'sheet2_public_ready_payload.json') + + if (!fs.existsSync(payloadPath)) { + throw new Error(`未找到 sheet2_public_ready_payload.json: ${payloadPath}`) + } + + const payloadDocument = readJson(payloadPath) + const payload = Array.isArray(payloadDocument.payload) ? payloadDocument.payload : [] + if (!payload.length) { + throw new Error('可导入 payload 为空') + } + + const backupDir = ensureOutputDir(options.dryRun ? 'sheet2_public_preview' : 'sheet2_public_apply') + writeJson(path.join(backupDir, 'planned_payload.json'), { + inputDir, + payloadCount: payload.length, + payload + }) + + const preview = runRemoteNodeScript(buildRemotePreviewScript(payload), connection) + writeJson(path.join(backupDir, 'preview_result.json'), preview) + + if (options.dryRun) { + console.log(JSON.stringify({ backupDir, inputDir, dryRun: true, preview }, null, 2)) + return + } + + const applyResult = runRemoteNodeScript(buildRemoteApplyScript(payload), connection) + writeJson(path.join(backupDir, 'apply_result.json'), applyResult) + + console.log( + JSON.stringify( + { + backupDir, + inputDir, + preview, + applyResult + }, + null, + 2 + ) + ) +} + +if (require.main === module) { + try { + run() + } catch (error) { + console.error(error && error.stack ? error.stack : String(error)) + process.exit(1) + } +} + +module.exports = { + parseArgs, + resolveRemoteConnection +} diff --git a/backend/scripts/prepareSheet2SupplementDryRun.js b/backend/scripts/prepareSheet2SupplementDryRun.js index ebe57c1..afadc9b 100644 --- a/backend/scripts/prepareSheet2SupplementDryRun.js +++ b/backend/scripts/prepareSheet2SupplementDryRun.js @@ -4,7 +4,12 @@ const { spawnSync } = require('child_process') const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')) const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs')) -const { resolveStorageLocationMatch } = require('./locationNormalization') +const { + mergeCandidateRowsWithSourceCodes, + normalizeLookupKey, + normalizeText, + resolveSheet2LocationMatch +} = require('./sheet2PublicSupplementUtils') const DEFAULT_SOURCE_WORKBOOK = path.resolve(__dirname, '../../docs/总表待导入.xlsx') const DEFAULT_SOURCE_SHEET = 'Sheet2' @@ -16,11 +21,6 @@ const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') const DOCS_ROOT = path.resolve(__dirname, '../../docs') const DEFAULT_OUTPUT_WORKBOOK_BASENAME = '总表待导入-Sheet2_公共设备补导分流-2026-03-27.xlsx' -const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe' -const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133' -const SSH_HOSTKEY = - process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA' -const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || 'jsjj8888' const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node' const DEPARTMENT_ALIASES = { @@ -64,95 +64,91 @@ const PUBLIC_STATUS_MAP = { 闲置: '可用' } -const SHEET2_LOCATION_OVERRIDES = { - 山东: '济南', - 运营中心: '常州-天安', - 客服中心: '常州-天安', - 人力行政中心: '常州-天安', - 庙西宿舍: '常州-仓库', - 财务中心: '常州-天安', - 天安17楼: '常州-天安', - 运营中心B: '常州-天安', - 供应链中心: '常州-天安', - '2楼': '常州-天安', - 湖塘摄影棚: '影棚', - 睢宁仓: '睢宁仓库', - 横林二仓仓库: '常州-仓库', - 横山桥: '常州-仓库', - 客服二部: '常州-天安', - 苏北仓: '苏北仓库', - 仓储中心: '常州-仓库', - 横林二仓二楼会议室: '常州-仓库', - 横林二仓二楼张总办公室: '常州-仓库', - 红星美凯龙: '常州-美凯龙', - 天安三楼: '常州-天安', - '1楼 客服四部': '常州-天安', - '3楼 智管信息中心': '常州-天安', - 常州运c: '常州-天安', - 二楼: '常州-天安', - 分销外贸: '常州-天安', - 佛山顺德仓: '佛山-顺德仓库', - 河北仓: '河北-仓库', - 横林二仓采购办公室: '常州-仓库', - 横林二仓仓库办公室: '常州-仓库', - 横林二仓二楼顾总办公室: '常州-仓库', - 横林二仓二楼贾总办公室: '常州-仓库', - 横林二仓二楼朱总办公室: '常州-仓库', - 横林二仓一楼仓储办公室: '常州-仓库', - 客服六部: '常州-天安', - 三楼: '常州-天安', - 山东曹县: '山东-曹县', - 事业一部: '常州-天安', - 天安10号楼三楼机房: '常州-天安', - 天安10号楼四楼厨房: '常州-天安', - 天安10号楼一楼机房: '常州-天安', - 天安四楼会议室: '常州-天安', - 运营八部: '常州-天安', - 运营十部: '常州-天安', - 总经理室: '常州-天安' -} - -const normalizeText = (value) => - String(value == null ? '' : value) - .replace(/\r/g, ' ') - .replace(/\n/g, ' ') - .replace(/\s+/g, ' ') - .trim() - -const normalizeLookupKey = (value) => normalizeText(value).toUpperCase() - const buildTimestampLabel = () => new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) -const parseArgs = () => { - const args = process.argv.slice(2) +const requireArgValue = (args, index, argumentName) => { + const value = args[index + 1] + if (!value || String(value).startsWith('--')) { + throw new Error(`${argumentName} 缺少参数值`) + } + return value +} + +const parseArgs = (args = process.argv.slice(2)) => { const options = { sourceWorkbook: DEFAULT_SOURCE_WORKBOOK, sourceSheet: DEFAULT_SOURCE_SHEET, candidateWorkbook: DEFAULT_CANDIDATE_WORKBOOK, - outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME) + outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME), + assetCodes: [], + plinkPath: '', + sshHost: '', + sshHostkey: '', + sshPassword: '' } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--source-workbook') { - options.sourceWorkbook = args[index + 1] || options.sourceWorkbook + options.sourceWorkbook = requireArgValue(args, index, arg) index += 1 } else if (arg === '--source-sheet') { - options.sourceSheet = args[index + 1] || options.sourceSheet + options.sourceSheet = requireArgValue(args, index, arg) index += 1 } else if (arg === '--candidate-workbook') { - options.candidateWorkbook = args[index + 1] || options.candidateWorkbook + options.candidateWorkbook = requireArgValue(args, index, arg) index += 1 } else if (arg === '--output-workbook') { - options.outputWorkbook = args[index + 1] || options.outputWorkbook + options.outputWorkbook = requireArgValue(args, index, arg) index += 1 + } else if (arg === '--asset-codes') { + options.assetCodes = String(requireArgValue(args, index, arg)) + .split(/[,\s]+/) + .map((item) => normalizeText(item).toUpperCase()) + .filter(Boolean) + index += 1 + } else if (arg === '--plink-path') { + options.plinkPath = requireArgValue(args, index, arg) + index += 1 + } else if (arg === '--ssh-host') { + options.sshHost = requireArgValue(args, index, arg) + index += 1 + } else if (arg === '--ssh-hostkey') { + options.sshHostkey = requireArgValue(args, index, arg) + index += 1 + } else if (arg === '--ssh-password') { + options.sshPassword = requireArgValue(args, index, arg) + index += 1 + } else { + throw new Error(`不支持的参数: ${arg}`) } } return options } +const resolveRemoteConnection = (options, env = process.env) => { + const connection = { + plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH), + sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST), + sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY), + sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || '' + } + const missing = [] + + if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path') + if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host') + if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey') + if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password') + + if (missing.length > 0) { + throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`) + } + + return connection +} + const resolvePathArg = (value) => { const normalized = normalizeText(value) if (!normalized) { @@ -255,21 +251,26 @@ const loadSourceRowMap = (sourceWorkbookPath, sourceSheetName) => { return rowMap } +const loadSourceRows = (sourceWorkbookPath, sourceSheetName) => { + const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false }) + const sheet = workbook.Sheets[sourceSheetName] + if (!sheet) { + throw new Error(`未找到源工作表: ${sourceSheetName}`) + } + + return XLSX.utils.sheet_to_json(sheet, { defval: '' }).map((row) => ({ + ...row, + 来源工作表: normalizeText(row.来源工作表) || sourceSheetName + })) +} + const loadCandidateRows = (candidateWorkbookPath) => { const workbook = XLSX.readFile(candidateWorkbookPath, { raw: false }) const sheet = workbook.Sheets[workbook.SheetNames[0]] return XLSX.utils.sheet_to_json(sheet, { defval: '' }) } -const fetchLiveRefs = () => { - if (!fs.existsSync(PLINK_PATH)) { - throw new Error(`未找到 plink.exe: ${PLINK_PATH}`) - } - - if (!SSH_PASSWORD) { - throw new Error('缺少 SSH 密码,无法读取线上固定资产基础数据') - } - +const fetchLiveRefs = (connection) => { const script = ` let PrismaClient try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } @@ -303,8 +304,17 @@ const prisma = new PrismaClient() ` const result = spawnSync( - PLINK_PATH, - ['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND], + connection.plinkPath, + [ + '-batch', + '-ssh', + '-hostkey', + connection.sshHostkey, + '-pw', + connection.sshPassword, + connection.sshHost, + REMOTE_NODE_COMMAND + ], { encoding: 'utf8', input: script, @@ -314,11 +324,11 @@ const prisma = new PrismaClient() ) if (result.error) { - throw result.error + throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。') } if (result.status !== 0) { - throw new Error(`读取线上基础数据失败: ${result.stderr || result.stdout}`) + throw new Error('读取线上基础数据失败,请检查连接参数、远程服务状态和服务器日志。') } const stdout = normalizeText(result.stdout) @@ -326,7 +336,11 @@ const prisma = new PrismaClient() throw new Error('线上基础数据返回为空') } - return JSON.parse(stdout) + try { + return JSON.parse(stdout) + } catch { + throw new Error('线上基础数据返回格式无效') + } } const buildImportDescription = (row, sourceRow) => { @@ -343,23 +357,6 @@ const buildImportDescription = (row, sourceRow) => { return parts.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`).join('; ') } -const resolveSheet2LocationMatch = (rawValue, locationByName) => { - const normalized = normalizeText(rawValue) - const override = SHEET2_LOCATION_OVERRIDES[normalized] - - if (override) { - const match = locationByName.get(normalizeLookupKey(override)) || null - return { - rawValue: normalized, - normalizedValue: override, - match, - rule: 'sheet2-confirmed-override' - } - } - - return resolveStorageLocationMatch(rawValue, locationByName) -} - const buildReadyImportPayload = (rows) => rows.map((row) => ({ legacyCode: row.资产编码, @@ -375,7 +372,7 @@ const buildReadyImportPayload = (rows) => description: row.导入备注 })) -const classifyRows = (candidateRows, sourceRowMap, liveRefs) => { +const createRowContext = (liveRefs) => { const departmentByName = new Map( liveRefs.departments.map((item) => [normalizeLookupKey(item.name), item]) ) @@ -383,52 +380,65 @@ const classifyRows = (candidateRows, sourceRowMap, liveRefs) => { liveRefs.locations.map((item) => [normalizeLookupKey(item.name), item]) ) + return { departmentByName, locationByName } +} + +const classifySingleRow = (rawRow, sourceRowMap, rowContext) => { + const { departmentByName, locationByName } = rowContext + + const assetCode = normalizeText(rawRow['资产编码']).toUpperCase() + const assetName = normalizeText(rawRow['资产名称']) + const rawDepartment = normalizeText(rawRow['部门']) + const resolvedDepartment = DEPARTMENT_ALIASES[rawDepartment] || rawDepartment + const departmentMatch = resolvedDepartment + ? departmentByName.get(normalizeLookupKey(resolvedDepartment)) || null + : null + + const rawLocation = normalizeText(rawRow['地址']) + const locationMatch = resolveSheet2LocationMatch(rawLocation, locationByName) + + const rawStatus = normalizeText(rawRow['当前状态']) + const systemStatus = PUBLIC_STATUS_MAP[rawStatus] || '' + + const sourceRow = sourceRowMap.get(assetCode) || '' + const baseRow = { + 来源工作表: normalizeText(rawRow['来源工作表']) || 'Sheet2', + 源数据行号: sourceRow, + 资产编码: assetCode, + 资产名称: assetName, + 当前状态: rawStatus, + 系统建议状态: systemStatus, + 资产分类: normalizeText(rawRow['资产分类']), + 部门: rawDepartment, + 系统归并部门: departmentMatch?.name || resolvedDepartment, + 地址: rawLocation, + 系统归并地址: locationMatch.match?.name || locationMatch.normalizedValue, + 地址归并规则: locationMatch.rule || '', + 规格型号: normalizeText(rawRow['规格型号']), + 导入备注: buildImportDescription(rawRow, sourceRow) + } + + const reasons = [] + if (!departmentMatch) { + reasons.push(`部门未匹配:${rawDepartment || '(空)'}`) + } + if (!locationMatch.match) { + reasons.push(`地址未匹配:${rawLocation || '(空)'}`) + } + if (!systemStatus) { + reasons.push(`状态无法自动映射:${rawStatus || '(空)'}`) + } + + return { baseRow, reasons } +} + +const classifyRows = (candidateRows, sourceRowMap, liveRefs) => { + const rowContext = createRowContext(liveRefs) const readyRows = [] const pendingRows = [] for (const rawRow of candidateRows) { - const assetCode = normalizeText(rawRow['资产编码']).toUpperCase() - const assetName = normalizeText(rawRow['资产名称']) - const rawDepartment = normalizeText(rawRow['部门']) - const resolvedDepartment = DEPARTMENT_ALIASES[rawDepartment] || rawDepartment - const departmentMatch = resolvedDepartment - ? departmentByName.get(normalizeLookupKey(resolvedDepartment)) || null - : null - - const rawLocation = normalizeText(rawRow['地址']) - const locationMatch = resolveSheet2LocationMatch(rawLocation, locationByName) - - const rawStatus = normalizeText(rawRow['当前状态']) - const systemStatus = PUBLIC_STATUS_MAP[rawStatus] || '' - - const sourceRow = sourceRowMap.get(assetCode) || '' - const baseRow = { - 来源工作表: normalizeText(rawRow['来源工作表']) || 'Sheet2', - 源数据行号: sourceRow, - 资产编码: assetCode, - 资产名称: assetName, - 当前状态: rawStatus, - 系统建议状态: systemStatus, - 资产分类: normalizeText(rawRow['资产分类']), - 部门: rawDepartment, - 系统归并部门: departmentMatch?.name || resolvedDepartment, - 地址: rawLocation, - 系统归并地址: locationMatch.match?.name || locationMatch.normalizedValue, - 地址归并规则: locationMatch.rule || '', - 规格型号: normalizeText(rawRow['规格型号']), - 导入备注: buildImportDescription(rawRow, sourceRow) - } - - const reasons = [] - if (!departmentMatch) { - reasons.push(`部门未匹配:${rawDepartment || '(空)'}`) - } - if (!locationMatch.match) { - reasons.push(`地址未匹配:${rawLocation || '(空)'}`) - } - if (!systemStatus) { - reasons.push(`状态无法自动映射:${rawStatus || '(空)'}`) - } + const { baseRow, reasons } = classifySingleRow(rawRow, sourceRowMap, rowContext) if (!reasons.length) { readyRows.push(baseRow) @@ -447,12 +457,39 @@ const classifyRows = (candidateRows, sourceRowMap, liveRefs) => { return { readyRows, pendingRows } } -const buildSummaryRows = ({ total, ready, pending, addressPending, otherPending, liveRefs }) => [ +const buildDuplicatePendingRows = (duplicateSourceCodes, sourceRowMap, liveRefs) => { + const rowContext = createRowContext(liveRefs) + + return duplicateSourceCodes.map((item) => { + const firstRow = item.rows[0] || {} + const { baseRow } = classifySingleRow(firstRow, sourceRowMap, rowContext) + return { + ...baseRow, + 待核对原因: '同一资产编码在 Sheet2 内对应了不同设备信息,不能自动判断。', + 建议处理: '同一编码对应了多条不同设备信息,先确认哪条才是真实记录' + } + }) +} + +const buildSummaryRows = ({ + total, + ready, + pending, + addressPending, + otherPending, + liveRefs, + explicitCodeCount, + explicitMissingCodeCount, + duplicatePendingCount +}) => [ { 项目: '候选总数', 说明: total }, { 项目: '可直接补录', 说明: ready }, { 项目: '待人工核对', 说明: pending }, { 项目: '其中地址待核对', 说明: addressPending }, { 项目: '其中其他待核对', 说明: otherPending }, + { 项目: '本次显式补回编码数', 说明: explicitCodeCount }, + { 项目: '显式编码仍未在源表找到', 说明: explicitMissingCodeCount }, + { 项目: '显式编码重复冲突数', 说明: duplicatePendingCount }, { 项目: '状态映射规则', 说明: '在用/闲置 -> 系统“可用”;其余状态不自动导入' }, { 项目: '人员处理规则', 说明: '本批当前使用员工全部为空,按公共设备处理,不自动建立借出记录' }, { 项目: '线上部门数', 说明: liveRefs.departments.length }, @@ -462,6 +499,7 @@ const buildSummaryRows = ({ total, ready, pending, addressPending, otherPending, const run = async () => { const options = parseArgs() + const connection = resolveRemoteConnection(options) const sourceWorkbookPath = resolvePathArg(options.sourceWorkbook) const candidateWorkbookPath = resolvePathArg(options.candidateWorkbook) @@ -469,19 +507,29 @@ const run = async () => { throw new Error(`源工作簿不存在: ${sourceWorkbookPath}`) } - if (!fs.existsSync(candidateWorkbookPath)) { + if (!fs.existsSync(candidateWorkbookPath) && options.assetCodes.length === 0) { throw new Error(`候选工作簿不存在: ${candidateWorkbookPath}`) } const outputDir = ensureOutputDir() const workbookPath = resolveAvailableWorkbookPath(options.outputWorkbook) const sourceRowMap = loadSourceRowMap(sourceWorkbookPath, options.sourceSheet) - const candidateRows = loadCandidateRows(candidateWorkbookPath) - const liveRefs = fetchLiveRefs() + const sourceRows = loadSourceRows(sourceWorkbookPath, options.sourceSheet) + const baseCandidateRows = fs.existsSync(candidateWorkbookPath) + ? loadCandidateRows(candidateWorkbookPath) + : [] + const { + rows: candidateRows, + missingCodes: missingSourceCodes, + duplicateSourceCodes + } = mergeCandidateRowsWithSourceCodes(baseCandidateRows, sourceRows, options.assetCodes) + const liveRefs = fetchLiveRefs(connection) const { readyRows, pendingRows } = classifyRows(candidateRows, sourceRowMap, liveRefs) + const duplicatePendingRows = buildDuplicatePendingRows(duplicateSourceCodes, sourceRowMap, liveRefs) + const allPendingRows = [...pendingRows, ...duplicatePendingRows] - const addressPendingRows = pendingRows.filter((row) => row.待核对原因.includes('地址未匹配')) - const otherPendingRows = pendingRows.filter((row) => !row.待核对原因.includes('地址未匹配')) + const addressPendingRows = allPendingRows.filter((row) => row.待核对原因.includes('地址未匹配')) + const otherPendingRows = allPendingRows.filter((row) => !row.待核对原因.includes('地址未匹配')) const readyPayload = buildReadyImportPayload(readyRows) const manifest = { @@ -489,9 +537,12 @@ const run = async () => { sourceWorkbook: sourceWorkbookPath, sourceSheet: options.sourceSheet, candidateWorkbook: candidateWorkbookPath, - totalCandidates: candidateRows.length, + explicitAssetCodes: options.assetCodes, + explicitMissingCodes: missingSourceCodes, + duplicateSourceCodes: duplicateSourceCodes.map((item) => item.code), + totalCandidates: candidateRows.length + duplicatePendingRows.length, readyCount: readyRows.length, - pendingCount: pendingRows.length, + pendingCount: allPendingRows.length, addressPendingCount: addressPendingRows.length, otherPendingCount: otherPendingRows.length, rulesVersion: 'sheet2-public-supplement-v1', @@ -506,16 +557,19 @@ const run = async () => { { name: '汇总说明', rows: buildSummaryRows({ - total: candidateRows.length, + total: candidateRows.length + duplicatePendingRows.length, ready: readyRows.length, - pending: pendingRows.length, + pending: allPendingRows.length, addressPending: addressPendingRows.length, otherPending: otherPendingRows.length, - liveRefs + liveRefs, + explicitCodeCount: options.assetCodes.length, + explicitMissingCodeCount: missingSourceCodes.length, + duplicatePendingCount: duplicatePendingRows.length }) }, { name: '可直接补录', rows: readyRows }, - { name: '待人工核对', rows: pendingRows }, + { name: '待人工核对', rows: allPendingRows }, { name: '地址待核对', rows: addressPendingRows }, { name: '其他待核对', rows: otherPendingRows.length ? otherPendingRows : [{ 说明: '当前无记录' }] } ]) @@ -526,8 +580,8 @@ const run = async () => { payload: readyPayload }) writeJson(path.join(outputDir, 'sheet2_public_pending_rows.json'), { - pendingCount: pendingRows.length, - rows: pendingRows + pendingCount: allPendingRows.length, + rows: allPendingRows }) writeJson(path.join(outputDir, 'sheet2_live_refs.json'), liveRefs) @@ -544,7 +598,14 @@ const run = async () => { ) } -run().catch((error) => { - console.error(error && error.stack ? error.stack : String(error)) - process.exit(1) -}) +if (require.main === module) { + run().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)) + process.exit(1) + }) +} + +module.exports = { + parseArgs, + resolveRemoteConnection +} diff --git a/backend/scripts/sheet2PublicAssetImportUtils.js b/backend/scripts/sheet2PublicAssetImportUtils.js new file mode 100644 index 0000000..aff80cb --- /dev/null +++ b/backend/scripts/sheet2PublicAssetImportUtils.js @@ -0,0 +1,51 @@ +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES = ['可用'] + +const isSheet2PublicEquipmentDescription = (description) => { + const normalized = normalizeText(description) + if (!normalized) { + return false + } + + return ( + normalized.includes('source_workbook=docs/总表待导入.xlsx') && + normalized.includes('source_sheet=Sheet2') + ) +} + +const buildPublicAssetBorrowState = (unitPrice) => { + const normalizedUnitPrice = Number(unitPrice || 0) + + return { + equipmentStatus: '已借出', + remainingQuantity: 0, + totalValue: normalizedUnitPrice, + borrowedValue: normalizedUnitPrice, + availableValue: 0 + } +} + +const buildAssignableExistingSheet2PublicEquipmentWhere = ( + assignableStatuses = PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES +) => ({ + AND: [ + { description: { contains: 'source_workbook=docs/总表待导入.xlsx' } }, + { description: { contains: 'source_sheet=Sheet2' } } + ], + status: { in: assignableStatuses }, + borrow_records: { none: { status: '已借出' } } +}) + +module.exports = { + buildAssignableExistingSheet2PublicEquipmentWhere, + PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES, + buildPublicAssetBorrowState, + isSheet2PublicEquipmentDescription, + normalizeText +} diff --git a/backend/scripts/sheet2PublicSupplementUtils.js b/backend/scripts/sheet2PublicSupplementUtils.js new file mode 100644 index 0000000..5c6fa40 --- /dev/null +++ b/backend/scripts/sheet2PublicSupplementUtils.js @@ -0,0 +1,231 @@ +const { resolveStorageLocationMatch } = require('./locationNormalization') + +const SHEET2_LOCATION_OVERRIDES = { + 山东: '济南', + 运营中心: '常州-天安', + 客服中心: '常州-天安', + 人力行政中心: '常州-天安', + 庙西宿舍: '常州-仓库', + 财务中心: '常州-天安', + 天安17楼: '常州-天安', + 运营中心B: '常州-天安', + 供应链中心: '常州-天安', + '2楼': '常州-天安', + 湖塘摄影棚: '影棚', + 睢宁仓: '苏北仓库', + 横林二仓仓库: '常州-仓库', + 横山桥: '常州-仓库', + 客服二部: '常州-天安', + 苏北仓: '苏北仓库', + 仓储中心: '常州-仓库', + 横林二仓二楼会议室: '常州-仓库', + 横林二仓二楼张总办公室: '常州-仓库', + 红星美凯龙: '常州-美凯龙', + 天安三楼: '常州-天安', + '1楼 客服四部': '常州-天安', + '3楼 智管信息中心': '常州-天安', + 常州运c: '常州-天安', + 二楼: '常州-天安', + 分销外贸: '常州-天安', + 佛山顺德仓: '佛山-顺德仓库', + 河北仓: '河北-仓库', + 横林二仓采购办公室: '常州-仓库', + 横林二仓仓库办公室: '常州-仓库', + 横林二仓二楼顾总办公室: '常州-仓库', + 横林二仓二楼贾总办公室: '常州-仓库', + 横林二仓二楼朱总办公室: '常州-仓库', + 横林二仓一楼仓储办公室: '常州-仓库', + 客服六部: '常州-天安', + 三楼: '常州-天安', + 山东曹县: '山东-曹县', + 事业一部: '常州-天安', + 天安10号楼三楼机房: '常州-天安', + 天安10号楼四楼厨房: '常州-天安', + 天安10号楼一楼机房: '常州-天安', + 天安四楼会议室: '常州-天安', + 运营八部: '常州-天安', + 运营十部: '常州-天安', + 总经理室: '常州-天安' +} + +const SHEET2_LOCATION_PREFIXES = ['仓储一部', '仓储二部', '仓储三部'] + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const normalizeLookupKey = (value) => normalizeText(value).toUpperCase() + +const stripSheet2LocationPrefix = (rawValue) => { + const normalized = normalizeText(rawValue) + if (!normalized) { + return { + rawValue: '', + strippedValue: '', + prefix: '', + changed: false + } + } + + for (const prefix of SHEET2_LOCATION_PREFIXES) { + const marker = `${prefix} ` + if (normalized.startsWith(marker)) { + const strippedValue = normalizeText(normalized.slice(marker.length)) + return { + rawValue: normalized, + strippedValue, + prefix, + changed: strippedValue !== normalized + } + } + } + + return { + rawValue: normalized, + strippedValue: normalized, + prefix: '', + changed: false + } +} + +const resolveSheet2LocationMatch = (rawValue, locationByName) => { + const normalized = normalizeText(rawValue) + if (!normalized) { + return { + rawValue: '', + normalizedValue: '', + match: null, + rule: '' + } + } + + const directOverride = SHEET2_LOCATION_OVERRIDES[normalized] + if (directOverride) { + const match = locationByName.get(normalizeLookupKey(directOverride)) || null + return { + rawValue: normalized, + normalizedValue: directOverride, + match, + rule: 'sheet2-confirmed-override' + } + } + + const directMatch = resolveStorageLocationMatch(normalized, locationByName) + if (directMatch.match) { + return directMatch + } + + const stripped = stripSheet2LocationPrefix(normalized) + if (!stripped.changed) { + return directMatch + } + + const strippedOverride = SHEET2_LOCATION_OVERRIDES[stripped.strippedValue] + if (strippedOverride) { + const match = locationByName.get(normalizeLookupKey(strippedOverride)) || null + return { + rawValue: normalized, + normalizedValue: strippedOverride, + match, + rule: 'sheet2-prefix-override' + } + } + + const strippedMatch = resolveStorageLocationMatch(stripped.strippedValue, locationByName) + if (strippedMatch.match) { + return { + rawValue: normalized, + normalizedValue: strippedMatch.normalizedValue, + match: strippedMatch.match, + rule: 'sheet2-prefix-alias' + } + } + + return { + rawValue: normalized, + normalizedValue: stripped.strippedValue, + match: null, + rule: 'sheet2-prefix-stripped' + } +} + +const mergeCandidateRowsWithSourceCodes = (candidateRows, sourceRows, assetCodes) => { + const normalizedCodes = [...new Set((assetCodes || []).map((code) => normalizeText(code).toUpperCase()).filter(Boolean))] + if (!normalizedCodes.length) { + return { + rows: [...candidateRows], + missingCodes: [], + duplicateSourceCodes: [] + } + } + + const sourceRowsByCode = new Map() + for (const row of sourceRows || []) { + const code = normalizeText(row.资产编码).toUpperCase() + if (!code) { + continue + } + if (!sourceRowsByCode.has(code)) { + sourceRowsByCode.set(code, []) + } + sourceRowsByCode.get(code).push(row) + } + + const existingCodes = new Set( + (candidateRows || []).map((row) => normalizeText(row.资产编码).toUpperCase()).filter(Boolean) + ) + + const rows = [...candidateRows] + const missingCodes = [] + const duplicateSourceCodes = [] + + for (const code of normalizedCodes) { + if (existingCodes.has(code)) { + continue + } + + const sourceCodeRows = sourceRowsByCode.get(code) || [] + if (!sourceCodeRows.length) { + missingCodes.push(code) + continue + } + + if (sourceCodeRows.length > 1) { + duplicateSourceCodes.push({ + code, + rows: sourceCodeRows.map((row) => ({ + ...row, + 来源工作表: normalizeText(row.来源工作表) || 'Sheet2' + })) + }) + continue + } + + const sourceRow = sourceCodeRows[0] + + rows.push({ + ...sourceRow, + 来源工作表: normalizeText(sourceRow.来源工作表) || 'Sheet2' + }) + existingCodes.add(code) + } + + return { + rows, + missingCodes, + duplicateSourceCodes + } +} + +module.exports = { + SHEET2_LOCATION_OVERRIDES, + SHEET2_LOCATION_PREFIXES, + mergeCandidateRowsWithSourceCodes, + normalizeText, + normalizeLookupKey, + resolveSheet2LocationMatch, + stripSheet2LocationPrefix +} diff --git a/backend/src/controllers/__tests__/equipmentController.search.test.ts b/backend/src/controllers/__tests__/equipmentController.search.test.ts new file mode 100644 index 0000000..0cc7e4d --- /dev/null +++ b/backend/src/controllers/__tests__/equipmentController.search.test.ts @@ -0,0 +1,167 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { getEquipmentList } from '../equipmentController' + +const createMockResponse = () => { + const responseBody: { statusCode: number; body: any } = { + statusCode: 200, + body: null + } + + const res = { + 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.equipment.findMany + const originalCount = prisma.equipment.count + + let capturedWhere: any = null + + ;(prisma.equipment.findMany as any) = async (args: any) => { + capturedWhere = args.where + return [] + } + + ;(prisma.equipment.count as any) = async () => 0 + + try { + const { res, responseBody } = createMockResponse() + + await getEquipmentList( + { + query: { + keyword: '2227', + page: '1', + pageSize: '10' + } + } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 200) + assert.ok(capturedWhere) + + const serializedWhere = JSON.stringify(capturedWhere) + + assert.equal( + serializedWhere.includes('id_number'), + false, + '资产列表搜索不应包含 person.id_number 的模糊搜索' + ) + assert.equal( + serializedWhere.includes('person_identifier'), + false, + '资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中' + ) + assert.equal( + serializedWhere.includes('job_number'), + true, + '需要保留按人员真实工号搜索借用资产的能力' + ) + } finally { + ;(prisma.equipment.findMany as any) = originalFindMany + ;(prisma.equipment.count as any) = originalCount + } +}) + +test('资产列表最近变动排序会把有最新挂载记录的父资产排在前面', async () => { + const originalFindMany = prisma.equipment.findMany + const originalCount = prisma.equipment.count + const originalGroupBy = prisma.mountHistory.groupBy + + const olderParent = { + id: 1, + equipment_code: 'JS-PC-0001', + equipment_name: '旧主机', + equipment_type: '主机', + status: '可用', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 0, + updated_at: new Date('2026-03-01T00:00:00Z'), + children: [], + borrow_records: [] + } + const recentlyMountedParent = { + id: 2, + equipment_code: 'JS-PC-0002', + equipment_name: '刚换内存的主机', + equipment_type: '主机', + status: '可用', + total_quantity: 1, + remaining_quantity: 1, + unit_price: 0, + updated_at: new Date('2026-03-01T00:00:00Z'), + children: [], + borrow_records: [] + } + + const candidates = [ + { id: olderParent.id, updated_at: olderParent.updated_at, children: [] }, + { id: recentlyMountedParent.id, updated_at: recentlyMountedParent.updated_at, children: [] } + ] + const equipmentById = new Map([ + [olderParent.id, olderParent], + [recentlyMountedParent.id, recentlyMountedParent] + ]) + + ;(prisma.equipment.findMany as any) = async (args: any) => { + if (args.select) { + return candidates + } + + const ids = args.where.id.in as number[] + return ids.map((id) => equipmentById.get(id)) + } + + ;(prisma.equipment.count as any) = async () => candidates.length + + ;(prisma.mountHistory.groupBy as any) = async () => [ + { + parent_id: recentlyMountedParent.id, + _max: { + operated_at: new Date('2026-04-01T00:00:00Z') + } + } + ] + + try { + const { res, responseBody } = createMockResponse() + + await getEquipmentList( + { + query: { + sort: 'recent_change', + page: '1', + pageSize: '10' + } + } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 200) + assert.equal(responseBody.body.data.total, 2) + assert.equal(responseBody.body.data.list[0].id, recentlyMountedParent.id) + assert.equal( + responseBody.body.data.list[0].recent_change_at.getTime(), + new Date('2026-04-01T00:00:00Z').getTime() + ) + } finally { + ;(prisma.equipment.findMany as any) = originalFindMany + ;(prisma.equipment.count as any) = originalCount + ;(prisma.mountHistory.groupBy as any) = originalGroupBy + } +}) diff --git a/backend/src/controllers/__tests__/equipmentController.update-status.test.ts b/backend/src/controllers/__tests__/equipmentController.update-status.test.ts new file mode 100644 index 0000000..ccecc46 --- /dev/null +++ b/backend/src/controllers/__tests__/equipmentController.update-status.test.ts @@ -0,0 +1,102 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { updateEquipment } from '../equipmentController' + +const createMockResponse = () => { + const responseBody: { statusCode: number; body: any } = { + statusCode: 200, + body: null + } + + const res = { + status(code: number) { + responseBody.statusCode = code + return this + }, + json(payload: any) { + responseBody.body = payload + return this + } + } + + return { res, responseBody } +} + +test('普通编辑资产时不应把待报废资产冲回可用', async () => { + const originalFindUnique = prisma.equipment.findUnique + const originalUpdate = prisma.equipment.update + const originalCount = prisma.equipment.count + + const currentEquipment = { + id: 903, + equipment_code: 'JS-PC-0119', + equipment_name: '主机', + equipment_model: '', + equipment_type: '主机', + unit_price: 0, + total_quantity: 1, + remaining_quantity: 1, + total_value: 0, + borrowed_value: 0, + available_value: 0, + purchase_date: null, + supplier: '', + description: '', + status: '待报废', + storage_location_id: null, + department_id: null, + asset_type_id: null, + parent_id: null + } + + let findUniqueCallCount = 0 + let capturedUpdateData: any = null + + ;(prisma.equipment.findUnique as any) = async () => { + findUniqueCallCount += 1 + return currentEquipment + } + + ;(prisma.equipment.update as any) = async (args: any) => { + capturedUpdateData = args.data + return { + ...currentEquipment, + ...args.data + } + } + + ;(prisma.equipment.count as any) = async () => 0 + + try { + const { res, responseBody } = createMockResponse() + + await updateEquipment( + { + params: { id: '903' }, + body: { + equipment_name: '主机', + equipment_model: '', + equipment_type: '主机', + unit_price: 0, + total_quantity: 1, + status: '可用', + purchase_date: '', + supplier: '', + description: '' + } + } as any, + res as any + ) + + assert.equal(responseBody.statusCode, 200) + assert.ok(capturedUpdateData, '应当调用 equipment.update') + assert.equal(findUniqueCallCount >= 1, true) + assert.equal(capturedUpdateData.status, '待报废') + } finally { + ;(prisma.equipment.findUnique as any) = originalFindUnique + ;(prisma.equipment.update as any) = originalUpdate + ;(prisma.equipment.count as any) = originalCount + } +}) diff --git a/backend/src/controllers/batchController.ts b/backend/src/controllers/batchController.ts index 296efe7..1094780 100644 --- a/backend/src/controllers/batchController.ts +++ b/backend/src/controllers/batchController.ts @@ -48,7 +48,7 @@ const batchBorrowTransaction = async ( person_id: person.id, person_name: person.real_name, person_nickname: person.nickname, - person_identifier: person.id_number, + person_identifier: person.job_number, expected_return_time: isLongTerm ? null : (expectedReturnTime ? new Date(expectedReturnTime) : null), is_long_term: Boolean(isLongTerm), notes: finalNotes, diff --git a/backend/src/controllers/batchImportController.ts b/backend/src/controllers/batchImportController.ts index 7ed62ca..5ae1940 100644 --- a/backend/src/controllers/batchImportController.ts +++ b/backend/src/controllers/batchImportController.ts @@ -8,6 +8,7 @@ import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' interface PersonImportData { nickname: string real_name: string + job_number?: string id_number?: string department?: string position?: string @@ -66,12 +67,13 @@ export const batchImportPersons = async (req: Request, res: Response) => { } // 检查工号是否重复 - if (person.id_number) { + const normalizedJobNumber = normalizeOptionalText(person.job_number) + if (normalizedJobNumber) { const existing = await prisma.person.findUnique({ - where: { id_number: person.id_number } + where: { job_number: normalizedJobNumber } }) if (existing) { - results.errors.push({ row: rowNum, message: `工号 ${person.id_number} 已存在` }) + results.errors.push({ row: rowNum, message: `工号 ${normalizedJobNumber} 已存在` }) results.failed++ continue } @@ -83,11 +85,22 @@ export const batchImportPersons = async (req: Request, res: Response) => { : null await prisma.$transaction(async (tx) => { + const normalizedIdNumber = normalizeOptionalText(person.id_number) + if (normalizedIdNumber) { + const existingByIdNumber = await tx.person.findUnique({ + where: { id_number: normalizedIdNumber } + }) + if (existingByIdNumber) { + throw new Error(`身份证号 ${normalizedIdNumber} 已存在`) + } + } + const created = await tx.person.create({ data: { nickname: person.nickname, real_name: person.real_name, - id_number: person.id_number || null, + job_number: normalizedJobNumber, + id_number: normalizedIdNumber, department: normalizedDepartment, main_department_id: matchedDepartmentId, position: person.position || null, diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index 75e30e9..723c138 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -11,6 +11,10 @@ import { normalizeReturnDepartmentInput, resolveBorrowDepartmentUpdate } from '../utils/borrowDepartment' +import { + buildParentAssetFirstOrderBy, + sortParentAssetsFirst +} from '../utils/equipmentSearchPriority' import { buildBusinessDepartmentWhere } from '../utils/organization' import { logOperation } from './operationLogController' @@ -137,12 +141,20 @@ const ensureBorrowAnomalyHandleable = ( return anomalyMeta } -const ensurePersonAvailable = async (personId?: number) => { +const ensurePersonAvailable = async ( + personId?: number, + options: { + allowInactive?: boolean + } = {} +) => { if (!personId) { throw new Error('使用者ID不能为空') } const person = await prisma.person.findUnique({ where: { id: personId } }) - if (!person || person.status !== 'active') { + if (!person) { + throw new Error('使用者不存在') + } + if (!options.allowInactive && person.status !== 'active') { throw new Error('使用者不存在或已禁用') } return person @@ -252,7 +264,7 @@ const borrowEquipmentInTransaction = async ( person_id: person.id, person_name: person.real_name, person_nickname: person.nickname, - person_identifier: person.id_number, + person_identifier: person.job_number, expected_return_time: expectedReturn, is_long_term: Boolean(isLongTerm), notes: combinedNotes, @@ -493,7 +505,7 @@ const registerBorrowAnomalyInTransaction = async ( person_id: person.id, person_name: person.real_name, person_nickname: person.nickname, - person_identifier: person.id_number, + person_identifier: person.job_number, borrow_time: borrowTime, notes: notes?.trim() || null, status: '已借出', @@ -536,6 +548,8 @@ const mapBorrowRecord = (record: any) => ({ 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, department: record.person.mainDepartment?.name || record.person.department, main_department: record.person.mainDepartment || null, phone: record.person.phone, @@ -1223,7 +1237,7 @@ export const searchReturnAssets = async (req: Request, res: Response) => { : []) ] }, - orderBy: [{ updated_at: 'desc' }, { id: 'desc' }], + orderBy: buildParentAssetFirstOrderBy([{ updated_at: 'desc' }, { id: 'desc' }]), select: { id: true, equipment_code: true, @@ -1275,7 +1289,7 @@ export const searchReturnAssets = async (req: Request, res: Response) => { } } - const list = equipmentList.map((equipment) => { + const list = sortParentAssetsFirst(equipmentList).map((equipment) => { const borrowerNames: string[] = [] const borrowerIdentifiers: string[] = [] const borrowerDepartments: string[] = [] @@ -1375,7 +1389,8 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => { const { person_id, borrow_time, notes } = req.body as Record let person try { - person = await ensurePersonAvailable(parseNumber(person_id)) + // 异常借出补登记需要支持历史离职/停用人员,便于行政处理名下资产。 + person = await ensurePersonAvailable(parseNumber(person_id), { allowInactive: true }) } catch (error: any) { return errorResponse(res, error.message || '使用者验证失败', 422) } diff --git a/backend/src/controllers/equipmentController.ts b/backend/src/controllers/equipmentController.ts index 64e98d8..640f44d 100644 --- a/backend/src/controllers/equipmentController.ts +++ b/backend/src/controllers/equipmentController.ts @@ -84,6 +84,7 @@ const activeBorrowSelect = { id: true, nickname: true, real_name: true, + job_number: true, id_number: true } } @@ -91,6 +92,8 @@ const activeBorrowSelect = { } } +const EQUIPMENT_LIST_SORT_MODES = new Set(['created_desc', 'updated_desc', 'recent_change']) + const includeEquipmentRelations = { ...includeStorageLocation, parent: { @@ -143,12 +146,23 @@ 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 } : null } } +const getLatestTimestamp = (...values: Array) => { + return values.reduce((latest, value) => { + if (!value) return latest + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return latest + if (!latest || date.getTime() > latest.getTime()) return date + return latest + }, null) +} + const serializeEquipmentListItem = (equipment: any) => { const children = Array.isArray(equipment.children) ? equipment.children.map((child: any) => { @@ -161,10 +175,22 @@ const serializeEquipmentListItem = (equipment: any) => { : [] const { borrow_records, ...restEquipment } = equipment + const latestMountChangeAt = getLatestTimestamp( + equipment.latest_parent_mount_change_at, + equipment.latest_child_mount_change_at + ) + const recentChangeAt = getLatestTimestamp( + equipment.recent_change_at, + restEquipment.updated_at, + ...children.map((child: any) => child.updated_at), + latestMountChangeAt + ) return { ...restEquipment, children, + latest_mount_change_at: latestMountChangeAt, + recent_change_at: recentChangeAt, current_borrow: mapCurrentBorrow(borrow_records), borrow_anomaly: getBorrowAnomalyMeta({ status: restEquipment.status, @@ -180,6 +206,34 @@ const normalizeEntryMode = (value: unknown): EquipmentEntryMode => { return 'single' } +const EDIT_LOCKED_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏']) + +const resolveEditableEquipmentStatus = ({ + currentStatus, + requestedStatus, + remainingQuantity +}: { + currentStatus: string + requestedStatus?: unknown + remainingQuantity: number +}) => { + const normalizedRequestedStatus = + typeof requestedStatus === 'string' && requestedStatus.trim() + ? requestedStatus.trim() + : undefined + const autoDerivedStatus = remainingQuantity > 0 ? '可用' : '已借出' + + if (EDIT_LOCKED_STATUSES.has(currentStatus)) { + return currentStatus + } + + if (normalizedRequestedStatus && EDIT_LOCKED_STATUSES.has(normalizedRequestedStatus)) { + return autoDerivedStatus + } + + return normalizedRequestedStatus || autoDerivedStatus +} + const ensureLocationExists = async (client: any, locationId?: number | null) => { if (!locationId) { throw new Error('地址信息不能为空') @@ -361,10 +415,13 @@ const createEquipmentRecord = async ( // 获取设备列表 export const getEquipmentList = async (req: Request, res: Response) => { try { - const { page = 1, pageSize = 10, equipmentType, status, keyword, storage_location_id, department_id, asset_type_id, mount_status, parent_id } = req.query + const { page = 1, pageSize = 10, equipmentType, status, keyword, storage_location_id, department_id, asset_type_id, mount_status, parent_id, sort } = req.query const where: any = {} const hasParentFilter = !Number.isNaN(Number(parent_id)) && Boolean(parent_id) + const sortMode = typeof sort === 'string' && EQUIPMENT_LIST_SORT_MODES.has(sort) + ? sort + : 'created_desc' if (equipmentType) { where.equipment_type = equipmentType @@ -401,7 +458,6 @@ export const getEquipmentList = async (req: Request, res: Response) => { OR: [ { person_nickname: { contains: normalizedKeyword } }, { person_name: { contains: normalizedKeyword } }, - { person_identifier: { contains: normalizedKeyword } }, { person: { is: { @@ -419,7 +475,7 @@ export const getEquipmentList = async (req: Request, res: Response) => { { person: { is: { - id_number: { contains: normalizedKeyword } + job_number: { contains: normalizedKeyword } } } } @@ -482,40 +538,133 @@ export const getEquipmentList = async (req: Request, res: Response) => { } const skip = (Number(page) - 1) * Number(pageSize) + const take = Number(pageSize) + const listInclude = { + ...includeStorageLocation, + ...activeBorrowSelect, + parent: { + select: { + id: true, + equipment_code: true, + equipment_name: true + } + }, + children: { + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + unit_price: true, + status: true, + mount_status: true, + updated_at: true, + ...activeBorrowSelect + } + } + } - const [list, total] = await Promise.all([ - prisma.equipment.findMany({ + let list: any[] = [] + let total = 0 + + if (sortMode === 'recent_change') { + const candidates = await prisma.equipment.findMany({ where, - skip, - take: Number(pageSize), - orderBy: { created_at: 'desc' }, - include: { - ...includeStorageLocation, - ...activeBorrowSelect, - parent: { - select: { - id: true, - equipment_code: true, - equipment_name: true - } - }, + select: { + id: true, + updated_at: true, children: { select: { - id: true, - equipment_code: true, - equipment_name: true, - equipment_model: true, - equipment_type: true, - unit_price: true, - status: true, - mount_status: true, - ...activeBorrowSelect + updated_at: true } } } - }), + }) + total = candidates.length + + const candidateIds = candidates.map((item) => item.id) + const candidateLatestChange = new Map() + for (const candidate of candidates) { + candidateLatestChange.set( + candidate.id, + getLatestTimestamp( + candidate.updated_at, + ...candidate.children.map((child) => child.updated_at) + ) + ) + } + + if (candidateIds.length > 0) { + const groupedHistory = hasParentFilter + ? await prisma.mountHistory.groupBy({ + by: ['child_id'], + where: { + child_id: { in: candidateIds } + }, + _max: { + operated_at: true + } + }) + : await prisma.mountHistory.groupBy({ + by: ['parent_id'], + where: { + parent_id: { in: candidateIds } + }, + _max: { + operated_at: true + } + }) + + for (const item of groupedHistory) { + const targetId = hasParentFilter ? (item as any).child_id : (item as any).parent_id + if (!targetId) continue + candidateLatestChange.set( + targetId, + getLatestTimestamp(candidateLatestChange.get(targetId), item._max.operated_at) + ) + } + } + + const pageIds = [...candidateIds] + .sort((leftId, rightId) => { + const leftTime = candidateLatestChange.get(leftId)?.getTime() || 0 + const rightTime = candidateLatestChange.get(rightId)?.getTime() || 0 + return rightTime - leftTime || rightId - leftId + }) + .slice(skip, skip + take) + + if (pageIds.length > 0) { + const pageIdOrder = new Map(pageIds.map((id, index) => [id, index])) + const pageList = await prisma.equipment.findMany({ + where: { + id: { in: pageIds } + }, + include: listInclude + }) + list = pageList + .map((item) => ({ + ...item, + recent_change_at: candidateLatestChange.get(item.id) || null + })) + .sort((left, right) => (pageIdOrder.get(left.id) || 0) - (pageIdOrder.get(right.id) || 0)) + } + } else { + const orderBy = sortMode === 'updated_desc' + ? [{ updated_at: 'desc' as const }, { id: 'desc' as const }] + : [{ created_at: 'desc' as const }, { id: 'desc' as const }] + + ;[list, total] = await Promise.all([ + prisma.equipment.findMany({ + where, + skip, + take, + orderBy, + include: listInclude + }), prisma.equipment.count({ where }) - ]) + ]) + } successResponse( res, @@ -777,12 +926,11 @@ export const updateEquipment = async (req: Request, res: Response) => { total_quantity !== undefined ? Math.max(finalTotalQuantity - borrowedQuantity, 0) : currentEquipment.remaining_quantity - const resolvedStatus = status - ?? ( - ['维护中', '待报废', '已报废', '已损坏'].includes(currentEquipment.status) - ? currentEquipment.status - : (newRemainingQuantity > 0 ? '可用' : '已借出') - ) + const resolvedStatus = resolveEditableEquipmentStatus({ + currentStatus: currentEquipment.status, + requestedStatus: status, + remainingQuantity: newRemainingQuantity + }) const values = calculateEquipmentValues({ unitPrice: finalUnitPrice, totalQuantity: finalTotalQuantity, diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index 3a78c38..678a2b1 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -289,7 +289,8 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { if (person_name) { where.OR = [ { person_name: { contains: String(person_name) } }, - { person_nickname: { contains: String(person_name) } } + { person_nickname: { contains: String(person_name) } }, + { person_identifier: { contains: String(person_name) } } ] } if (start_date || end_date) { @@ -306,6 +307,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { select: { nickname: true, real_name: true, + job_number: true, department: true, mainDepartment: { select: { name: true } @@ -324,6 +326,8 @@ 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 }, { header: '预计归还', key: 'expected_return_time', width: 20 }, @@ -343,6 +347,8 @@ 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') : '', expected_return_time: record.is_long_term ? '长期' : (record.expected_return_time ? new Date(record.expected_return_time).toLocaleDateString('zh-CN') : ''), @@ -393,6 +399,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => { { header: '昵称 *', key: 'nickname', width: 15 }, { header: '真实姓名 *', key: 'real_name', width: 15 }, { header: '工号', key: 'employee_id', width: 15 }, + { header: '身份证号', key: 'id_number', width: 22 }, { header: '部门', key: 'department', width: 18 }, { header: '职位', key: 'position', width: 15 }, { header: '电话', key: 'phone', width: 15 }, @@ -411,6 +418,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => { nickname: '示例:张三', real_name: '张三', employee_id: 'EMP001', + id_number: '320000199001011234', department: departmentNames[0] || '技术部', position: '工程师', phone: '13800138000', @@ -435,10 +443,10 @@ export const generatePersonTemplate = async (req: Request, res: Response) => { // 预设100行的下拉选项 const maxRows = 100 - // 部门下拉(D列) + // 部门下拉(E列) if (departmentNames.length > 0) { for (let row = 2; row <= maxRows; row++) { - mainSheet.getCell(`D${row}`).dataValidation = { + mainSheet.getCell(`E${row}`).dataValidation = { type: 'list', allowBlank: true, formulae: [`数据源!$A$2:$A$${departmentNames.length + 1}`], @@ -464,6 +472,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => { { field: '昵称', required: '是', description: '使用者的昵称或常用名' }, { field: '真实姓名', required: '是', description: '使用者的真实姓名' }, { field: '工号', required: '否', description: '员工工号' }, + { field: '身份证号', required: '否', description: '身份证或其他证件号码' }, { field: '部门', required: '否', description: '从下拉列表选择,当前可选:' + (departmentNames.join('、') || '暂无') }, { field: '职位', required: '否', description: '职位名称,如"工程师"、"经理"等' }, { field: '电话', required: '否', description: '联系电话' }, diff --git a/backend/src/controllers/inventoryController.ts b/backend/src/controllers/inventoryController.ts index c348e9d..0ef2dd4 100644 --- a/backend/src/controllers/inventoryController.ts +++ b/backend/src/controllers/inventoryController.ts @@ -8,7 +8,8 @@ import { getInventoryTaskDetail, getTaskUnassignedItems, listInventoryTasks, - previewInventoryTask + previewInventoryTask, + submitAdminInventoryScan } from '../services/inventoryService' import { normalizeInventoryScopeInput } from '../utils/inventoryScope' @@ -166,3 +167,33 @@ export const assignInventoryTaskUnassignedController = async (req: Request, res: errorResponse(res, message, message.includes('不存在') ? 404 : 422) } } + +export const submitAdminInventoryScanController = async (req: Request, res: Response) => { + try { + const taskId = Number(req.params.id) + const currentUserId = Number(res.locals.user?.id) + const equipmentCode = String(req.body?.equipment_code || '').trim() + + if (!Number.isInteger(taskId) || taskId <= 0) { + return errorResponse(res, '盘点任务不存在', 404) + } + + if (!Number.isInteger(currentUserId) || currentUserId <= 0) { + return errorResponse(res, '未获取到当前登录用户', 401) + } + + if (!equipmentCode) { + return errorResponse(res, '资产编码不能为空', 422) + } + + const item = await submitAdminInventoryScan(taskId, equipmentCode, currentUserId) + successResponse(res, item, '管理员代扫成功') + } catch (error: any) { + const message = error.message || '管理员扫码盘点失败' + errorResponse( + res, + message, + message.includes('不存在') ? 404 : message.includes('关闭') ? 403 : 422 + ) + } +} diff --git a/backend/src/controllers/personController.ts b/backend/src/controllers/personController.ts index e3dc28c..9548697 100644 --- a/backend/src/controllers/personController.ts +++ b/backend/src/controllers/personController.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' +import { isSystemReservedPerson } from '../utils/systemReservedPerson' const departmentSelect = { id: true, @@ -57,6 +58,7 @@ const sanitizePerson = (person: any) => { id: person.id, nickname: person.nickname, real_name: person.real_name, + job_number: person.job_number, id_number: person.id_number, department: mainDepartment?.name || person.department || null, main_department_id: person.main_department_id, @@ -114,7 +116,7 @@ const buildPersonWhere = (keyword?: unknown, status?: unknown) => { where.OR = [ { nickname: { contains: kw } }, { real_name: { contains: kw } }, - { id_number: { contains: kw } }, + { job_number: { contains: kw } }, { department: { contains: kw } }, { mainDepartment: { @@ -232,6 +234,7 @@ export const createPerson = async (req: Request, res: Response) => { const { nickname, real_name, + job_number, id_number, department, position, @@ -245,11 +248,19 @@ export const createPerson = async (req: Request, res: Response) => { return errorResponse(res, '花名与真实姓名为必填项', 422) } + const normalizedJobNumber = normalizeOptionalText(job_number) + if (normalizedJobNumber) { + const exists = await prisma.person.findUnique({ where: { job_number: normalizedJobNumber } }) + if (exists) { + return errorResponse(res, '工号已存在', 409) + } + } + const normalizedIdNumber = normalizeOptionalText(id_number) if (normalizedIdNumber) { const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } }) if (exists) { - return errorResponse(res, '工号已存在', 409) + return errorResponse(res, '身份证号已存在', 409) } } @@ -261,6 +272,7 @@ export const createPerson = async (req: Request, res: Response) => { data: { nickname: String(nickname).trim(), real_name: String(real_name).trim(), + job_number: normalizedJobNumber, id_number: normalizedIdNumber, department: normalizedDepartment, main_department_id: matchedDepartment?.id || null, @@ -293,6 +305,7 @@ export const updatePerson = async (req: Request, res: Response) => { const { nickname, real_name, + job_number, id_number, department, position, @@ -309,12 +322,23 @@ export const updatePerson = async (req: Request, res: Response) => { if (!person) { return errorResponse(res, '使用者不存在', 404) } + if (isSystemReservedPerson(person)) { + return errorResponse(res, '系统保留人员不允许修改', 422) + } + + const normalizedJobNumber = normalizeOptionalText(job_number) + if (normalizedJobNumber && normalizedJobNumber !== person.job_number) { + const exists = await prisma.person.findUnique({ where: { job_number: normalizedJobNumber } }) + if (exists) { + return errorResponse(res, '工号已存在', 409) + } + } const normalizedIdNumber = normalizeOptionalText(id_number) if (normalizedIdNumber && normalizedIdNumber !== person.id_number) { const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } }) if (exists) { - return errorResponse(res, '工号已存在', 409) + return errorResponse(res, '身份证号已存在', 409) } } @@ -332,6 +356,7 @@ export const updatePerson = async (req: Request, res: Response) => { data: { nickname: nickname !== undefined ? String(nickname).trim() : person.nickname, real_name: real_name !== undefined ? String(real_name).trim() : person.real_name, + job_number: job_number !== undefined ? normalizedJobNumber : person.job_number, id_number: id_number !== undefined ? normalizedIdNumber : person.id_number, department: hasDepartmentInput ? normalizedDepartment : person.department, main_department_id: hasDepartmentInput @@ -379,6 +404,9 @@ export const updatePersonStatus = async (req: Request, res: Response) => { if (!person) { return errorResponse(res, '使用者不存在', 404) } + if (isSystemReservedPerson(person)) { + return errorResponse(res, '系统保留人员不允许停用', 422) + } const updated = await prisma.person.update({ where: { id: Number(id) }, @@ -401,6 +429,9 @@ export const deletePerson = async (req: Request, res: Response) => { if (!person) { return errorResponse(res, '使用者不存在', 404) } + if (isSystemReservedPerson(person)) { + return errorResponse(res, '系统保留人员不允许删除', 422) + } const activeBorrowCount = await prisma.borrowRecord.count({ where: { @@ -431,11 +462,14 @@ export const deletePerson = async (req: Request, res: Response) => { } } -export const getPersonOptions = async (_req: Request, res: Response) => { +export const getPersonOptions = async (req: Request, res: Response) => { try { + const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true' const persons = await prisma.person.findMany({ - where: { status: 'active' }, - orderBy: [{ nickname: 'asc' }, { real_name: 'asc' }], + where: includeInactive ? undefined : { status: 'active' }, + orderBy: includeInactive + ? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }] + : [{ nickname: 'asc' }, { real_name: 'asc' }], include: personInclude }) diff --git a/backend/src/controllers/searchController.ts b/backend/src/controllers/searchController.ts index f9cf7ce..9f20a61 100644 --- a/backend/src/controllers/searchController.ts +++ b/backend/src/controllers/searchController.ts @@ -1,6 +1,10 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' +import { + buildParentAssetFirstOrderBy, + sortParentAssetsFirst +} from '../utils/equipmentSearchPriority' // 全局搜索 export const globalSearch = async (req: Request, res: Response) => { @@ -26,14 +30,15 @@ export const globalSearch = async (req: Request, res: Response) => { equipment_code: true, equipment_name: true, equipment_type: true, + parent_id: true, status: true, assetType: { select: { name: true } } }, take: 10, - orderBy: { updated_at: 'desc' } + orderBy: buildParentAssetFirstOrderBy([{ updated_at: 'desc' }, { id: 'desc' }]) }) - const results = equipments.map(eq => ({ + const results = sortParentAssetsFirst(equipments).map(eq => ({ id: eq.id, code: eq.equipment_code, name: eq.equipment_name, diff --git a/backend/src/index.ts b/backend/src/index.ts index 418d393..581d659 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -34,6 +34,7 @@ import printDownloadRoutes from './routes/printDownloads' import { keycloak, memoryStore } from './utils/keycloak' import { protect, requireAdmin } from './middleware/auth' import { startHrSyncScheduler } from './services/hrSyncService' +import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService' dotenv.config() @@ -43,8 +44,8 @@ const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secre // Middleware app.use(cors({ origin: true, credentials: true })) -app.use(express.json({ limit: '30mb' })) -app.use(express.urlencoded({ extended: true })) +app.use(express.json({ limit: '50mb' })) +app.use(express.urlencoded({ extended: true, limit: '50mb' })) app.use( session({ secret: SESSION_SECRET, @@ -113,4 +114,8 @@ app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`) }) +ensurePublicAssetPersonExists().catch((error) => { + console.error('初始化系统保留人员失败:', error) +}) + startHrSyncScheduler() diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts index 0978e12..723d746 100644 --- a/backend/src/routes/inventory.ts +++ b/backend/src/routes/inventory.ts @@ -7,7 +7,8 @@ import { getInventoryTaskDetailController, getInventoryTaskListController, getInventoryTaskUnassignedController, - previewInventoryTaskController + previewInventoryTaskController, + submitAdminInventoryScanController } from '../controllers/inventoryController' import { protect, requireAdmin } from '../middleware/auth' @@ -23,5 +24,6 @@ router.post('/tasks/:id/close', closeInventoryTaskController) router.delete('/tasks/:id', deleteInventoryTaskController) router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController) router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController) +router.post('/tasks/:id/admin-scan', submitAdminInventoryScanController) export default router diff --git a/backend/src/services/__tests__/hrSyncService.test.ts b/backend/src/services/__tests__/hrSyncService.test.ts index ca85fa8..d6dbb90 100644 --- a/backend/src/services/__tests__/hrSyncService.test.ts +++ b/backend/src/services/__tests__/hrSyncService.test.ts @@ -5,10 +5,28 @@ import { buildHrSyncPreviewLine, formatBorrowedAssetSummary, resolveHrSyncPersonDisplayName, - resolveSourcePersonStatus + resolveSourcePersonStatus, + shouldUseSourceDepartmentBinding } from '../hrSyncHelpers' -test('resolvePersonStatus 在状态值 2 被配置为停用时应返回 inactive', () => { +test('resolvePersonStatus 默认应将状态值 2 视为 active,仅将状态值 5 视为 inactive', () => { + const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES + delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES + + try { + assert.equal(resolveSourcePersonStatus('2'), 'active') + assert.equal(resolveSourcePersonStatus('5'), 'inactive') + assert.equal(resolveSourcePersonStatus('3'), 'active') + } finally { + if (previous === undefined) { + delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES + } else { + process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous + } + } +}) + +test('resolvePersonStatus 在显式配置状态值 2 为停用时仍应尊重配置', () => { const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES process.env.HR_SYNC_INACTIVE_STATUS_VALUES = '2,5' @@ -17,7 +35,28 @@ test('resolvePersonStatus 在状态值 2 被配置为停用时应返回 inactive assert.equal(resolveSourcePersonStatus('5'), 'inactive') assert.equal(resolveSourcePersonStatus('3'), 'active') } finally { - process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous + if (previous === undefined) { + delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES + } else { + process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous + } + } +}) + +test('shouldUseSourceDepartmentBinding 默认应允许状态值 2 和 3 保留源部门', () => { + const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES + delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES + + try { + assert.equal(shouldUseSourceDepartmentBinding('2'), true) + assert.equal(shouldUseSourceDepartmentBinding('3'), true) + assert.equal(shouldUseSourceDepartmentBinding('5'), false) + } finally { + if (previous === undefined) { + delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES + } else { + process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous + } } }) diff --git a/backend/src/services/__tests__/printService.test.ts b/backend/src/services/__tests__/printService.test.ts index 4b5362f..0bcfb4d 100644 --- a/backend/src/services/__tests__/printService.test.ts +++ b/backend/src/services/__tests__/printService.test.ts @@ -1,10 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - getPrintTerminalAdminStatusChangeError, - resolveEffectiveTerminalStatus, normalizeLabelProfile, + planPrintTerminalAdminStatusChange, pickNextClaimablePrintJob, + resolveEffectiveTerminalStatus, + resolvePrintJobTargetTerminal, + resolvePreferredDefaultTerminal, resolveTerminalReadiness, sanitizeRenderedLabelImages, stripRenderedLabelData @@ -146,15 +148,157 @@ test('resolveEffectiveTerminalStatus 对手动设为 offline 的终端保持 off assert.equal(status, 'offline') }) -test('getPrintTerminalAdminStatusChangeError 允许默认终端被直接停用', () => { - const error = getPrintTerminalAdminStatusChangeError( +test('resolvePreferredDefaultTerminal 会优先选择最新在线默认终端,忽略已停用默认记录', () => { + const terminal = resolvePreferredDefaultTerminal([ { - is_default: true + id: 1, + status: 'disabled', + is_default: true, + updated_at: '2026-03-26T10:00:00.000Z' + }, + { + id: 2, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:00:00.000Z' + }, + { + id: 3, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:05:00.000Z' + } + ]) + + assert.equal(terminal?.id, 3) +}) + +test('resolvePreferredDefaultTerminal 在只剩停用默认终端时会回退到最新停用记录', () => { + const terminal = resolvePreferredDefaultTerminal([ + { + id: 1, + status: 'disabled', + is_default: true, + updated_at: '2026-03-26T10:00:00.000Z' + }, + { + id: 2, + status: 'disabled', + is_default: true, + updated_at: '2026-03-27T10:00:00.000Z' + } + ]) + + assert.equal(terminal?.id, 2) +}) + +test('resolvePrintJobTargetTerminal 会优先使用前端当时展示的默认终端', () => { + const result = resolvePrintJobTargetTerminal( + [ + { + id: 2, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:10:00.000Z' + }, + { + id: 3, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:12:00.000Z' + } + ], + 2 + ) + + assert.equal(result.terminal.id, 2) + assert.deepEqual(result.staleDefaultIdsToClear, [3]) +}) + +test('resolvePrintJobTargetTerminal 在前端默认终端已变化时会要求刷新页面', () => { + assert.throws( + () => + resolvePrintJobTargetTerminal( + [ + { + id: 3, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:12:00.000Z' + } + ], + 2 + ), + /默认打印电脑状态已变化/ + ) +}) + +test('planPrintTerminalAdminStatusChange 在停用当前默认终端时会保留自身默认并清掉其他脏默认', () => { + const plan = planPrintTerminalAdminStatusChange( + [ + { + id: 1, + status: 'disabled', + is_default: true, + updated_at: '2026-03-26T10:00:00.000Z' + }, + { + id: 2, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:00:00.000Z' + }, + { + id: 3, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:05:00.000Z' + } + ], + { + id: 3, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:05:00.000Z' }, 'disabled' ) - assert.equal(error, null) + assert.deepEqual(plan, { + keepCurrentDefaultFlag: true, + staleDefaultIdsToClear: [1, 2] + }) +}) + +test('planPrintTerminalAdminStatusChange 在处理旧脏默认时会移除它的默认标记', () => { + const plan = planPrintTerminalAdminStatusChange( + [ + { + id: 1, + status: 'disabled', + is_default: true, + updated_at: '2026-03-26T10:00:00.000Z' + }, + { + id: 2, + status: 'online', + is_default: true, + updated_at: '2026-03-27T10:05:00.000Z' + } + ], + { + id: 1, + status: 'disabled', + is_default: true, + updated_at: '2026-03-26T10:00:00.000Z' + }, + 'offline' + ) + + assert.deepEqual(plan, { + keepCurrentDefaultFlag: false, + staleDefaultIdsToClear: [] + }) }) test('sanitizeRenderedLabelImages 会按 equipment_ids 顺序重排网页生成的 PNG 标签', () => { diff --git a/backend/src/services/hrSyncHelpers.ts b/backend/src/services/hrSyncHelpers.ts index ef95e10..602f0e7 100644 --- a/backend/src/services/hrSyncHelpers.ts +++ b/backend/src/services/hrSyncHelpers.ts @@ -29,7 +29,7 @@ const getConfiguredSet = (value: string, defaultValue: string) => ) const getInactiveStatusValues = () => - getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '2,5') + getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '5') const getInactiveStatusKeywords = () => Array.from( @@ -59,6 +59,9 @@ export const resolveSourcePersonStatus = (employeeStatus: unknown): 'active' | ' return 'active' } +export const shouldUseSourceDepartmentBinding = (employeeStatus: unknown) => + resolveSourcePersonStatus(employeeStatus) === 'active' + export const formatBorrowedAssetSummary = (assets: OpenBorrowAssetPreview[], limit = 3) => { if (assets.length === 0) { return '暂无待处理资产' diff --git a/backend/src/services/inventoryService.ts b/backend/src/services/inventoryService.ts index b8b9b18..1dcb8ce 100644 --- a/backend/src/services/inventoryService.ts +++ b/backend/src/services/inventoryService.ts @@ -8,6 +8,7 @@ import { type NormalizeInventoryScopeInputParams, type NormalizedInventoryScopeInput } from '../utils/inventoryScope' +import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft' import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle' type ScopeEntity = { @@ -351,52 +352,26 @@ export const buildInventoryScope = async ( const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap) const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || [] const activeBorrowRecord = resolveUniqueActiveBorrowRecord(activeBorrowRecords) + const itemDraft = buildInventoryScopeItemDraft({ + equipment, + activeBorrowRecord + }) - if (!activeBorrowRecord?.person) { - continue + if (activeBorrowRecord?.person && itemDraft.participant_person_id) { + participantMap.set(itemDraft.participant_person_id, { + person_id: activeBorrowRecord.person.id, + person_name: activeBorrowRecord.person.real_name, + person_nickname: activeBorrowRecord.person.nickname, + person_source_userid: activeBorrowRecord.person.source_userid || null, + department_id: activeBorrowRecord.person.mainDepartment?.id || null, + department_name: + activeBorrowRecord.person.mainDepartment?.name || + activeBorrowRecord.person.department || + null + }) } - const participantPersonId = activeBorrowRecord.person.id - const snapshotDepartmentId = - activeBorrowRecord?.person?.mainDepartment?.id || - equipment.department?.id || - null - const snapshotDepartmentName = - activeBorrowRecord?.person?.mainDepartment?.name || - activeBorrowRecord?.person?.department || - equipment.department?.name || - null - - participantMap.set(participantPersonId, { - person_id: activeBorrowRecord.person.id, - person_name: activeBorrowRecord.person.real_name, - person_nickname: activeBorrowRecord.person.nickname, - person_source_userid: activeBorrowRecord.person.source_userid || null, - department_id: activeBorrowRecord.person.mainDepartment?.id || null, - department_name: - activeBorrowRecord.person.mainDepartment?.name || - activeBorrowRecord.person.department || - null - }) - - items.push({ - participant_person_id: participantPersonId, - equipment_id: equipment.id, - equipment_code: equipment.equipment_code, - equipment_name: equipment.equipment_name, - equipment_model: equipment.equipment_model, - equipment_type: equipment.equipment_type, - asset_type_id: equipment.asset_type_id, - asset_type_name: equipment.assetType?.name || null, - snapshot_department_id: snapshotDepartmentId, - snapshot_department_name: snapshotDepartmentName, - snapshot_person_id: activeBorrowRecord?.person?.id || null, - snapshot_person_name: activeBorrowRecord?.person?.real_name || null, - snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null, - snapshot_status: equipment.status, - source_borrow_record_id: activeBorrowRecord?.id || null, - is_unassigned: false - }) + items.push(itemDraft) } return { @@ -467,7 +442,8 @@ const refreshTaskCounters = async (tx: PrismaClient, taskId: number) => { tx.inventoryTaskItem.count({ where: { task_id: taskId, - is_unassigned: true + is_unassigned: true, + check_status: INVENTORY_ITEM_CHECK_STATUS.pending } }) ]) @@ -1180,11 +1156,137 @@ export const getTaskUnassignedItems = async (taskId: number) => prisma.inventoryTaskItem.findMany({ where: { task_id: taskId, - is_unassigned: true + is_unassigned: true, + check_status: INVENTORY_ITEM_CHECK_STATUS.pending }, orderBy: { equipment_code: 'asc' } }) +const assertTaskOpenForAdminInventory = async (taskId: number) => { + const task = await prisma.inventoryTask.findUnique({ + where: { id: taskId }, + select: { + id: true, + status: true + } + }) + + if (!task) { + throw new Error('盘点任务不存在') + } + + if (task.status === INVENTORY_TASK_STATUSES.closed) { + throw new Error('盘点任务已关闭') + } + + return task +} + +const updateTaskItemStatusByAdmin = async ( + taskId: number, + itemId: number, + operatorUserId: number, + payload: { + check_status: string + exception_type?: string | null + exception_note?: string | null + action_type: string + action_payload?: Record | null + } +) => { + const item = await prisma.inventoryTaskItem.findFirst({ + where: { + id: itemId, + task_id: taskId + } + }) + + if (!item) { + throw new Error('该资产不在本次盘点任务范围内') + } + + await prisma.$transaction(async (tx) => { + await tx.inventoryTaskItem.update({ + where: { id: item.id }, + data: { + check_status: payload.check_status, + checked_by_participant_id: null, + checked_at: new Date(), + exception_type: payload.exception_type ?? null, + exception_note: payload.exception_note ?? null + } + }) + + if (item.participant_id) { + await tx.inventoryTaskParticipant.update({ + where: { id: item.participant_id }, + data: { + last_action_at: new Date() + } + }) + await refreshParticipantStatus(tx as unknown as PrismaClient, item.participant_id) + } + + await refreshTaskCounters(tx as unknown as PrismaClient, taskId) + + await tx.inventoryTaskAction.create({ + data: { + task_id: taskId, + participant_id: item.participant_id, + item_id: item.id, + action_type: payload.action_type, + action_payload: payload.action_payload ? JSON.stringify(payload.action_payload) : null, + operator_user_id: operatorUserId + } + }) + }) + + return prisma.inventoryTaskItem.findUnique({ + where: { id: item.id } + }) +} + +export const submitAdminInventoryScan = async ( + taskId: number, + equipmentCode: string, + operatorUserId: number +) => { + const normalizedCode = equipmentCode.trim() + if (!normalizedCode) { + throw new Error('资产编码不能为空') + } + + await assertTaskOpenForAdminInventory(taskId) + + const item = await prisma.inventoryTaskItem.findFirst({ + where: { + task_id: taskId, + equipment_code: normalizedCode + } + }) + + if (!item) { + throw new Error('该资产不在本次盘点任务范围内') + } + + const updated = await updateTaskItemStatusByAdmin(taskId, item.id, operatorUserId, { + check_status: INVENTORY_ITEM_CHECK_STATUS.checked, + exception_type: null, + exception_note: null, + action_type: 'item_checked_by_admin', + action_payload: { + equipment_code: normalizedCode, + is_unassigned: item.is_unassigned + } + }) + + if (!updated) { + throw new Error('盘点资产不存在') + } + + return updated +} + export const assignUnassignedTaskItem = async ( taskId: number, itemId: number, diff --git a/backend/src/services/printService.ts b/backend/src/services/printService.ts index 33c9c15..642fc19 100644 --- a/backend/src/services/printService.ts +++ b/backend/src/services/printService.ts @@ -147,6 +147,10 @@ const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const PNG_DATA_URL_PREFIX = 'data:image/png;base64,' const PNG_DATA_URL_PATTERN = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/i const PRINT_JOB_RENDER_STORAGE_ROOT = path.resolve(__dirname, '..', '..', 'uploads', 'print-jobs') +const DEFAULT_TERMINAL_ORDER_BY: Prisma.PrintTerminalOrderByWithRelationInput[] = [ + { updated_at: 'desc' }, + { id: 'desc' } +] type ParsedTerminalData = { labelProfile: PrintLabelProfileInput | null @@ -155,6 +159,13 @@ type ParsedTerminalData = { readiness: PrintTerminalReadinessResult } +type DefaultTerminalCandidate = { + id: number + status: string + is_default: boolean + updated_at?: Date | string | null +} + type TerminalListRecord = PrintTerminal & { recentFailedJob?: { id: number @@ -194,6 +205,105 @@ export class PrintServiceError extends Error { } } +const getDefaultTerminalUpdatedAtMs = (value?: Date | string | null) => { + if (!value) return 0 + + const date = value instanceof Date ? value : new Date(value) + const timestamp = date.getTime() + return Number.isNaN(timestamp) ? 0 : timestamp +} + +const compareDefaultTerminalPriority = (a: T, b: T) => { + const updatedAtDiff = getDefaultTerminalUpdatedAtMs(b.updated_at) - getDefaultTerminalUpdatedAtMs(a.updated_at) + if (updatedAtDiff !== 0) { + return updatedAtDiff + } + + return b.id - a.id +} + +export const resolvePreferredDefaultTerminal = ( + terminals: T[] +) => { + const defaultTerminals = terminals.filter((terminal) => terminal.is_default).slice().sort(compareDefaultTerminalPriority) + + return ( + defaultTerminals.find((terminal) => terminal.status !== PRINT_TERMINAL_STATUS.disabled) || + defaultTerminals[0] || + null + ) +} + +const resolveActiveDefaultTerminal = (terminals: T[]) => { + const preferredDefault = resolvePreferredDefaultTerminal(terminals) + if (!preferredDefault || preferredDefault.status === PRINT_TERMINAL_STATUS.disabled) { + return null + } + + return preferredDefault +} + +export const resolvePrintJobTargetTerminal = ( + terminals: T[], + requestedTerminalId: number | null +) => { + const defaultTerminals = terminals.filter((terminal) => terminal.is_default).slice().sort(compareDefaultTerminalPriority) + + if (defaultTerminals.length === 0) { + throw new PrintServiceError('尚未配置默认打印电脑', 422) + } + + const targetTerminal = + requestedTerminalId === null + ? resolvePreferredDefaultTerminal(defaultTerminals) + : defaultTerminals.find((terminal) => terminal.id === requestedTerminalId) || null + + if (!targetTerminal) { + throw new PrintServiceError('默认打印电脑状态已变化,请刷新页面后重试', 409) + } + + if (targetTerminal.status === PRINT_TERMINAL_STATUS.disabled) { + throw new PrintServiceError('默认打印电脑已停用,请先启用或切换默认终端', 422) + } + + return { + terminal: targetTerminal, + staleDefaultIdsToClear: defaultTerminals + .filter((terminal) => terminal.id !== targetTerminal.id) + .map((terminal) => terminal.id) + } +} + +export const planPrintTerminalAdminStatusChange = ( + terminals: T[], + terminal: T, + nextStatus: 'offline' | 'disabled' +) => { + const preferredDefault = resolvePreferredDefaultTerminal(terminals) + const isPreferredDefault = preferredDefault?.id === terminal.id + + if (terminal.is_default && !isPreferredDefault) { + return { + keepCurrentDefaultFlag: false, + staleDefaultIdsToClear: [] as number[] + } + } + + if (nextStatus === 'disabled' && isPreferredDefault) { + return { + keepCurrentDefaultFlag: true, + staleDefaultIdsToClear: terminals + .filter((item) => item.is_default && item.id !== terminal.id) + .map((item) => item.id) + } + } + + return { + keepCurrentDefaultFlag: terminal.is_default, + staleDefaultIdsToClear: [] as number[] + } +} + const normalizePositiveNumber = (value: number | null | undefined, fallback: number) => { if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) { return fallback @@ -369,6 +479,19 @@ const sanitizeEquipmentIds = (value: unknown) => { return uniqueIds } +const sanitizeTargetTerminalId = (value: unknown) => { + if (typeof value === 'undefined' || value === null || value === '') { + return null + } + + const terminalId = Number(value) + if (!Number.isInteger(terminalId) || terminalId <= 0) { + throw new PrintServiceError('默认打印电脑编号无效,请刷新页面后重试', 422) + } + + return terminalId +} + const decodePngDataUrl = (value: unknown) => { if (typeof value !== 'string') { throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422) @@ -607,13 +730,6 @@ export const resolveEffectiveTerminalStatus = ( return PRINT_TERMINAL_STATUS.online } -export const getPrintTerminalAdminStatusChangeError = ( - _terminal: Pick, - _nextStatus: 'offline' | 'disabled' -) => { - return null -} - const getEffectiveTerminalStatus = (status: string, lastSeenAt: Date | null) => { if (status === PRINT_TERMINAL_STATUS.disabled) { return PRINT_TERMINAL_STATUS.disabled @@ -675,8 +791,19 @@ const parseTerminalData = (terminal: { } } -const mapTerminalRecord = (terminal: TerminalListRecord) => { +const mapTerminalRecord = ( + terminal: TerminalListRecord, + options: { + preferredDefaultTerminalId?: number | null + } = {} +) => { const parsed = parseTerminalData(terminal) + const normalizedDefaultFlag = + typeof options.preferredDefaultTerminalId === 'number' + ? terminal.id === options.preferredDefaultTerminalId + : options.preferredDefaultTerminalId === null + ? false + : terminal.is_default return { id: terminal.id, @@ -684,7 +811,7 @@ const mapTerminalRecord = (terminal: TerminalListRecord) => { platform: terminal.platform, machine_name: terminal.machine_name, status: parsed.effectiveStatus, - is_default: terminal.is_default, + is_default: normalizedDefaultFlag, last_seen_at: terminal.last_seen_at, selected_printer_name: terminal.selected_printer_name, printer_snapshot: parsed.printerSnapshot, @@ -785,27 +912,17 @@ export const markStalePrintTerminalsOffline = async () => { }) } -const getDefaultTargetTerminal = async () => { +const getDefaultTargetTerminal = async (requestedTerminalId: number | null = null) => { await markStalePrintTerminalsOffline() - const terminal = await prisma.printTerminal.findFirst({ + const terminals = await prisma.printTerminal.findMany({ where: { is_default: true }, - orderBy: { - id: 'asc' - } + orderBy: DEFAULT_TERMINAL_ORDER_BY }) - if (!terminal) { - throw new PrintServiceError('尚未配置默认打印电脑', 422) - } - - if (terminal.status === PRINT_TERMINAL_STATUS.disabled) { - throw new PrintServiceError('默认打印电脑已停用,请先启用或切换默认终端', 422) - } - - return terminal + return resolvePrintJobTargetTerminal(terminals, requestedTerminalId) } const buildAssetLabelPayload = async (equipmentIds: number[]): Promise => { @@ -951,10 +1068,14 @@ export const listPrintTerminals = async () => { } } + const preferredDefaultTerminalId = resolvePreferredDefaultTerminal(terminals)?.id ?? null + return terminals.map((terminal) => mapTerminalRecord({ ...terminal, recentFailedJob: latestFailedJobMap.get(terminal.id) || null + }, { + preferredDefaultTerminalId }) ) } @@ -1008,25 +1129,52 @@ export const updatePrintTerminalAdminStatus = async ( terminalId: number, nextStatus: 'offline' | 'disabled' ) => { - const existing = await prisma.printTerminal.findUnique({ - where: { id: terminalId } - }) + const [existing, defaultTerminals] = await Promise.all([ + prisma.printTerminal.findUnique({ + where: { id: terminalId } + }), + prisma.printTerminal.findMany({ + where: { + is_default: true + }, + orderBy: DEFAULT_TERMINAL_ORDER_BY, + select: { + id: true, + status: true, + is_default: true, + updated_at: true + } + }) + ]) if (!existing) { throw new PrintServiceError('打印终端不存在', 404) } - const statusChangeError = getPrintTerminalAdminStatusChangeError(existing, nextStatus) - if (statusChangeError) { - throw new PrintServiceError(statusChangeError, 422) - } + const changePlan = planPrintTerminalAdminStatusChange(defaultTerminals, existing, nextStatus) - const updated = await prisma.printTerminal.update({ - where: { id: terminalId }, - data: { - status: nextStatus, - last_seen_at: existing.last_seen_at + const updated = await prisma.$transaction(async (tx) => { + if (changePlan.staleDefaultIdsToClear.length > 0) { + await tx.printTerminal.updateMany({ + where: { + id: { + in: changePlan.staleDefaultIdsToClear + } + }, + data: { + is_default: false + } + }) } + + return tx.printTerminal.update({ + where: { id: terminalId }, + data: { + status: nextStatus, + is_default: changePlan.keepCurrentDefaultFlag, + last_seen_at: existing.last_seen_at + } + }) }) return mapTerminalRecord({ @@ -1041,12 +1189,15 @@ export const createAssetLabelPrintJob = async (input: unknown, requestedBy: numb } const equipmentIds = sanitizeEquipmentIds((input as { equipment_ids?: unknown }).equipment_ids) + const requestedTargetTerminalId = sanitizeTargetTerminalId( + (input as { target_terminal_id?: unknown }).target_terminal_id + ) const renderedLabels = sanitizeRenderedLabelImages( (input as { rendered_labels?: unknown }).rendered_labels, equipmentIds ) - const [targetTerminal, basePayload] = await Promise.all([ - getDefaultTargetTerminal(), + const [targetTerminalSelection, basePayload] = await Promise.all([ + getDefaultTargetTerminal(requestedTargetTerminalId), buildAssetLabelPayload(equipmentIds) ]) @@ -1060,47 +1211,62 @@ export const createAssetLabelPrintJob = async (input: unknown, requestedBy: numb rendered_label_files: storedRenderBatch.storedFiles } - job = await prisma.printJob.create({ - data: { - job_type: PRINT_JOB_TYPE.assetLabel, - status: PRINT_JOB_STATUS.queued, - target_terminal_id: targetTerminal.id, - requested_by: requestedBy, - payload_json: stringifyJson(payload) - }, - include: { - targetTerminal: { - select: { - id: true, - name: true, - status: true, - selected_printer_name: true, - last_seen_at: true, - label_profile_json: true, - printer_snapshot_json: true + job = await prisma.$transaction(async (tx) => { + if (targetTerminalSelection.staleDefaultIdsToClear.length > 0) { + await tx.printTerminal.updateMany({ + where: { + id: { + in: targetTerminalSelection.staleDefaultIdsToClear + } + }, + data: { + is_default: false } + }) + } + + return tx.printJob.create({ + data: { + job_type: PRINT_JOB_TYPE.assetLabel, + status: PRINT_JOB_STATUS.queued, + target_terminal_id: targetTerminalSelection.terminal.id, + requested_by: requestedBy, + payload_json: stringifyJson(payload) }, - claimedByTerminal: { - select: { - id: true, - name: true - } - }, - requester: { - select: { - id: true, - username: true, - name: true + include: { + targetTerminal: { + select: { + id: true, + name: true, + status: true, + selected_printer_name: true, + last_seen_at: true, + label_profile_json: true, + printer_snapshot_json: true + } + }, + claimedByTerminal: { + select: { + id: true, + name: true + } + }, + requester: { + select: { + id: true, + username: true, + name: true + } } } - } + }) }) } catch (error) { await removeStoredRenderBatch(storedRenderBatch.batchDirectory) throw error } - const parsedTarget = parseTerminalData(targetTerminal) + const parsedTarget = parseTerminalData(targetTerminalSelection.terminal) return { job: await mapPrintJobRecord(job), @@ -1250,22 +1416,44 @@ export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => { throw new PrintServiceError('配对码已被其他终端使用,请重新生成', 409) } - const defaultTerminalCount = await tx.printTerminal.count({ + const defaultTerminals = await tx.printTerminal.findMany({ where: { + is_default: true + }, + orderBy: DEFAULT_TERMINAL_ORDER_BY, + select: { + id: true, + status: true, is_default: true, - status: { - not: PRINT_TERMINAL_STATUS.disabled - } + updated_at: true } }) + const activeDefaultTerminal = resolveActiveDefaultTerminal(defaultTerminals) + const staleDefaultIds = defaultTerminals + .filter((terminal) => terminal.id !== activeDefaultTerminal?.id) + .map((terminal) => terminal.id) + + if (staleDefaultIds.length > 0) { + await tx.printTerminal.updateMany({ + where: { + id: { + in: staleDefaultIds + } + }, + data: { + is_default: false + } + }) + } + const terminal = await tx.printTerminal.create({ data: { name, platform, machine_name: machineName, status: PRINT_TERMINAL_STATUS.offline, - is_default: defaultTerminalCount === 0, + is_default: !activeDefaultTerminal, terminal_token_hash: terminalTokenHash, paired_at: now } diff --git a/backend/src/services/systemReservedPersonService.ts b/backend/src/services/systemReservedPersonService.ts new file mode 100644 index 0000000..d1e7478 --- /dev/null +++ b/backend/src/services/systemReservedPersonService.ts @@ -0,0 +1,44 @@ +import prisma from '../utils/prisma' +import { + PUBLIC_ASSET_PERSON_SOURCE_USERID, + buildPublicAssetPersonSeed +} from '../utils/systemReservedPerson' + +export const ensurePublicAssetPersonExists = async () => { + const seed = buildPublicAssetPersonSeed() + + return prisma.$transaction(async (tx) => { + const person = await tx.person.upsert({ + where: { + source_userid: PUBLIC_ASSET_PERSON_SOURCE_USERID + }, + create: seed, + update: { + nickname: seed.nickname, + real_name: seed.real_name, + department: null, + main_department_id: null, + position: seed.position, + status: 'active', + remarks: seed.remarks, + source_system: seed.source_system, + source_main_department_id: null, + source_dept_ids: null, + source_department_name: null, + source_main_department_name: null, + department_sync_bucket: null, + manager_source_userid: null, + source_employee_status: null, + last_synced_at: null + } + }) + + await tx.personDepartment.deleteMany({ + where: { + person_id: person.id + } + }) + + return person + }) +} diff --git a/backend/src/utils/__tests__/equipmentSearchPriority.test.ts b/backend/src/utils/__tests__/equipmentSearchPriority.test.ts new file mode 100644 index 0000000..a2a57e9 --- /dev/null +++ b/backend/src/utils/__tests__/equipmentSearchPriority.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildParentAssetFirstOrderBy, + sortParentAssetsFirst +} from '../equipmentSearchPriority' + +test('sortParentAssetsFirst 会把父资产排到子资产前面,并保持同组原顺序', () => { + const sorted = sortParentAssetsFirst([ + { id: 3, equipment_code: 'JS-CHILD-0003', parent_id: 101 }, + { id: 1, equipment_code: 'JS-PARENT-0001', parent_id: null }, + { id: 4, equipment_code: 'JS-CHILD-0004', parent_id: 102 }, + { id: 2, equipment_code: 'JS-PARENT-0002', parent_id: null } + ]) + + assert.deepEqual( + sorted.map((item) => item.id), + [1, 2, 3, 4] + ) +}) + +test('buildParentAssetFirstOrderBy 会把 parent_id 升序插到其他排序条件前面', () => { + assert.deepEqual(buildParentAssetFirstOrderBy([{ updated_at: 'desc' }, { id: 'desc' }]), [ + { parent_id: 'asc' }, + { updated_at: 'desc' }, + { id: 'desc' } + ]) +}) diff --git a/backend/src/utils/__tests__/inventoryTaskDraft.test.ts b/backend/src/utils/__tests__/inventoryTaskDraft.test.ts new file mode 100644 index 0000000..e5b552a --- /dev/null +++ b/backend/src/utils/__tests__/inventoryTaskDraft.test.ts @@ -0,0 +1,155 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildInventoryScopeItemDraft } from '../inventoryTaskDraft' + +test('buildInventoryScopeItemDraft 在存在唯一当前使用人时应生成已分配盘点项', () => { + const draft = buildInventoryScopeItemDraft({ + equipment: { + id: 101, + equipment_code: 'JS-PC-1010', + equipment_name: '设计部主机', + equipment_model: 'Dell 7090', + equipment_type: '主机', + asset_type_id: 8, + status: '已借出', + assetType: { + id: 8, + name: '主机' + }, + department: { + id: 12, + name: '设计部' + } + }, + activeBorrowRecord: { + id: 9001, + person: { + id: 66, + real_name: '张三', + nickname: '三藏', + source_userid: 'ding_001', + department: '设计部', + main_department_id: 12, + mainDepartment: { + id: 12, + name: '设计部' + } + } + } + }) + + assert.deepEqual(draft, { + participant_person_id: 66, + equipment_id: 101, + equipment_code: 'JS-PC-1010', + equipment_name: '设计部主机', + equipment_model: 'Dell 7090', + equipment_type: '主机', + asset_type_id: 8, + asset_type_name: '主机', + snapshot_department_id: 12, + snapshot_department_name: '设计部', + snapshot_person_id: 66, + snapshot_person_name: '张三', + snapshot_person_nickname: '三藏', + snapshot_status: '已借出', + source_borrow_record_id: 9001, + is_unassigned: false + }) +}) + +test('buildInventoryScopeItemDraft 在没有明确当前使用人时应生成待管理员代盘项', () => { + const draft = buildInventoryScopeItemDraft({ + equipment: { + id: 202, + equipment_code: 'JS-MON-2020', + equipment_name: '前台公用显示器', + equipment_model: 'AOC 24B2', + equipment_type: '显示器', + asset_type_id: 6, + status: '可用', + assetType: { + id: 6, + name: '显示器' + }, + department: { + id: 18, + name: '前台' + } + }, + activeBorrowRecord: null + }) + + assert.deepEqual(draft, { + participant_person_id: null, + equipment_id: 202, + equipment_code: 'JS-MON-2020', + equipment_name: '前台公用显示器', + equipment_model: 'AOC 24B2', + equipment_type: '显示器', + asset_type_id: 6, + asset_type_name: '显示器', + snapshot_department_id: 18, + snapshot_department_name: '前台', + snapshot_person_id: null, + snapshot_person_name: null, + snapshot_person_nickname: null, + snapshot_status: '可用', + source_borrow_record_id: null, + is_unassigned: true + }) +}) + +test('buildInventoryScopeItemDraft 遇到公共资产保留人员时仍应视为已分配盘点项', () => { + const draft = buildInventoryScopeItemDraft({ + equipment: { + id: 303, + equipment_code: 'JS-DQ-0342', + equipment_name: '冰箱', + equipment_model: null, + equipment_type: '电器设备', + asset_type_id: 9, + status: '已借出', + assetType: { + id: 9, + name: '电器设备' + }, + department: { + id: 21, + name: '仓储中心' + } + }, + activeBorrowRecord: { + id: 9901, + person: { + id: 1315, + real_name: '公共资产', + nickname: '公共资产', + source_userid: '__public_asset__', + department: null, + main_department_id: null, + mainDepartment: null + } + } + }) + + assert.deepEqual(draft, { + participant_person_id: 1315, + equipment_id: 303, + equipment_code: 'JS-DQ-0342', + equipment_name: '冰箱', + equipment_model: null, + equipment_type: '电器设备', + asset_type_id: 9, + asset_type_name: '电器设备', + snapshot_department_id: 21, + snapshot_department_name: '仓储中心', + snapshot_person_id: 1315, + snapshot_person_name: '公共资产', + snapshot_person_nickname: '公共资产', + snapshot_status: '已借出', + source_borrow_record_id: 9901, + is_unassigned: false + }) +}) diff --git a/backend/src/utils/__tests__/systemReservedPerson.test.ts b/backend/src/utils/__tests__/systemReservedPerson.test.ts new file mode 100644 index 0000000..5fdb210 --- /dev/null +++ b/backend/src/utils/__tests__/systemReservedPerson.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + PUBLIC_ASSET_PERSON_SOURCE_SYSTEM, + PUBLIC_ASSET_PERSON_SOURCE_USERID, + buildPublicAssetPersonSeed, + isSystemReservedPerson +} from '../systemReservedPerson' + +test('buildPublicAssetPersonSeed 应生成不参与组织同步的公共资产保留人员', () => { + assert.deepEqual(buildPublicAssetPersonSeed(), { + nickname: '公共资产', + real_name: '公共资产', + job_number: null, + id_number: null, + department: null, + main_department_id: null, + position: '系统保留', + phone: null, + email: null, + status: 'active', + remarks: '系统保留人员:用于承接公共设备,不参与 HR 组织同步。', + source_userid: PUBLIC_ASSET_PERSON_SOURCE_USERID, + source_system: PUBLIC_ASSET_PERSON_SOURCE_SYSTEM, + source_main_department_id: null, + source_dept_ids: null, + source_department_name: null, + source_main_department_name: null, + department_sync_bucket: null, + manager_source_userid: null, + source_employee_status: null, + last_synced_at: null + }) +}) + +test('isSystemReservedPerson 只应识别系统保留来源', () => { + assert.equal(isSystemReservedPerson({ source_system: PUBLIC_ASSET_PERSON_SOURCE_SYSTEM }), true) + assert.equal(isSystemReservedPerson({ source_system: 'ding_hr' }), false) + assert.equal(isSystemReservedPerson({ source_system: null }), false) +}) diff --git a/backend/src/utils/equipmentSearchPriority.ts b/backend/src/utils/equipmentSearchPriority.ts new file mode 100644 index 0000000..0ad5443 --- /dev/null +++ b/backend/src/utils/equipmentSearchPriority.ts @@ -0,0 +1,17 @@ +export interface ParentAssetSortable { + parent_id?: number | null +} + +export const sortParentAssetsFirst = (list: T[]) => { + return [...list].sort((left, right) => { + const leftPriority = left.parent_id == null ? 0 : 1 + const rightPriority = right.parent_id == null ? 0 : 1 + return leftPriority - rightPriority + }) +} + +export const buildParentAssetFirstOrderBy = < + T extends Record +>( + fallbackOrderBy: T[] +) => [{ parent_id: 'asc' as const }, ...fallbackOrderBy] diff --git a/backend/src/utils/inventoryTaskDraft.ts b/backend/src/utils/inventoryTaskDraft.ts new file mode 100644 index 0000000..608afb5 --- /dev/null +++ b/backend/src/utils/inventoryTaskDraft.ts @@ -0,0 +1,86 @@ +export type InventoryDraftEquipment = { + id: number + equipment_code: string + equipment_name: string + equipment_model: string | null + equipment_type: string + asset_type_id: number | null + status: string | null + assetType?: { + id: number + name: string + } | null + department?: { + id: number + name: string + } | null +} + +export type InventoryDraftPerson = { + id: number + real_name: string + nickname: string + source_userid?: string | null + department?: string | null + main_department_id?: number | null + mainDepartment?: { + id: number + name: string + } | null +} + +export type InventoryDraftBorrowRecord = { + id: number + person?: InventoryDraftPerson | null +} | null + +export type InventoryScopeItemDraft = { + participant_person_id: number | null + equipment_id: number + equipment_code: string + equipment_name: string + equipment_model: string | null + equipment_type: string + asset_type_id: number | null + asset_type_name: string | null + snapshot_department_id: number | null + snapshot_department_name: string | null + snapshot_person_id: number | null + snapshot_person_name: string | null + snapshot_person_nickname: string | null + snapshot_status: string | null + source_borrow_record_id: number | null + is_unassigned: boolean +} + +export const buildInventoryScopeItemDraft = ({ + equipment, + activeBorrowRecord +}: { + equipment: InventoryDraftEquipment + activeBorrowRecord: InventoryDraftBorrowRecord +}): InventoryScopeItemDraft => { + const person = activeBorrowRecord?.person || null + const snapshotDepartmentId = person?.mainDepartment?.id || equipment.department?.id || null + const snapshotDepartmentName = + person?.mainDepartment?.name || person?.department || equipment.department?.name || null + + return { + participant_person_id: person?.id || null, + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + equipment_name: equipment.equipment_name, + equipment_model: equipment.equipment_model, + equipment_type: equipment.equipment_type, + asset_type_id: equipment.asset_type_id, + asset_type_name: equipment.assetType?.name || null, + snapshot_department_id: snapshotDepartmentId, + snapshot_department_name: snapshotDepartmentName, + snapshot_person_id: person?.id || null, + snapshot_person_name: person?.real_name || null, + snapshot_person_nickname: person?.nickname || null, + snapshot_status: equipment.status, + source_borrow_record_id: activeBorrowRecord?.id || null, + is_unassigned: !person + } +} diff --git a/backend/src/utils/systemReservedPerson.ts b/backend/src/utils/systemReservedPerson.ts new file mode 100644 index 0000000..c666363 --- /dev/null +++ b/backend/src/utils/systemReservedPerson.ts @@ -0,0 +1,29 @@ +export const PUBLIC_ASSET_PERSON_SOURCE_SYSTEM = 'system_reserved' +export const PUBLIC_ASSET_PERSON_SOURCE_USERID = '__public_asset__' + +export const buildPublicAssetPersonSeed = () => ({ + nickname: '公共资产', + real_name: '公共资产', + job_number: null, + id_number: null, + department: null, + main_department_id: null, + position: '系统保留', + phone: null, + email: null, + status: 'active', + remarks: '系统保留人员:用于承接公共设备,不参与 HR 组织同步。', + source_userid: PUBLIC_ASSET_PERSON_SOURCE_USERID, + source_system: PUBLIC_ASSET_PERSON_SOURCE_SYSTEM, + source_main_department_id: null, + source_dept_ids: null, + source_department_name: null, + source_main_department_name: null, + department_sync_bucket: null, + manager_source_userid: null, + source_employee_status: null, + last_synced_at: null +}) + +export const isSystemReservedPerson = (person?: { source_system?: string | null } | null) => + person?.source_system === PUBLIC_ASSET_PERSON_SOURCE_SYSTEM diff --git a/downloads/print-agent/latest/manifest.json b/downloads/print-agent/latest/manifest.json index de3a9d1..c556ecf 100644 --- a/downloads/print-agent/latest/manifest.json +++ b/downloads/print-agent/latest/manifest.json @@ -1,9 +1,9 @@ { - "version": "0.2.5", - "published_at": "2026-03-26T21:07:29+08:00", - "filename": "print-agent-setup.exe", - "notes": "打印助手最新稳定版,当前为安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。", - "source_type": "server_release", - "relative_path": "downloads/print-agent/latest/print-agent-setup.exe", - "relativePath": "downloads/print-agent/latest/print-agent-setup.exe" -} \ No newline at end of file + "version": "0.2.6", + "published_at": "2026-03-28T23:59:00+08:00", + "filename": "print-agent-setup.exe", + "notes": "打印助手最新稳定版,已修复远程批量打印时网页成品标签图没有被打印助手实际使用的问题;现在优先直接打印网页最终 PNG,批量标签的版式与网页预览保持一致,并同步放宽大批量任务的请求容量限制。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/latest/print-agent-setup.exe", + "relativePath": "downloads/print-agent/latest/print-agent-setup.exe" +} diff --git a/downloads/print-agent/releases/0.2.5/manifest.json b/downloads/print-agent/releases/0.2.5/manifest.json index e4269e2..8b367ec 100644 --- a/downloads/print-agent/releases/0.2.5/manifest.json +++ b/downloads/print-agent/releases/0.2.5/manifest.json @@ -1,9 +1,9 @@ { - "version": "0.2.5", - "published_at": "2026-03-26T21:07:29+08:00", - "filename": "print-agent-setup-0.2.5.exe", - "notes": "打印助手 v0.2.5,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。", - "source_type": "server_release", - "relative_path": "downloads/print-agent/releases/0.2.5/print-agent-setup-0.2.5.exe", - "relativePath": "downloads/print-agent/releases/0.2.5/print-agent-setup-0.2.5.exe" -} \ No newline at end of file + "version": "0.2.5", + "published_at": "2026-03-26T21:07:29+08:00", + "filename": "print-agent-setup-0.2.5.exe", + "notes": "打印助手 v0.2.5,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/releases/0.2.5/print-agent-setup-0.2.5.exe", + "relativePath": "downloads/print-agent/releases/0.2.5/print-agent-setup-0.2.5.exe" +} diff --git a/downloads/print-agent/releases/0.2.6/manifest.json b/downloads/print-agent/releases/0.2.6/manifest.json new file mode 100644 index 0000000..30709b7 --- /dev/null +++ b/downloads/print-agent/releases/0.2.6/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "0.2.6", + "published_at": "2026-03-28T23:59:00+08:00", + "filename": "print-agent-setup-0.2.6.exe", + "notes": "打印助手 v0.2.6,已修复远程批量打印时网页成品标签图没有被打印助手实际使用的问题;现在优先直接打印网页最终 PNG,批量标签的版式与网页预览保持一致,并同步放宽大批量任务的请求容量限制。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/releases/0.2.6/print-agent-setup-0.2.6.exe", + "relativePath": "downloads/print-agent/releases/0.2.6/print-agent-setup-0.2.6.exe" +} diff --git a/frontend/scripts/test-borrow-person-search.ts b/frontend/scripts/test-borrow-person-search.ts index fb412b9..87be4bf 100644 --- a/frontend/scripts/test-borrow-person-search.ts +++ b/frontend/scripts/test-borrow-person-search.ts @@ -12,7 +12,7 @@ const samplePersons = [ id: 1, nickname: '阿杰', real_name: '王杰', - id_number: 'A1001', + job_number: 'A1001', department: '设计中心', department_names: ['设计中心', '品牌设计部'], phone: '13800000001', @@ -22,7 +22,7 @@ const samplePersons = [ id: 2, nickname: '小周', real_name: '周晴', - id_number: 'A1002', + job_number: 'A1002', department: '行政部', department_names: ['行政部'], phone: '13800000002', @@ -32,7 +32,7 @@ const samplePersons = [ id: 3, nickname: '离职张', real_name: '张离', - id_number: 'A1003', + job_number: 'A1003', department: '设计中心', department_names: ['设计中心'], phone: '13800000003', @@ -48,8 +48,8 @@ const byRealName = filterManualBorrowPersons(samplePersons as any, '周晴') assert.equal(byRealName.length, 1, '姓名搜索应返回正确人员') assert.equal(byRealName[0].id, 2, '姓名匹配结果不正确') -const byIdNumber = filterManualBorrowPersons(samplePersons as any, 'A1001') -assert.equal(byIdNumber[0].id, 1, '工号搜索应支持') +const byJobNumber = filterManualBorrowPersons(samplePersons as any, 'A1001') +assert.equal(byJobNumber[0].id, 1, '工号搜索应支持') const byDepartment = filterManualBorrowPersons(samplePersons as any, '设计') assert.equal(byDepartment.length, 1, '搜索部门时应排除已停用人员') @@ -89,7 +89,7 @@ const multiResults = filterManualBorrowPersons( id: 4, nickname: '阿杰2', real_name: '王杰同名', - id_number: 'A1004', + job_number: 'A1004', department: '设计中心', department_names: ['设计中心'], phone: '13800000004', diff --git a/frontend/scripts/test-inventory-public-asset.ts b/frontend/scripts/test-inventory-public-asset.ts new file mode 100644 index 0000000..a617223 --- /dev/null +++ b/frontend/scripts/test-inventory-public-asset.ts @@ -0,0 +1,33 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + getInventoryPublicAssetLabel, + isInventoryPublicAssetPerson +} from '../src/views/inventory/inventoryPublicAsset.ts' + +test('isInventoryPublicAssetPerson 应识别公共资产保留人员', () => { + assert.equal( + isInventoryPublicAssetPerson({ + person_source_userid: '__public_asset__', + person_nickname: '公共资产', + person_name: '公共资产' + }), + true + ) +}) + +test('isInventoryPublicAssetPerson 不应把普通人员误判为公共资产', () => { + assert.equal( + isInventoryPublicAssetPerson({ + person_source_userid: 'ding_001', + person_nickname: '三藏', + person_name: '张三' + }), + false + ) +}) + +test('getInventoryPublicAssetLabel 应返回系统保留标签文案', () => { + assert.equal(getInventoryPublicAssetLabel(), '系统保留') +}) diff --git a/frontend/scripts/test-local-batch-print.ts b/frontend/scripts/test-local-batch-print.ts new file mode 100644 index 0000000..3c32029 --- /dev/null +++ b/frontend/scripts/test-local-batch-print.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { buildLocalBatchPrintHtml } from '../src/utils/localBatchPrint.ts' + +const extractStyleBlock = (html: string) => { + const match = html.match(/ - - - ${images.map((img) => ``).join('')} - - - ` + const printHTML = buildLocalBatchPrintHtml(images, LABEL_PRESET) printWindow.document.write(printHTML) printWindow.document.close() - printWindow.onload = () => { - setTimeout(() => { - printWindow.print() + await waitForLocalBatchPrintWindowReady(printWindow) + + const closePrintWindow = () => { + if (!printWindow.closed) { printWindow.close() - }, 500) + } } + + const closeTimer = printWindow.setTimeout(() => { + closePrintWindow() + }, 1000) + + printWindow.addEventListener( + 'afterprint', + () => { + printWindow.clearTimeout(closeTimer) + closePrintWindow() + }, + { once: true } + ) + + printWindow.focus() + printWindow.print() } catch (error) { console.error('打印失败:', error) alert('打印失败,请重试') diff --git a/frontend/src/components/BorrowAnomalyHandler.vue b/frontend/src/components/BorrowAnomalyHandler.vue index 80d7648..bcc9511 100644 --- a/frontend/src/components/BorrowAnomalyHandler.vue +++ b/frontend/src/components/BorrowAnomalyHandler.vue @@ -94,7 +94,7 @@ @keyup.enter="handlePersonKeywordSubmit" />

- 补登记借出时,只能选择系统里当前启用中的人员。 + 这里支持搜索已停用人员,但只用于补登记历史借出、处理离职人员名下资产。

@@ -116,13 +116,21 @@ >
-

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

+
+

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

+ + 已停用 + +

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

已选中 @@ -134,6 +142,10 @@ 当前没有匹配到使用者,请换个关键词再试。

+ +

+ 已选中的人员当前为停用状态,本次仅用于补登记历史借出与后续入库处理。 +

@@ -250,7 +262,9 @@ const selectedPerson = computed(() => { return persons.value.find((person) => person.id === selectedPersonId.value) || null }) -const filteredPersons = computed(() => filterManualBorrowPersons(persons.value, personKeyword.value).slice(0, 8)) +const filteredPersons = computed(() => + filterManualBorrowPersons(persons.value, personKeyword.value, { allowInactive: true }).slice(0, 8) +) const showPersonSearchResults = computed(() => shouldShowManualBorrowPersonResults(selectedPerson.value, personKeyword.value) ) @@ -278,7 +292,7 @@ const ensurePersonsLoaded = async () => { personsLoading.value = true try { - const response = await getPersonOptions() + const response = await getPersonOptions({ includeInactive: true }) persons.value = response.data || [] } finally { personsLoading.value = false diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4d7360b..2d81bb9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -150,6 +150,8 @@ export interface Equipment { } | null current_borrow?: EquipmentCurrentBorrow | null borrow_anomaly?: BorrowAnomalyInfo | null + recent_change_at?: string | null + latest_mount_change_at?: string | null children?: Array<{ id: number equipment_code: string @@ -159,6 +161,7 @@ export interface Equipment { unit_price?: number status?: string mount_status?: string + updated_at?: string current_borrow?: EquipmentCurrentBorrow | null }> created_at: string @@ -177,7 +180,8 @@ export interface EquipmentCurrentBorrow { id: number nickname: string real_name: string - id_number?: string + job_number?: string | null + id_number?: string | null } | null } @@ -186,7 +190,8 @@ export interface Person { id: number nickname: string real_name: string - id_number?: string + job_number?: string | null + id_number?: string | null department?: string | null main_department_id?: number | null main_department?: Department | null @@ -222,7 +227,7 @@ export interface BorrowRecord { person_id: number person_name: string person_nickname: string - person_identifier?: string + person_identifier?: string | null person?: Person borrow_time: string expected_return_time?: string @@ -855,6 +860,7 @@ export interface PrintJobListData extends PaginatedResponse {} export interface CreateAssetLabelPrintJobPayload { equipment_ids: number[] rendered_labels: AssetLabelRenderedImage[] + target_terminal_id?: number } export interface CreateAssetLabelPrintJobResult { diff --git a/frontend/src/utils/localBatchPrint.ts b/frontend/src/utils/localBatchPrint.ts new file mode 100644 index 0000000..d0e73df --- /dev/null +++ b/frontend/src/utils/localBatchPrint.ts @@ -0,0 +1,123 @@ +export interface LocalBatchPrintPreset { + widthMm: number + heightMm: number +} + +const waitForPopupLoad = (printWindow: Window) => + new Promise((resolve) => { + if (printWindow.document.readyState === 'complete') { + resolve() + return + } + + const handleLoad = () => resolve() + printWindow.addEventListener('load', handleLoad, { once: true }) + }) + +const waitForPopupFrame = (printWindow: Window) => + new Promise((resolve) => { + printWindow.requestAnimationFrame(() => resolve()) + }) + +const waitForImageReady = async (image: HTMLImageElement) => { + if (!image.complete || image.naturalWidth === 0) { + await new Promise((resolve, reject) => { + const handleLoad = () => { + cleanup() + resolve() + } + + const handleError = () => { + cleanup() + reject(new Error('标签图片加载失败,无法发起打印')) + } + + const cleanup = () => { + image.removeEventListener('load', handleLoad) + image.removeEventListener('error', handleError) + } + + image.addEventListener('load', handleLoad) + image.addEventListener('error', handleError) + }) + } + + if (typeof image.decode === 'function') { + try { + await image.decode() + } catch (error) { + // 某些浏览器会在 data URL 上抛 decode 异常,但图片实际上已可用于打印。 + void error + } + } +} + +export const waitForLocalBatchPrintWindowReady = async (printWindow: Window) => { + await waitForPopupLoad(printWindow) + + const images = Array.from(printWindow.document.images) + await Promise.all(images.map((image) => waitForImageReady(image))) + + // 连续等待两帧,尽量让浏览器把图片布局和分页都稳定下来后再拉起打印。 + await waitForPopupFrame(printWindow) + await waitForPopupFrame(printWindow) +} + +export const buildLocalBatchPrintHtml = ( + images: string[], + preset: LocalBatchPrintPreset +) => ` + + + + + 二维码打印 + + + + ${images.map((img) => ``).join('')} + + +` diff --git a/frontend/src/views/borrow/BorrowList.vue b/frontend/src/views/borrow/BorrowList.vue index 3d80283..389de4c 100644 --- a/frontend/src/views/borrow/BorrowList.vue +++ b/frontend/src/views/borrow/BorrowList.vue @@ -46,7 +46,7 @@ v-model.trim="filters.person_name" class="input" type="text" - placeholder="输入花名、姓名或证件号" + placeholder="输入花名、姓名或工号" @keyup.enter="handleSearch" />

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

-

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

+

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

已选中 diff --git a/frontend/src/views/borrow/manualBorrowPersonSearch.ts b/frontend/src/views/borrow/manualBorrowPersonSearch.ts index 09eb60b..d44e5dc 100644 --- a/frontend/src/views/borrow/manualBorrowPersonSearch.ts +++ b/frontend/src/views/borrow/manualBorrowPersonSearch.ts @@ -2,7 +2,7 @@ export interface ManualBorrowSearchPerson { id: number nickname: string real_name: string - id_number?: string | null + job_number?: string | null department?: string | null department_names?: string[] phone?: string | null @@ -26,19 +26,19 @@ const buildDepartmentText = ( } const getMatchScore = ( - person: Pick, + person: Pick, keyword: string ) => { const nickname = normalize(person.nickname) const realName = normalize(person.real_name) - const idNumber = normalize(person.id_number) + const jobNumber = normalize(person.job_number) if (nickname === keyword) return 0 if (realName === keyword) return 1 - if (idNumber === keyword) return 2 + if (jobNumber === keyword) return 2 if (nickname.startsWith(keyword)) return 3 if (realName.startsWith(keyword)) return 4 - if (idNumber.startsWith(keyword)) return 5 + if (jobNumber.startsWith(keyword)) return 5 if (nickname.includes(keyword)) return 6 if (realName.includes(keyword)) return 7 return 8 @@ -46,11 +46,15 @@ const getMatchScore = ( export const filterManualBorrowPersons = ( persons: T[], - keyword: string + keyword: string, + options: { + allowInactive?: boolean + } = {} ) => { const normalizedKeyword = normalize(keyword) + const allowInactive = Boolean(options.allowInactive) const list = persons.filter((person) => { - if (person.status !== 'active') { + if (!allowInactive && person.status !== 'active') { return false } @@ -61,13 +65,18 @@ export const filterManualBorrowPersons = ( return ( includesKeyword(person.nickname, normalizedKeyword) || includesKeyword(person.real_name, normalizedKeyword) || - includesKeyword(person.id_number, normalizedKeyword) || + includesKeyword(person.job_number, normalizedKeyword) || includesKeyword(buildDepartmentText(person), normalizedKeyword) || includesKeyword(person.phone, normalizedKeyword) ) }) return list.sort((left, right) => { + const statusDiff = Number(left.status !== 'active') - Number(right.status !== 'active') + if (statusDiff !== 0) { + return statusDiff + } + if (!normalizedKeyword) { return `${left.nickname}${left.real_name}`.localeCompare( `${right.nickname}${right.real_name}`, @@ -100,7 +109,7 @@ export const findPreferredManualBorrowPerson = { await createDisposal({ equipment_id: Number(route.params.id), ...disposalForm.value }) showDisposalForm.value = false disposalForm.value = { disposal_reason: '', disposal_method: '报废', estimated_value: 0, notes: '' } - fetchDisposalRecords() + await fetchEquipmentData() + await fetchDisposalRecords() alert('报废申请已提交') } catch (error: any) { alert(error?.message || '提交报废申请失败') } } diff --git a/frontend/src/views/equipment/EquipmentList.vue b/frontend/src/views/equipment/EquipmentList.vue index 77ee4f5..248b97d 100644 --- a/frontend/src/views/equipment/EquipmentList.vue +++ b/frontend/src/views/equipment/EquipmentList.vue @@ -30,11 +30,13 @@ - +
-
-
-
+
+ +
+ +
- - - -
- -
- +
+ - - +
+ + + +
+
+ + +
+ + + + + +
+
+
+ + +
+
+
+ + + +
+

使用操作区

+ 批量借还
- -
- - - - + +
+
+ + + +
+

请在下方资产列表中选择需要借出或归还的资产

+

支持多选批量操作,也可点击单行「借出/归还」快捷操作

- -
-
已选 {{ selectedIds.length }} 项
-
- - - - - + +
+ +
+
+ {{ selectedIds.length }} + 项资产已选择 +
+ +
+ + +
+
+ + {{ getEquipmentBrief(id) }} + + + +{{ selectedIds.length - 8 }} 项 + +
+
+ + +
+ + + +
@@ -120,7 +227,7 @@
-
+
@@ -153,19 +260,29 @@

部门

{{ equipment.department?.name || '-' }}

+
+

最近变动

+

{{ formatRecentChangeTime(equipment) }}

+
+ + @@ -176,7 +293,7 @@
- +
@@ -225,6 +342,7 @@ {{ equipment.equipment_code }} {{ equipment.equipment_type }} {{ equipment.equipment_model || '未填写型号' }} + 最近变动:{{ formatRecentChangeTime(equipment) }} @@ -252,11 +370,17 @@ + + @@ -271,7 +395,7 @@
- +
@@ -309,57 +433,318 @@ - +
-

批量借出 ({{ selectedIds.length }}项)

-
-
- - -
-
- -
-
- - +

{{ borrowDialogTitle }}

+ + +
+

当前资产

+

{{ singleActionContext.equipment.equipment_name }}

+

{{ singleActionContext.equipment.equipment_code }}

+

仅借出当前资产,不会连带挂载配件一起操作。

+
+ + +
+

待借出资产

+

共 {{ selectedIds.length }} 项资产将批量借出

+
+
+ {{ getEquipmentBrief(id) }} + +{{ selectedIds.length - 10 }} +
+ +
+ +
+ + +

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

+ + +
+
+

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

+

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

+
+ +
+ +
+ +

+ 正在搜索使用者... +

+

+ 当前没有匹配到使用者,请换个关键词再试。 +

+
+ + +
+
+
+

已选中:{{ borrowSelectedPerson.nickname }} · {{ borrowSelectedPerson.real_name }}

+

{{ borrowSelectedPerson.department }}

+
+ +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
- - + +
- +
-

批量归还 ({{ selectedIds.length }}项)

-
-
- - +

{{ returnDialogTitle }}

+ + +
+
+

批量归还模式

+

共 {{ selectedIds.length }} 项资产将批量归还

+

适用于无异常、借用记录完整的资产批量归还。

-
- - + +
+
+ + +
+ +
+ + +
+ + +
+
+

单资产归还 - 需选择借用记录

+

{{ returnAssetInfo.equipment_name }}

+

{{ returnAssetInfo.equipment_code }}

+

该资产有多条未归还记录,请选择正确的归还人。

+
+ +
+ +
+ + +

{{ returnAssetInfo.borrow_records.length > 1 ? '该资产存在多条未归还记录,请搜索并点选正确使用者。' : '该资产当前只有 1 条未归还记录。' }}

+ + +
+
+

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

+

共 {{ filteredReturnRecords.length }} 条

+
+ +
+ +
+ +

+ 当前没有匹配到使用者,请换个关键词再试。 +

+
+ + +
+
+
+

已选中:{{ returnSelectedRecord.person_nickname }} · {{ returnSelectedRecord.person_name }}

+

{{ returnSelectedRecord.person.department }}

+
+ +
+
+
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+ + + +
+

{{ returnAnomalyInfo.equipment_name }}

+

{{ returnAnomalyInfo.equipment_code }}

+

{{ returnAnomalyInfo.message }}

+

该资产存在借用异常,需要先处理异常才能归还。

+
+
+ +
+ + +
+
+ + + +
+

检测到配件差异,需要确认

+

该资产的当前配件与借出时不一致,请核对后再确认归还。

+ + +
+
+

缺失配件({{ returnChildrenDiffInfo.removed.length }}件):

+
+ + {{ child.equipment_name || child.equipment_code }} + + +{{ returnChildrenDiffInfo.removed.length - 5 }} +
+
+ +
+

新增配件({{ returnChildrenDiffInfo.added.length }}件):

+
+ + {{ child.equipment_name || child.equipment_code }} + + +{{ returnChildrenDiffInfo.added.length - 5 }} +
+
+
+ +

点击"确认归还"将接受上述差异并完成入库。

+
+
+
+
- - + +
@@ -382,35 +767,55 @@
+