From 0dc3c09315bd762c88b19286f83efc98672a3eaa Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 27 Mar 2026 20:18:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B5=84=E4=BA=A7=E4=B8=8E=E6=89=93?= =?UTF-8?q?=E5=8D=B0):=20=E6=8F=90=E4=BA=A4=E8=BF=91=E6=9C=9F=E5=80=9F?= =?UTF-8?q?=E8=BF=98=E7=9B=98=E7=82=B9=E4=B8=8E=E6=89=93=E5=8D=B0=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E6=BA=90=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提交借用单缺失异常处理、扫码借还搜索与站内消息快捷处理源码 - 提交盘点范围、多终端打印、组织同步与认证模式相关源码 - 提交 .NET/Node 打印助手及版本清单源码 --- .../scripts/prepareSheet2SupplementDryRun.js | 550 ++ backend/src/controllers/borrowController.ts | 499 +- .../src/controllers/equipmentController.ts | 49 +- .../src/controllers/inventoryController.ts | 45 +- .../src/controllers/notificationController.ts | 21 + backend/src/controllers/printController.ts | 2 +- backend/src/index.ts | 2 +- backend/src/routes/auth.ts | 9 +- backend/src/routes/borrow.ts | 8 +- backend/src/routes/inventory.ts | 2 + .../services/__tests__/hrSyncService.test.ts | 91 + .../services/__tests__/printService.test.ts | 88 +- backend/src/services/hrSyncHelpers.ts | 113 + backend/src/services/hrSyncService.ts | 285 +- backend/src/services/inventoryService.ts | 337 +- backend/src/services/printService.ts | 386 +- .../src/utils/__tests__/borrowAnomaly.test.ts | 80 + .../utils/__tests__/borrowDepartment.test.ts | 71 + .../utils/__tests__/equipmentDeletion.test.ts | 93 + .../utils/__tests__/inventoryScope.test.ts | 88 + .../__tests__/inventoryTaskLifecycle.test.ts | 11 + backend/src/utils/borrowAnomaly.ts | 57 + backend/src/utils/borrowDepartment.ts | 82 + backend/src/utils/equipmentDeletion.ts | 41 + backend/src/utils/inventoryScope.ts | 70 + backend/src/utils/inventoryTaskLifecycle.ts | 3 + docker-compose.yml | 2 +- downloads/print-agent/latest/manifest.json | 16 +- .../print-agent/releases/0.2.1/manifest.json | 9 + .../print-agent/releases/0.2.3/manifest.json | 9 + .../print-agent/releases/0.2.4/manifest.json | 9 + .../print-agent/releases/0.2.5/manifest.json | 9 + frontend/scripts/test-auth-mode.ts | 14 + frontend/scripts/test-borrow-anomaly.ts | 38 + frontend/scripts/test-borrow-manual-search.ts | 78 + frontend/scripts/test-borrow-person-search.ts | 103 + frontend/scripts/test-borrow-scan-state.ts | 29 + .../scripts/test-notification-quick-return.ts | 76 + frontend/scripts/test-return-manual-search.ts | 80 + frontend/scripts/test-return-record-search.ts | 86 + frontend/scripts/test-return-scan-state.ts | 29 + frontend/src/api/borrow.ts | 68 +- frontend/src/api/index.ts | 46 +- frontend/src/api/inventory.ts | 9 + frontend/src/api/print.ts | 8 +- frontend/src/api/publicInventory.ts | 3 +- frontend/src/api/sync.ts | 4 +- frontend/src/auth/keycloak.ts | 2 +- frontend/src/components/BarcodePrint.vue | 17 +- .../src/components/BorrowAnomalyHandler.vue | 403 ++ frontend/src/main.ts | 3 +- frontend/src/router/index.ts | 3 +- frontend/src/stores/auth.ts | 4 +- frontend/src/types/index.ts | 49 + frontend/src/utils/authMode.ts | 11 + frontend/src/utils/borrowAnomaly.ts | 15 + frontend/src/views/Login.vue | 3 +- frontend/src/views/borrow/BorrowScan.vue | 450 +- frontend/src/views/borrow/ReturnScan.vue | 629 +- frontend/src/views/borrow/borrowScanState.ts | 8 + .../views/borrow/manualBorrowPersonSearch.ts | 148 + .../src/views/borrow/manualBorrowSearch.ts | 103 + .../views/borrow/manualReturnRecordSearch.ts | 147 + .../src/views/borrow/manualReturnSearch.ts | 112 + frontend/src/views/borrow/returnScanState.ts | 11 + .../src/views/equipment/EquipmentForm.vue | 18 +- .../src/views/equipment/EquipmentList.vue | 97 +- .../src/views/inventory/InventoryCreate.vue | 294 +- .../src/views/inventory/InventoryDetail.vue | 49 +- .../src/views/inventory/InventoryList.vue | 104 +- .../views/inventory/PublicInventoryTask.vue | 10 +- .../views/inventory/inventoryScopeSummary.ts | 60 + .../notifications/NotificationCenter.vue | 810 ++- .../notifications/notificationQuickReturn.ts | 22 + .../settings/components/HrSyncManagement.vue | 6 +- .../components/PrintTerminalManagement.vue | 14 +- .../FixedAssetPrintAgent.csproj | 6 +- .../FixedAssetPrintAgent/LabelPrintService.cs | 73 +- .../FixedAssetPrintAgent/MainForm.cs | 1108 +++- .../FixedAssetPrintAgent/PrintAgentModels.cs | 12 + .../FixedAssetPrintAgent/PrintAgentRuntime.cs | 24 +- print-agent-dotnet/build-release.ps1 | 25 +- .../installer/ChineseSimplified.isl | 418 ++ .../installer/FixedAssetPrintAgent.iss | 23 +- print-agent/.gitignore | 5 + print-agent/README.md | 36 + print-agent/package-lock.json | 5082 +++++++++++++++++ print-agent/package.json | 36 + .../resources/company-driver-archive-note.txt | 10 + print-agent/resources/driver-resources.json | 15 + print-agent/src/config/storage.js | 92 + print-agent/src/index.html | 170 + print-agent/src/main.js | 275 + print-agent/src/preload.js | 17 + print-agent/src/renderer.js | 209 + print-agent/src/services/agentRuntime.js | 274 + print-agent/src/services/apiClient.js | 166 + print-agent/src/services/driverManifest.js | 61 + print-agent/src/services/electronPrinter.js | 101 + print-agent/src/services/jobProcessor.js | 121 + print-agent/src/services/labelDocument.js | 428 ++ print-agent/src/styles.css | 250 + print-agent/tests/agent-runtime.test.mjs | 104 + print-agent/tests/api-client.test.mjs | 115 + print-agent/tests/config-store.test.mjs | 58 + print-agent/tests/driver-manifest.test.mjs | 45 + print-agent/tests/job-processor.test.mjs | 96 + print-agent/tests/label-document.test.mjs | 46 + scripts/backup-equipment.sh | 11 +- .../tests/backup-equipment-retention-test.sh | 94 + 110 files changed, 16051 insertions(+), 965 deletions(-) create mode 100644 backend/scripts/prepareSheet2SupplementDryRun.js create mode 100644 backend/src/services/__tests__/hrSyncService.test.ts create mode 100644 backend/src/services/hrSyncHelpers.ts create mode 100644 backend/src/utils/__tests__/borrowAnomaly.test.ts create mode 100644 backend/src/utils/__tests__/borrowDepartment.test.ts create mode 100644 backend/src/utils/__tests__/equipmentDeletion.test.ts create mode 100644 backend/src/utils/__tests__/inventoryScope.test.ts create mode 100644 backend/src/utils/__tests__/inventoryTaskLifecycle.test.ts create mode 100644 backend/src/utils/borrowAnomaly.ts create mode 100644 backend/src/utils/borrowDepartment.ts create mode 100644 backend/src/utils/equipmentDeletion.ts create mode 100644 backend/src/utils/inventoryScope.ts create mode 100644 backend/src/utils/inventoryTaskLifecycle.ts create mode 100644 downloads/print-agent/releases/0.2.1/manifest.json create mode 100644 downloads/print-agent/releases/0.2.3/manifest.json create mode 100644 downloads/print-agent/releases/0.2.4/manifest.json create mode 100644 downloads/print-agent/releases/0.2.5/manifest.json create mode 100644 frontend/scripts/test-auth-mode.ts create mode 100644 frontend/scripts/test-borrow-anomaly.ts create mode 100644 frontend/scripts/test-borrow-manual-search.ts create mode 100644 frontend/scripts/test-borrow-person-search.ts create mode 100644 frontend/scripts/test-borrow-scan-state.ts create mode 100644 frontend/scripts/test-notification-quick-return.ts create mode 100644 frontend/scripts/test-return-manual-search.ts create mode 100644 frontend/scripts/test-return-record-search.ts create mode 100644 frontend/scripts/test-return-scan-state.ts create mode 100644 frontend/src/components/BorrowAnomalyHandler.vue create mode 100644 frontend/src/utils/authMode.ts create mode 100644 frontend/src/utils/borrowAnomaly.ts create mode 100644 frontend/src/views/borrow/borrowScanState.ts create mode 100644 frontend/src/views/borrow/manualBorrowPersonSearch.ts create mode 100644 frontend/src/views/borrow/manualBorrowSearch.ts create mode 100644 frontend/src/views/borrow/manualReturnRecordSearch.ts create mode 100644 frontend/src/views/borrow/manualReturnSearch.ts create mode 100644 frontend/src/views/borrow/returnScanState.ts create mode 100644 frontend/src/views/inventory/inventoryScopeSummary.ts create mode 100644 frontend/src/views/notifications/notificationQuickReturn.ts create mode 100644 print-agent-dotnet/installer/ChineseSimplified.isl create mode 100644 print-agent/.gitignore create mode 100644 print-agent/README.md create mode 100644 print-agent/package-lock.json create mode 100644 print-agent/package.json create mode 100644 print-agent/resources/company-driver-archive-note.txt create mode 100644 print-agent/resources/driver-resources.json create mode 100644 print-agent/src/config/storage.js create mode 100644 print-agent/src/index.html create mode 100644 print-agent/src/main.js create mode 100644 print-agent/src/preload.js create mode 100644 print-agent/src/renderer.js create mode 100644 print-agent/src/services/agentRuntime.js create mode 100644 print-agent/src/services/apiClient.js create mode 100644 print-agent/src/services/driverManifest.js create mode 100644 print-agent/src/services/electronPrinter.js create mode 100644 print-agent/src/services/jobProcessor.js create mode 100644 print-agent/src/services/labelDocument.js create mode 100644 print-agent/src/styles.css create mode 100644 print-agent/tests/agent-runtime.test.mjs create mode 100644 print-agent/tests/api-client.test.mjs create mode 100644 print-agent/tests/config-store.test.mjs create mode 100644 print-agent/tests/driver-manifest.test.mjs create mode 100644 print-agent/tests/job-processor.test.mjs create mode 100644 print-agent/tests/label-document.test.mjs create mode 100644 scripts/tests/backup-equipment-retention-test.sh diff --git a/backend/scripts/prepareSheet2SupplementDryRun.js b/backend/scripts/prepareSheet2SupplementDryRun.js new file mode 100644 index 0000000..ebe57c1 --- /dev/null +++ b/backend/scripts/prepareSheet2SupplementDryRun.js @@ -0,0 +1,550 @@ +const fs = require('fs') +const path = require('path') +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 DEFAULT_SOURCE_WORKBOOK = path.resolve(__dirname, '../../docs/总表待导入.xlsx') +const DEFAULT_SOURCE_SHEET = 'Sheet2' +const DEFAULT_CANDIDATE_WORKBOOK = path.resolve( + __dirname, + '../../docs/总表待导入-Sheet2_严格可安全补导候选-2026-03-27.xlsx' +) +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 = { + '3D视觉(广东)': '视觉中心', + '3D视觉(常州)': '视觉中心', + 事业一部: '运营中心A', + 事业二部: '运营中心A', + 事业三部: '运营中心A', + 事业四部: '运营中心A', + 策划一部: '品牌策划中心', + 策划二部: '品牌策划中心', + 策划三部: '品牌策划中心', + 策划四部: '品牌策划中心', + 策划五部: '品牌策划中心', + 策划六部: '品牌策划中心', + 客服二部: '客服中心', + 客服三部: '客服中心', + 客服四部: '客服中心', + 客服五部: '客服中心', + 客服六部: '客服中心', + 品牌营销策划中心: '品牌策划中心', + 品牌创意视觉中心: '视觉中心', + 品牌供应链中心: '供应链中心', + 智管信息中心: '效率部(懂业务的技术部)', + 总账组: '财务中心', + 结算组: '财务中心', + 视觉一部: '视觉中心', + 视觉二部: '视觉中心', + 视觉三部: '视觉中心', + 视觉四部: '视觉中心', + 视觉五部: '视觉中心', + 视觉六部: '视觉中心', + '基本盘 采购部': '供应链中心', + '基本盘 核价组': '供应链中心', + 产品视觉中心: '视觉中心', + 分销外贸: '外贸部' +} + +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 options = { + sourceWorkbook: DEFAULT_SOURCE_WORKBOOK, + sourceSheet: DEFAULT_SOURCE_SHEET, + candidateWorkbook: DEFAULT_CANDIDATE_WORKBOOK, + outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME) + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--source-workbook') { + options.sourceWorkbook = args[index + 1] || options.sourceWorkbook + index += 1 + } else if (arg === '--source-sheet') { + options.sourceSheet = args[index + 1] || options.sourceSheet + index += 1 + } else if (arg === '--candidate-workbook') { + options.candidateWorkbook = args[index + 1] || options.candidateWorkbook + index += 1 + } else if (arg === '--output-workbook') { + options.outputWorkbook = args[index + 1] || options.outputWorkbook + index += 1 + } + } + + return options +} + +const resolvePathArg = (value) => { + const normalized = normalizeText(value) + if (!normalized) { + return '' + } + return path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized) +} + +const ensureOutputDir = () => { + const timestamp = buildTimestampLabel() + const outputDir = path.join(OUTPUT_ROOT, `sheet2_public_dry_run_${timestamp}`) + fs.mkdirSync(outputDir, { recursive: true }) + return outputDir +} + +const writeJson = (filePath, value) => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +const writeWorkbook = async (filePath, sheets) => { + const workbook = new ExcelJS.Workbook() + workbook.creator = 'Codex' + workbook.created = new Date() + + for (const sheet of sheets) { + const worksheet = workbook.addWorksheet(sheet.name) + const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}] + const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))] + worksheet.columns = headers.map((header) => ({ + header, + key: header, + width: Math.min(Math.max(header.length + 6, 14), 40) + })) + rows.forEach((row) => worksheet.addRow(row)) + worksheet.getRow(1).font = { bold: true, name: 'Arial' } + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + worksheet.eachRow((row) => { + row.font = { name: 'Arial' } + row.alignment = { vertical: 'middle', wrapText: true } + }) + } + + await workbook.xlsx.writeFile(filePath) +} + +const resolveAvailableWorkbookPath = (preferredPath) => { + const resolvedPreferredPath = resolvePathArg(preferredPath) + if (!resolvedPreferredPath) { + throw new Error('输出工作簿路径不能为空') + } + + const workbookDir = path.dirname(resolvedPreferredPath) + const extension = path.extname(resolvedPreferredPath) || '.xlsx' + const baseName = path.basename(resolvedPreferredPath, extension) + const timestampedPath = path.join(workbookDir, `${baseName}-${buildTimestampLabel()}${extension}`) + + try { + const fd = fs.openSync(resolvedPreferredPath, 'a') + fs.closeSync(fd) + return resolvedPreferredPath + } catch (error) { + if (error && (error.code === 'EBUSY' || error.code === 'EPERM')) { + return timestampedPath + } + throw error + } +} + +const loadSourceRowMap = (sourceWorkbookPath, sourceSheetName) => { + const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false }) + const sheet = workbook.Sheets[sourceSheetName] + if (!sheet) { + throw new Error(`未找到源工作表: ${sourceSheetName}`) + } + + const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' }) + if (!matrix.length) { + return new Map() + } + + const headerIndex = new Map() + matrix[0].forEach((header, index) => { + headerIndex.set(normalizeText(header), index) + }) + + const codeColumnIndex = headerIndex.get('资产编码') + if (codeColumnIndex === undefined) { + throw new Error('源工作表缺少“资产编码”列') + } + + const rowMap = new Map() + for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) { + const row = matrix[rowIndex] + const code = normalizeText(row[codeColumnIndex]).toUpperCase() + if (code && !rowMap.has(code)) { + rowMap.set(code, rowIndex + 1) + } + } + + return rowMap +} + +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 script = ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +;(async () => { + const [assetTypes, departments, locations] = await Promise.all([ + prisma.assetType.findMany({ + where: { status: 'active' }, + select: { id: true, name: true, short_name: true }, + orderBy: { name: 'asc' } + }), + prisma.department.findMany({ + where: { status: 'active', source_system: 'ding_hr_business' }, + select: { id: true, name: true }, + orderBy: { name: 'asc' } + }), + prisma.storageLocation.findMany({ + where: { status: 'active' }, + select: { id: true, name: true }, + orderBy: { name: 'asc' } + }) + ]) + + console.log(JSON.stringify({ assetTypes, departments, locations })) + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` + + const result = spawnSync( + PLINK_PATH, + ['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND], + { + encoding: 'utf8', + input: script, + maxBuffer: 32 * 1024 * 1024, + windowsHide: true + } + ) + + if (result.error) { + throw result.error + } + + if (result.status !== 0) { + throw new Error(`读取线上基础数据失败: ${result.stderr || result.stdout}`) + } + + const stdout = normalizeText(result.stdout) + if (!stdout) { + throw new Error('线上基础数据返回为空') + } + + return JSON.parse(stdout) +} + +const buildImportDescription = (row, sourceRow) => { + const parts = [ + ['source_workbook', 'docs/总表待导入.xlsx'], + ['source_sheet', 'Sheet2'], + ['source_row', sourceRow || ''], + ['source_status', normalizeText(row['当前状态'])], + ['raw_department', normalizeText(row['部门'])], + ['raw_location', normalizeText(row['地址'])], + ['raw_asset_category', normalizeText(row['资产分类'])] + ] + + 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.资产编码, + equipmentName: row.资产名称, + equipmentModel: row.规格型号 || null, + equipmentType: row.资产名称, + departmentName: row.系统归并部门 || '', + storageLocationName: row.系统归并地址 || '', + systemStatus: row.系统建议状态, + purchaseDate: null, + supplier: '', + unitPrice: 0, + description: row.导入备注 + })) + +const classifyRows = (candidateRows, sourceRowMap, liveRefs) => { + const departmentByName = new Map( + liveRefs.departments.map((item) => [normalizeLookupKey(item.name), item]) + ) + const locationByName = new Map( + liveRefs.locations.map((item) => [normalizeLookupKey(item.name), item]) + ) + + 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 || '(空)'}`) + } + + if (!reasons.length) { + readyRows.push(baseRow) + continue + } + + pendingRows.push({ + ...baseRow, + 待核对原因: reasons.join(';'), + 建议处理: reasons.some((item) => item.startsWith('地址未匹配')) + ? '先确认应归并到哪个系统地址,再导入' + : '先确认状态或部门后再导入' + }) + } + + return { readyRows, pendingRows } +} + +const buildSummaryRows = ({ total, ready, pending, addressPending, otherPending, liveRefs }) => [ + { 项目: '候选总数', 说明: total }, + { 项目: '可直接补录', 说明: ready }, + { 项目: '待人工核对', 说明: pending }, + { 项目: '其中地址待核对', 说明: addressPending }, + { 项目: '其中其他待核对', 说明: otherPending }, + { 项目: '状态映射规则', 说明: '在用/闲置 -> 系统“可用”;其余状态不自动导入' }, + { 项目: '人员处理规则', 说明: '本批当前使用员工全部为空,按公共设备处理,不自动建立借出记录' }, + { 项目: '线上部门数', 说明: liveRefs.departments.length }, + { 项目: '线上地址数', 说明: liveRefs.locations.length }, + { 项目: '线上资产类型数', 说明: liveRefs.assetTypes.length } +] + +const run = async () => { + const options = parseArgs() + const sourceWorkbookPath = resolvePathArg(options.sourceWorkbook) + const candidateWorkbookPath = resolvePathArg(options.candidateWorkbook) + + if (!fs.existsSync(sourceWorkbookPath)) { + throw new Error(`源工作簿不存在: ${sourceWorkbookPath}`) + } + + if (!fs.existsSync(candidateWorkbookPath)) { + 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 { readyRows, pendingRows } = classifyRows(candidateRows, sourceRowMap, liveRefs) + + const addressPendingRows = pendingRows.filter((row) => row.待核对原因.includes('地址未匹配')) + const otherPendingRows = pendingRows.filter((row) => !row.待核对原因.includes('地址未匹配')) + const readyPayload = buildReadyImportPayload(readyRows) + + const manifest = { + generatedAt: new Date().toISOString(), + sourceWorkbook: sourceWorkbookPath, + sourceSheet: options.sourceSheet, + candidateWorkbook: candidateWorkbookPath, + totalCandidates: candidateRows.length, + readyCount: readyRows.length, + pendingCount: pendingRows.length, + addressPendingCount: addressPendingRows.length, + otherPendingCount: otherPendingRows.length, + rulesVersion: 'sheet2-public-supplement-v1', + assumptions: [ + '当前使用员工为空的记录按公共设备处理', + '在用/闲置统一映射为系统可用', + '地址不能 100% 确认时不自动导入' + ] + } + + await writeWorkbook(workbookPath, [ + { + name: '汇总说明', + rows: buildSummaryRows({ + total: candidateRows.length, + ready: readyRows.length, + pending: pendingRows.length, + addressPending: addressPendingRows.length, + otherPending: otherPendingRows.length, + liveRefs + }) + }, + { name: '可直接补录', rows: readyRows }, + { name: '待人工核对', rows: pendingRows }, + { name: '地址待核对', rows: addressPendingRows }, + { name: '其他待核对', rows: otherPendingRows.length ? otherPendingRows : [{ 说明: '当前无记录' }] } + ]) + + writeJson(path.join(outputDir, 'sheet2_public_manifest.json'), manifest) + writeJson(path.join(outputDir, 'sheet2_public_ready_payload.json'), { + payloadCount: readyPayload.length, + payload: readyPayload + }) + writeJson(path.join(outputDir, 'sheet2_public_pending_rows.json'), { + pendingCount: pendingRows.length, + rows: pendingRows + }) + writeJson(path.join(outputDir, 'sheet2_live_refs.json'), liveRefs) + + console.log( + JSON.stringify( + { + outputDir, + workbookPath, + manifest + }, + null, + 2 + ) + ) +} + +run().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)) + process.exit(1) +}) diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index f5fc45f..75e30e9 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -6,6 +6,13 @@ import { getDisplayEquipmentValue, syncMountedChildrenState } from '../utils/assetValue' +import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly' +import { + normalizeReturnDepartmentInput, + resolveBorrowDepartmentUpdate +} from '../utils/borrowDepartment' +import { buildBusinessDepartmentWhere } from '../utils/organization' +import { logOperation } from './operationLogController' const parseNumber = (value: any): number | undefined => { const num = Number(value) @@ -18,6 +25,18 @@ const parseDate = (value?: any): Date | null => { return Number.isNaN(date.getTime()) ? null : date } +const parseBorrowAnomalyBorrowTime = (value?: any): Date | null => { + if (!value || typeof value !== 'string') return null + const normalizedValue = value.trim() + if (!normalizedValue) return null + + if (/^\d{4}-\d{2}-\d{2}$/.test(normalizedValue)) { + return parseDate(`${normalizedValue}T00:00:00`) + } + + return parseDate(normalizedValue) +} + const equipmentInclude = { storageLocation: { select: { @@ -38,6 +57,20 @@ const personInclude = { } } +const buildReturnAssetKeywordWhere = (keyword: string) => [ + { equipment_code: { contains: keyword } }, + { equipment_name: { contains: keyword } }, + { equipment_model: { contains: keyword } }, + { equipment_type: { contains: keyword } }, + { + storageLocation: { + is: { + name: { contains: keyword } + } + } + } +] + const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: string) => { if (equipmentId) { return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude }) @@ -48,6 +81,62 @@ const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: st return null } +const getBorrowAnomalyMetaFromEquipment = (equipment: { + status?: string | null + total_quantity?: number | null + parent_id?: number | null +}, activeBorrowCount: number) => + getBorrowAnomalyMeta({ + status: equipment.status, + activeBorrowCount, + totalQuantity: equipment.total_quantity, + parentId: equipment.parent_id + }) + +const loadBorrowAnomalyTarget = async (equipmentId: number) => { + const equipment = await getEquipmentByIdentifier(equipmentId) + + if (!equipment) { + throw new Error('资产不存在') + } + + const activeBorrowCount = await prisma.borrowRecord.count({ + where: { + equipment_id: equipment.id, + status: '已借出' + } + }) + + const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount) + + return { + equipment, + activeBorrowCount, + anomalyMeta + } +} + +const ensureBorrowAnomalyHandleable = ( + equipment: { + status?: string | null + total_quantity?: number | null + parent_id?: number | null + }, + activeBorrowCount: number +) => { + const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount) + + if (anomalyMeta.state !== 'borrow_record_missing') { + throw new Error('该资产当前不存在借用单缺失问题') + } + + if (!anomalyMeta.canHandle) { + throw new Error(anomalyMeta.blockedReason || '该资产当前不支持异常借出处理') + } + + return anomalyMeta +} + const ensurePersonAvailable = async (personId?: number) => { if (!personId) { throw new Error('使用者ID不能为空') @@ -59,6 +148,46 @@ const ensurePersonAvailable = async (personId?: number) => { return person } +const ensureBorrowAnomalyBorrowTime = (value?: any) => { + const borrowTime = parseBorrowAnomalyBorrowTime(value) + if (!borrowTime) { + throw new Error('借出日期不能为空') + } + + if (borrowTime.getTime() > Date.now()) { + throw new Error('借出日期不能晚于当前时间') + } + + return borrowTime +} + +const validateReturnDepartmentUpdate = async (input: Record) => { + const normalized = normalizeReturnDepartmentInput(input) + + if (normalized.departmentAction !== 'set' || !normalized.departmentId) { + return normalized + } + + const department = await prisma.department.findFirst({ + where: { + id: normalized.departmentId, + AND: [ + buildBusinessDepartmentWhere(), + { status: 'active' } + ] + }, + select: { + id: true + } + }) + + if (!department) { + throw new Error('所选业务部门不存在或已停用') + } + + return normalized +} + const ensureEquipmentBorrowable = (equipment: any) => { if (equipment.parent_id) { throw new Error('该子资产当前已挂载,请先卸载后再借用') @@ -134,6 +263,7 @@ const borrowEquipmentInTransaction = async ( const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0) const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出' + const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id) const values = calculateEquipmentValues({ unitPrice: Number(equipment.unit_price), totalQuantity: Number(equipment.total_quantity), @@ -151,7 +281,8 @@ const borrowEquipmentInTransaction = async ( total_value: values.total_value, borrowed_value: values.borrowed_value, available_value: values.available_value, - status: nextStatus + status: nextStatus, + ...(departmentUpdateData || {}) } }) @@ -246,7 +377,10 @@ const returnEquipmentInTransaction = async ( equipment: any, borrowRecord: any, returnCondition: string, - notes?: string + notes?: string, + equipmentUpdateData?: { + department_id: number | null + } | null ) => { return prisma.$transaction(async (tx) => { const returnTime = new Date() @@ -287,7 +421,8 @@ const returnEquipmentInTransaction = async ( total_value: values.total_value, borrowed_value: values.borrowed_value, available_value: values.available_value, - status: '可用' + status: '可用', + ...(equipmentUpdateData || {}) } }) @@ -306,6 +441,89 @@ const returnEquipmentInTransaction = async ( }) } +const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any) => { + return prisma.$transaction(async (tx) => { + const nextRemainingQuantity = Number(equipment.total_quantity) + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: nextRemainingQuantity, + status: '可用' + }) + + const updatedEquipment = await tx.equipment.update({ + where: { id: equipment.id }, + data: { + status: '可用', + remaining_quantity: nextRemainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value + }, + include: equipmentInclude + }) + + await syncMountedChildrenState(tx, equipment.id, '可用') + + return updatedEquipment + }) +} + +const registerBorrowAnomalyInTransaction = async ( + equipment: any, + person: any, + borrowTime: Date, + notes?: string +) => { + return prisma.$transaction(async (tx) => { + const { children, snapshot } = await getChildrenSnapshot(equipment.id) + const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id) + const nextRemainingQuantity = Math.max(Number(equipment.total_quantity) - 1, 0) + const values = calculateEquipmentValues({ + unitPrice: Number(equipment.unit_price), + totalQuantity: Number(equipment.total_quantity), + remainingQuantity: nextRemainingQuantity, + status: '已借出' + }) + + const record = await tx.borrowRecord.create({ + data: { + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + person_id: person.id, + person_name: person.real_name, + person_nickname: person.nickname, + person_identifier: person.id_number, + borrow_time: borrowTime, + notes: notes?.trim() || null, + status: '已借出', + children_snapshot: snapshot + } + }) + + const updatedEquipment = await tx.equipment.update({ + where: { id: equipment.id }, + data: { + status: '已借出', + remaining_quantity: nextRemainingQuantity, + total_value: values.total_value, + borrowed_value: values.borrowed_value, + available_value: values.available_value, + ...(departmentUpdateData || {}) + }, + include: equipmentInclude + }) + + await syncMountedChildrenState(tx, equipment.id, '已借出') + + return { + record, + updatedEquipment, + childrenCount: children.length + } + }) +} + const mapBorrowRecord = (record: any) => ({ id: record.id, equipment_id: record.equipment_id, @@ -320,7 +538,8 @@ const mapBorrowRecord = (record: any) => ({ real_name: record.person.real_name, department: record.person.mainDepartment?.name || record.person.department, main_department: record.person.mainDepartment || null, - phone: record.person.phone + phone: record.person.phone, + status: record.person.status } : { id: record.person_id, @@ -568,6 +787,13 @@ export const returnEquipment = async (req: Request, res: Response) => { confirm_children_diff } = req.body as Record + let departmentUpdate + try { + departmentUpdate = await validateReturnDepartmentUpdate(req.body || {}) + } catch (error: any) { + return errorResponse(res, error.message || '部门归属处理无效', 422) + } + const equipmentIdNum = parseNumber(equipment_id) const equipment = await getEquipmentByIdentifier(equipmentIdNum, equipment_code) @@ -605,7 +831,13 @@ export const returnEquipment = async (req: Request, res: Response) => { .join(';') : notes - await returnEquipmentInTransaction(equipment, borrowRecord, return_condition, finalNotes) + await returnEquipmentInTransaction( + equipment, + borrowRecord, + return_condition, + finalNotes, + departmentUpdate.equipmentUpdateData + ) successResponse( res, @@ -639,6 +871,13 @@ export const scanReturn = async (req: Request, res: Response) => { confirm_children_diff } = req.body as Record + let departmentUpdate + try { + departmentUpdate = await validateReturnDepartmentUpdate(req.body || {}) + } catch (error: any) { + return errorResponse(res, error.message || '部门归属处理无效', 422) + } + const equipment = await getEquipmentByIdentifier(undefined, equipment_code) if (!equipment) { @@ -679,7 +918,8 @@ export const scanReturn = async (req: Request, res: Response) => { equipment, borrowRecord, return_condition, - finalNotes + finalNotes, + departmentUpdate.equipmentUpdateData ) successResponse( @@ -834,6 +1074,15 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => { }) if (borrowRecords.length === 0) { + const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, 0) + if (anomalyMeta.state === 'borrow_record_missing') { + const suffix = anomalyMeta.blockedReason ? `;${anomalyMeta.blockedReason}` : '' + return errorResponse( + res, + `该资产当前标记为已借出,但系统里没有对应借用记录,无法直接归还,请先核对。${suffix}`, + 409 + ) + } return errorResponse(res, '未找到对应的借用记录', 404) } @@ -944,3 +1193,241 @@ export const getBorrowRecordList = async (req: Request, res: Response) => { errorResponse(res, error.message || '获取借用记录列表失败') } } + +// 搜索可归还资产 +export const searchReturnAssets = async (req: Request, res: Response) => { + try { + const keyword = typeof req.query.keyword === 'string' ? req.query.keyword.trim() : '' + + const equipmentList = await prisma.equipment.findMany({ + where: { + AND: [ + { + OR: [ + { status: '已借出' }, + { + borrow_records: { + some: { + status: '已借出' + } + } + } + ] + }, + ...(keyword + ? [ + { + OR: buildReturnAssetKeywordWhere(keyword) + } + ] + : []) + ] + }, + orderBy: [{ updated_at: 'desc' }, { id: 'desc' }], + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + status: true, + total_quantity: true, + parent_id: true, + storage_location_id: true, + storageLocation: { + select: { + id: true, + name: true, + code: true, + status: true, + } + }, + borrow_records: { + where: { + status: '已借出' + }, + orderBy: [{ borrow_time: 'desc' }, { id: 'desc' }], + select: { + id: true, + person_name: true, + person_nickname: true, + person_identifier: true, + person: { + select: { + department: true, + mainDepartment: { + select: { + name: true + } + } + } + } + } + } + } + }) + + const pushUnique = (bucket: string[], value?: string | null) => { + const normalized = (value || '').trim() + if (!normalized) return + if (!bucket.includes(normalized)) { + bucket.push(normalized) + } + } + + const list = equipmentList.map((equipment) => { + const borrowerNames: string[] = [] + const borrowerIdentifiers: string[] = [] + const borrowerDepartments: string[] = [] + + for (const record of equipment.borrow_records) { + const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ') + const borrowerIdentifier = record.person_identifier || null + const borrowerDepartment = + record.person?.mainDepartment?.name || record.person?.department || null + + pushUnique(borrowerNames, borrowerName) + pushUnique(borrowerIdentifiers, borrowerIdentifier) + pushUnique(borrowerDepartments, borrowerDepartment) + } + + const activeBorrowCount = equipment.borrow_records.length + const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount) + const searchState = anomalyMeta.state === 'borrow_record_missing' ? 'borrow_record_missing' : 'returnable' + + return { + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + equipment_name: equipment.equipment_name, + equipment_model: equipment.equipment_model, + equipment_type: equipment.equipment_type, + storage_location_id: equipment.storage_location_id, + storageLocation: equipment.storageLocation, + active_borrow_count: activeBorrowCount, + borrower_names: borrowerNames, + borrower_identifiers: borrowerIdentifiers, + borrower_departments: borrowerDepartments, + can_return: activeBorrowCount > 0, + can_handle_borrow_anomaly: anomalyMeta.canHandle, + borrow_anomaly_blocked_reason: anomalyMeta.blockedReason, + search_state: searchState, + state_message: searchState === 'returnable' ? '可直接归还' : anomalyMeta.message + } + }) + + successResponse(res, list, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '搜索可归还资产失败') + } +} + +export const resolveBorrowAnomalyAsReturned = async (req: Request, res: Response) => { + try { + const equipmentId = parseNumber(req.params.equipment_id) + if (!equipmentId) { + return errorResponse(res, '资产ID无效', 422) + } + + const { equipment, activeBorrowCount } = await loadBorrowAnomalyTarget(equipmentId) + + try { + ensureBorrowAnomalyHandleable(equipment, activeBorrowCount) + } catch (error: any) { + return errorResponse(res, error.message || '该资产当前不支持异常借出处理', 422) + } + + const updatedEquipment = await resolveBorrowAnomalyReturnedInTransaction(equipment) + const userId = res.locals.user?.id || null + const username = res.locals.user?.username || res.locals.user?.name || 'unknown' + await logOperation( + userId, + username, + 'borrow_anomaly_mark_returned', + '异常借出处理:确认已归还,系统补齐为可用状态', + equipment.id, + equipment.equipment_code, + req.ip + ) + + successResponse( + res, + { + equipment_id: updatedEquipment.id, + equipment_code: updatedEquipment.equipment_code, + equipment_name: updatedEquipment.equipment_name, + status: updatedEquipment.status, + remaining_quantity: updatedEquipment.remaining_quantity + }, + '已按异常借出处理为归还完成' + ) + } catch (error: any) { + errorResponse(res, error.message || '异常借出归还处理失败') + } +} + +export const registerBorrowAnomaly = async (req: Request, res: Response) => { + try { + const equipmentId = parseNumber(req.params.equipment_id) + if (!equipmentId) { + return errorResponse(res, '资产ID无效', 422) + } + + const { person_id, borrow_time, notes } = req.body as Record + let person + try { + person = await ensurePersonAvailable(parseNumber(person_id)) + } catch (error: any) { + return errorResponse(res, error.message || '使用者验证失败', 422) + } + + let borrowTime + try { + borrowTime = ensureBorrowAnomalyBorrowTime(borrow_time) + } catch (error: any) { + return errorResponse(res, error.message || '借出日期无效', 422) + } + + const { equipment, activeBorrowCount } = await loadBorrowAnomalyTarget(equipmentId) + + try { + ensureBorrowAnomalyHandleable(equipment, activeBorrowCount) + } catch (error: any) { + return errorResponse(res, error.message || '该资产当前不支持异常借出处理', 422) + } + + const { record, updatedEquipment, childrenCount } = await registerBorrowAnomalyInTransaction( + equipment, + person, + borrowTime, + typeof notes === 'string' ? notes : undefined + ) + + const userId = res.locals.user?.id || null + const username = res.locals.user?.username || res.locals.user?.name || 'unknown' + await logOperation( + userId, + username, + 'borrow_anomaly_register_borrow', + `异常借出处理:补登记借出给 ${person.nickname}(${person.real_name}),借出日期 ${borrow_time}`, + equipment.id, + equipment.equipment_code, + req.ip + ) + + successResponse( + res, + { + id: record.id, + equipment_id: updatedEquipment.id, + equipment_code: updatedEquipment.equipment_code, + equipment_name: updatedEquipment.equipment_name, + borrow_time: record.borrow_time, + status: record.status, + children_count: childrenCount + }, + '已补登记借出记录' + ) + } catch (error: any) { + errorResponse(res, error.message || '补登记借出失败') + } +} diff --git a/backend/src/controllers/equipmentController.ts b/backend/src/controllers/equipmentController.ts index a3920e5..64e98d8 100644 --- a/backend/src/controllers/equipmentController.ts +++ b/backend/src/controllers/equipmentController.ts @@ -9,6 +9,8 @@ import { sumBundleChildrenValue, syncMountedChildrenState } from '../utils/assetValue' +import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly' +import { getEquipmentDeleteBlockedReason } from '../utils/equipmentDeletion' import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' type EquipmentEntryMode = 'single' | 'parent' | 'child' @@ -163,7 +165,13 @@ const serializeEquipmentListItem = (equipment: any) => { return { ...restEquipment, children, - current_borrow: mapCurrentBorrow(borrow_records) + current_borrow: mapCurrentBorrow(borrow_records), + borrow_anomaly: getBorrowAnomalyMeta({ + status: restEquipment.status, + activeBorrowCount: Array.isArray(borrow_records) ? borrow_records.length : 0, + totalQuantity: restEquipment.total_quantity, + parentId: restEquipment.parent_id + }) } } @@ -894,7 +902,8 @@ export const deleteEquipment = async (req: Request, res: Response) => { children: true, mountHistories: true, disposalRecords: true, - maintenanceRecords: true + maintenanceRecords: true, + inventoryTaskItems: true } } } @@ -904,37 +913,21 @@ export const deleteEquipment = async (req: Request, res: Response) => { return errorResponse(res, '资产不存在', 404) } - // 检查是否有未归还的借用记录 - const activeBorrowCount = await prisma.borrowRecord.count({ - where: { - equipment_id: equipmentId, - status: '已借出' - } + const blockedReason = getEquipmentDeleteBlockedReason({ + borrowRecordCount: equipment._count.borrow_records, + isMountedChild: Boolean(equipment.parent_id), + childCount: equipment._count.children, + mountHistoryCount: equipment._count.mountHistories, + disposalRecordCount: equipment._count.disposalRecords, + maintenanceRecordCount: equipment._count.maintenanceRecords, + inventoryTaskItemCount: equipment._count.inventoryTaskItems }) - - if (activeBorrowCount > 0) { - return errorResponse(res, '该资产存在未归还的借用记录,无法删除') - } - if (equipment.parent_id) { - return errorResponse(res, '该资产当前作为子资产挂载中,无法删除') - } - - if (equipment._count.children > 0) { - return errorResponse(res, '该资产存在已挂载的子资产,无法删除') - } - - if (equipment._count.mountHistories > 0 || equipment._count.disposalRecords > 0 || equipment._count.maintenanceRecords > 0) { - return errorResponse(res, '该资产已存在维修、报废或挂载历史,无法删除') + if (blockedReason) { + return errorResponse(res, blockedReason) } await prisma.$transaction(async (tx) => { - await tx.borrowRecord.deleteMany({ - where: { - equipment_id: equipmentId - } - }) - await tx.equipment.delete({ where: { id: equipmentId } }) diff --git a/backend/src/controllers/inventoryController.ts b/backend/src/controllers/inventoryController.ts index f32d33f..c348e9d 100644 --- a/backend/src/controllers/inventoryController.ts +++ b/backend/src/controllers/inventoryController.ts @@ -4,11 +4,13 @@ import { assignUnassignedTaskItem, closeInventoryTask, createInventoryTask, + deleteInventoryTask, getInventoryTaskDetail, getTaskUnassignedItems, listInventoryTasks, previewInventoryTask } from '../services/inventoryService' +import { normalizeInventoryScopeInput } from '../utils/inventoryScope' const toOptionalNumber = (value: unknown) => { if (value === undefined || value === null || value === '') return null @@ -18,10 +20,15 @@ const toOptionalNumber = (value: unknown) => { export const previewInventoryTaskController = async (req: Request, res: Response) => { try { - const result = await previewInventoryTask({ - departmentId: toOptionalNumber(req.body?.department_id), - assetTypeId: toOptionalNumber(req.body?.asset_type_id) + const normalizedScope = normalizeInventoryScopeInput({ + department_id: toOptionalNumber(req.body?.department_id), + department_ids: req.body?.department_ids, + asset_type_id: toOptionalNumber(req.body?.asset_type_id), + asset_type_ids: req.body?.asset_type_ids, + storage_location_id: toOptionalNumber(req.body?.storage_location_id), + storage_location_ids: req.body?.storage_location_ids }) + const result = await previewInventoryTask(normalizedScope) successResponse(res, result, '预览成功') } catch (error: any) { errorResponse(res, error.message || '预览盘点任务失败', 422) @@ -35,12 +42,22 @@ export const createInventoryTaskController = async (req: Request, res: Response) return errorResponse(res, '未获取到当前登录用户', 401) } + const normalizedScope = normalizeInventoryScopeInput({ + department_id: toOptionalNumber(req.body?.department_id), + department_ids: req.body?.department_ids, + asset_type_id: toOptionalNumber(req.body?.asset_type_id), + asset_type_ids: req.body?.asset_type_ids, + storage_location_id: toOptionalNumber(req.body?.storage_location_id), + storage_location_ids: req.body?.storage_location_ids + }) + const result = await createInventoryTask( { title: req.body?.title, description: req.body?.description, - department_id: toOptionalNumber(req.body?.department_id), - asset_type_id: toOptionalNumber(req.body?.asset_type_id) + department_ids: normalizedScope.departmentIds, + asset_type_ids: normalizedScope.assetTypeIds, + storage_location_ids: normalizedScope.storageLocationIds }, currentUserId ) @@ -96,6 +113,24 @@ export const closeInventoryTaskController = async (req: Request, res: Response) } } +export const deleteInventoryTaskController = async (req: Request, res: Response) => { + try { + const taskId = Number(req.params.id) + if (!Number.isInteger(taskId) || taskId <= 0) { + return errorResponse(res, '盘点任务不存在', 404) + } + + await deleteInventoryTask(taskId) + successResponse(res, null, '任务已删除') + } catch (error: any) { + const message = error.message || '删除盘点任务失败' + if (message.includes('不存在')) { + return errorResponse(res, message, 404) + } + errorResponse(res, message, 422) + } +} + export const getInventoryTaskUnassignedController = async (req: Request, res: Response) => { try { const taskId = Number(req.params.id) diff --git a/backend/src/controllers/notificationController.ts b/backend/src/controllers/notificationController.ts index e74d4f1..81b2931 100644 --- a/backend/src/controllers/notificationController.ts +++ b/backend/src/controllers/notificationController.ts @@ -16,6 +16,23 @@ const getCurrentUserId = (res: Response) => { return Number.isNaN(userId) ? null : userId } +const getCurrentUserRole = (res: Response) => { + const role = res.locals.user?.role + return typeof role === 'string' ? role : null +} + +const ensureAdminNotificationInbox = async (userId: number, role: string | null) => { + if (role !== 'admin') { + return + } + + await prisma.$executeRaw` + INSERT IGNORE INTO user_notification (user_id, notification_id) + SELECT ${userId}, n.id + FROM notification n + ` +} + const sanitizeNotification = (item: any) => ({ id: item.id, is_read: item.is_read, @@ -38,6 +55,7 @@ export const getNotificationList = async (req: Request, res: Response) => { if (!userId) { return errorResponse(res, '未获取到当前用户信息', 401) } + await ensureAdminNotificationInbox(userId, getCurrentUserRole(res)) const { page = 1, pageSize = 10, status = 'all' } = req.query const where: any = { user_id: userId } @@ -89,6 +107,7 @@ export const getUnreadNotificationCount = async (_req: Request, res: Response) = if (!userId) { return errorResponse(res, '未获取到当前用户信息', 401) } + await ensureAdminNotificationInbox(userId, getCurrentUserRole(res)) const count = await prisma.userNotification.count({ where: { @@ -109,6 +128,7 @@ export const markNotificationAsRead = async (req: Request, res: Response) => { if (!userId) { return errorResponse(res, '未获取到当前用户信息', 401) } + await ensureAdminNotificationInbox(userId, getCurrentUserRole(res)) const id = Number(req.params.id) const existing = await prisma.userNotification.findFirst({ @@ -148,6 +168,7 @@ export const markAllNotificationsAsRead = async (_req: Request, res: Response) = if (!userId) { return errorResponse(res, '未获取到当前用户信息', 401) } + await ensureAdminNotificationInbox(userId, getCurrentUserRole(res)) const result = await prisma.userNotification.updateMany({ where: { diff --git a/backend/src/controllers/printController.ts b/backend/src/controllers/printController.ts index 82f99b0..cc9baf3 100644 --- a/backend/src/controllers/printController.ts +++ b/backend/src/controllers/printController.ts @@ -108,7 +108,7 @@ export const createAssetLabelPrintJob = async (req: Request, res: Response) => { } try { - const result = await createAssetLabelPrintJobService(req.body?.equipment_ids, userId) + const result = await createAssetLabelPrintJobService(req.body, userId) successResponse(res, result.job, result.queue_notice) } catch (error) { handlePrintServiceError(res, error, '创建打印任务失败') diff --git a/backend/src/index.ts b/backend/src/index.ts index 8a85668..418d393 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -43,7 +43,7 @@ const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secre // Middleware app.use(cors({ origin: true, credentials: true })) -app.use(express.json()) +app.use(express.json({ limit: '30mb' })) app.use(express.urlencoded({ extended: true })) app.use( session({ diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 85e0709..8c4a0c4 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -36,10 +36,12 @@ router.get('/user', (req, res) => { // Keycloak 配置信息(前端使用) router.get('/config', (req, res) => { + const authMode = process.env.AUTH_MODE === 'keycloak' ? 'keycloak' : 'local' successResponse(res, { - url: process.env.KEYCLOAK_URL || 'http://localhost:8080', - realm: process.env.KEYCLOAK_REALM || 'equipment-management', - clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client' + authMode, + url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '', + realm: authMode === 'keycloak' ? (process.env.KEYCLOAK_REALM || 'equipment-management') : '', + clientId: authMode === 'keycloak' ? (process.env.KEYCLOAK_CLIENT_ID || 'equipment-client') : '' }, '获取成功') }) @@ -114,4 +116,3 @@ router.post('/local/login', async (req: Request, res: Response) => { }) export default router - diff --git a/backend/src/routes/borrow.ts b/backend/src/routes/borrow.ts index 8309c7e..e6a7373 100644 --- a/backend/src/routes/borrow.ts +++ b/backend/src/routes/borrow.ts @@ -3,6 +3,8 @@ import { borrowEquipment, scanBorrow, scanBorrowBatch, + resolveBorrowAnomalyAsReturned, + registerBorrowAnomaly, getBorrowRecordByCode, getBorrowRecordList } from '../controllers/borrowController' @@ -13,6 +15,8 @@ const router = Router() router.post('/', borrowEquipment) router.post('/scan', scanBorrow) router.post('/scan-batch', scanBorrowBatch) +router.post('/anomalies/:equipment_id/mark-returned', resolveBorrowAnomalyAsReturned) +router.post('/anomalies/:equipment_id/register-borrow', registerBorrowAnomaly) router.get('/scan/:equipment_code', getBorrowRecordByCode) router.get('/records', getBorrowRecordList) @@ -24,9 +28,11 @@ export const returnRouter = Router() import { returnEquipment, scanReturn, - scanReturnBatch + scanReturnBatch, + searchReturnAssets } from '../controllers/borrowController' returnRouter.post('/', returnEquipment) returnRouter.post('/scan', scanReturn) returnRouter.post('/scan-batch', scanReturnBatch) +returnRouter.get('/search-assets', searchReturnAssets) diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts index 8e02ebf..0978e12 100644 --- a/backend/src/routes/inventory.ts +++ b/backend/src/routes/inventory.ts @@ -3,6 +3,7 @@ import { assignInventoryTaskUnassignedController, closeInventoryTaskController, createInventoryTaskController, + deleteInventoryTaskController, getInventoryTaskDetailController, getInventoryTaskListController, getInventoryTaskUnassignedController, @@ -19,6 +20,7 @@ router.post('/tasks', createInventoryTaskController) router.get('/tasks', getInventoryTaskListController) router.get('/tasks/:id', getInventoryTaskDetailController) 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) diff --git a/backend/src/services/__tests__/hrSyncService.test.ts b/backend/src/services/__tests__/hrSyncService.test.ts new file mode 100644 index 0000000..ca85fa8 --- /dev/null +++ b/backend/src/services/__tests__/hrSyncService.test.ts @@ -0,0 +1,91 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHrSyncPreviewLine, + formatBorrowedAssetSummary, + resolveHrSyncPersonDisplayName, + resolveSourcePersonStatus +} from '../hrSyncHelpers' + +test('resolvePersonStatus 在状态值 2 被配置为停用时应返回 inactive', () => { + const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES + process.env.HR_SYNC_INACTIVE_STATUS_VALUES = '2,5' + + try { + assert.equal(resolveSourcePersonStatus('2'), 'inactive') + assert.equal(resolveSourcePersonStatus('5'), 'inactive') + assert.equal(resolveSourcePersonStatus('3'), 'active') + } finally { + process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous + } +}) + +test('resolvePersonStatus 在状态文案包含离职语义时应返回 inactive', () => { + const previousKeywords = process.env.HR_SYNC_INACTIVE_STATUS_KEYWORDS + process.env.HR_SYNC_INACTIVE_STATUS_KEYWORDS = '离职,停用,inactive' + + try { + assert.equal(resolveSourcePersonStatus('离职'), 'inactive') + assert.equal(resolveSourcePersonStatus('已停用'), 'inactive') + assert.equal(resolveSourcePersonStatus('Inactive'), 'inactive') + assert.equal(resolveSourcePersonStatus('在职'), 'active') + } finally { + process.env.HR_SYNC_INACTIVE_STATUS_KEYWORDS = previousKeywords + } +}) + +test('formatBorrowedAssetSummary 会按“编码 / 名称”输出并在超出数量时补充总数', () => { + const summary = formatBorrowedAssetSummary([ + { equipmentCode: 'JS-PC-1001', equipmentName: '主机' }, + { equipmentCode: 'JS-MON-2001', equipmentName: '显示器' }, + { equipmentCode: 'JS-KB-3001', equipmentName: '键盘' }, + { equipmentCode: 'JS-MS-4001', equipmentName: '鼠标' } + ]) + + assert.equal(summary, 'JS-PC-1001 / 主机、JS-MON-2001 / 显示器、JS-KB-3001 / 键盘 等 4 项') +}) + +test('resolveHrSyncPersonDisplayName 会优先返回花名', () => { + assert.equal( + resolveHrSyncPersonDisplayName({ + nickname: '三藏', + realName: '张亮' + }), + '三藏' + ) + + assert.equal( + resolveHrSyncPersonDisplayName({ + nickname: '', + realName: '张亮' + }), + '张亮' + ) +}) + +test('buildHrSyncPreviewLine 会优先使用结构化资产生成预览行', () => { + const line = buildHrSyncPreviewLine({ + changeType: 'person_deactivated', + title: '人员离职/停用:三藏', + detail: '该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。', + afterSnapshot: { + assets: [ + { equipmentCode: 'JS-PC-1001', equipmentName: '主机' }, + { equipmentCode: 'JS-MON-2001', equipmentName: '显示器' } + ] + } + }) + + assert.equal(line, '人员离职/停用:三藏(名下资产:JS-PC-1001 / 主机、JS-MON-2001 / 显示器)') +}) + +test('buildHrSyncPreviewLine 在没有结构化资产时仍兼容旧的详情文本', () => { + const line = buildHrSyncPreviewLine({ + changeType: 'person_position_changed', + title: '岗位变动:悟空', + detail: '岗位已从“摄影师”调整为“导演”。 名下资产:JS-PC-2001 / 主机。' + }) + + assert.equal(line, '岗位变动:悟空(名下资产:JS-PC-2001 / 主机)') +}) diff --git a/backend/src/services/__tests__/printService.test.ts b/backend/src/services/__tests__/printService.test.ts index 4fa1964..4b5362f 100644 --- a/backend/src/services/__tests__/printService.test.ts +++ b/backend/src/services/__tests__/printService.test.ts @@ -1,10 +1,13 @@ import test from 'node:test' import assert from 'node:assert/strict' import { + getPrintTerminalAdminStatusChangeError, resolveEffectiveTerminalStatus, normalizeLabelProfile, pickNextClaimablePrintJob, - resolveTerminalReadiness + resolveTerminalReadiness, + sanitizeRenderedLabelImages, + stripRenderedLabelData } from '../printService' test('normalizeLabelProfile 应回填 60x40 与默认方向', () => { @@ -142,3 +145,86 @@ test('resolveEffectiveTerminalStatus 对手动设为 offline 的终端保持 off assert.equal(status, 'offline') }) + +test('getPrintTerminalAdminStatusChangeError 允许默认终端被直接停用', () => { + const error = getPrintTerminalAdminStatusChangeError( + { + is_default: true + }, + 'disabled' + ) + + assert.equal(error, null) +}) + +test('sanitizeRenderedLabelImages 会按 equipment_ids 顺序重排网页生成的 PNG 标签', () => { + const renderedLabels = sanitizeRenderedLabelImages( + [ + { + equipment_id: 2, + image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0y' + }, + { + equipment_id: 1, + image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x' + } + ], + [1, 2] + ) + + assert.deepEqual(renderedLabels, [ + { + equipment_id: 1, + image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x' + }, + { + equipment_id: 2, + image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0y' + } + ]) +}) + +test('sanitizeRenderedLabelImages 在缺少网页成品图时直接报错,避免错误打印旧版布局', () => { + assert.throws( + () => + sanitizeRenderedLabelImages( + [ + { + equipment_id: 1, + image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x' + } + ], + [1, 2] + ), + /成品标签图/ + ) +}) + +test('stripRenderedLabelData 不向管理页返回大图或内部存储引用', () => { + const payload = stripRenderedLabelData({ + job_type: 'asset_label', + generated_at: '2026-03-26T09:00:00.000Z', + equipment_ids: [1], + items: [], + rendered_labels: [ + { + equipment_id: 1, + image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x' + } + ], + rendered_label_files: [ + { + equipment_id: 1, + storage_key: '20260326/test/1.png', + content_type: 'image/png' + } + ] + }) + + assert.deepEqual(payload, { + job_type: 'asset_label', + generated_at: '2026-03-26T09:00:00.000Z', + equipment_ids: [1], + items: [] + }) +}) diff --git a/backend/src/services/hrSyncHelpers.ts b/backend/src/services/hrSyncHelpers.ts new file mode 100644 index 0000000..ef95e10 --- /dev/null +++ b/backend/src/services/hrSyncHelpers.ts @@ -0,0 +1,113 @@ +export type OpenBorrowAssetPreview = { + equipmentId?: number | null + borrowRecordId?: number | null + personId?: number | null + equipmentCode: string + equipmentName: string +} + +type HrSyncPersonDisplayNameSource = { + nickname?: string | null + realName?: string | null +} + +const safeTrim = (value: unknown) => { + if (value === null || value === undefined) { + return null + } + + const text = String(value).trim() + return text ? text : null +} + +const getConfiguredSet = (value: string, defaultValue: string) => + new Set( + ((value && value.trim() ? value : defaultValue) || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + ) + +const getInactiveStatusValues = () => + getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '2,5') + +const getInactiveStatusKeywords = () => + Array.from( + getConfiguredSet( + process.env.HR_SYNC_INACTIVE_STATUS_KEYWORDS || '', + '离职,停用,停职,离岗,辞职,注销,禁用,失效,inactive,disabled,left' + ) + ).map((keyword) => keyword.toLowerCase()) + +export const resolveSourcePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' => { + const inactiveStatusValues = getInactiveStatusValues() + const sourceStatus = + employeeStatus === null || employeeStatus === undefined ? null : String(employeeStatus).trim() + + if (sourceStatus && inactiveStatusValues.has(sourceStatus)) { + return 'inactive' + } + + if (sourceStatus) { + const normalizedStatus = sourceStatus.toLowerCase() + const inactiveStatusKeywords = getInactiveStatusKeywords() + if (inactiveStatusKeywords.some((keyword) => normalizedStatus.includes(keyword))) { + return 'inactive' + } + } + + return 'active' +} + +export const formatBorrowedAssetSummary = (assets: OpenBorrowAssetPreview[], limit = 3) => { + if (assets.length === 0) { + return '暂无待处理资产' + } + + const summary = assets + .slice(0, limit) + .map((asset) => `${asset.equipmentCode} / ${asset.equipmentName}`) + .join('、') + + return assets.length > limit ? `${summary} 等 ${assets.length} 项` : summary +} + +export const resolveHrSyncPersonDisplayName = (person: HrSyncPersonDisplayNameSource) => + safeTrim(person.nickname) || safeTrim(person.realName) || '未命名' + +const extractBorrowedAssetSummary = (detail: string) => { + const matched = detail.match(/名下(?:待处理)?资产:([^。]+)/) + return matched?.[1]?.trim() || null +} + +const extractStructuredBorrowedAssets = (afterSnapshot?: Record | null) => { + if (!afterSnapshot || !Array.isArray(afterSnapshot.assets)) { + return [] + } + + return afterSnapshot.assets + .map((asset: Record) => ({ + equipmentCode: safeTrim(asset?.equipmentCode) || '', + equipmentName: safeTrim(asset?.equipmentName) || '' + })) + .filter((asset) => asset.equipmentCode && asset.equipmentName) +} + +export const buildHrSyncPreviewLine = (change: { + changeType: string + title: string + detail: string + afterSnapshot?: Record | null +}) => { + if (change.changeType !== 'person_deactivated' && change.changeType !== 'person_position_changed') { + return change.title + } + + const structuredAssets = extractStructuredBorrowedAssets(change.afterSnapshot) + const assetSummary = + structuredAssets.length > 0 + ? formatBorrowedAssetSummary(structuredAssets) + : extractBorrowedAssetSummary(change.detail) + + return assetSummary ? `${change.title}(名下资产:${assetSummary})` : change.title +} diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index ff8deb2..577def7 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -7,6 +7,12 @@ import { buildBusinessDepartmentWhere, buildSyncedBusinessDepartmentWhere } from '../utils/organization' +import { + buildHrSyncPreviewLine, + resolveHrSyncPersonDisplayName, + resolveSourcePersonStatus, + type OpenBorrowAssetPreview +} from './hrSyncHelpers' type SourceDepartment = { sourceGroupId: number | null @@ -147,6 +153,7 @@ const REQUIRED_SQLSERVER_FIELDS = [ 'SQLSERVER_USER', 'SQLSERVER_PASSWORD' ] as const +const DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE = 'Asia/Shanghai' const departmentCollator = new Intl.Collator('zh-CN', { numeric: true, @@ -181,9 +188,6 @@ const getConfiguredSet = (value: string, defaultValue: string) => const getFormalStatusValues = () => getConfiguredSet(process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', '3') -const getInactiveStatusValues = () => - getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '5') - const getExcludedSourceDepartmentNames = () => getConfiguredSet( process.env.HR_SYNC_EXCLUDED_DEPARTMENT_NAMES || process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '', @@ -192,6 +196,61 @@ const getExcludedSourceDepartmentNames = () => const getHrSyncSourceMode = (): HrSyncSourceMode => 'register_primary' +const isValidTimeZone = (timeZone: string) => { + try { + new Intl.DateTimeFormat('zh-CN', { timeZone }).format(new Date()) + return true + } catch { + return false + } +} + +const getConfiguredScheduleTimezone = () => { + const configuredTimezone = (process.env.HR_SYNC_TIMEZONE || '').trim() + if (!configuredTimezone) { + return DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE + } + + if (isValidTimeZone(configuredTimezone)) { + return configuredTimezone + } + + console.warn( + `[hr-sync] invalid HR_SYNC_TIMEZONE=${configuredTimezone}, fallback=${DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE}` + ) + return DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE +} + +const getCurrentClockInTimeZone = (date: Date, timeZone: string) => { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23' + }) + const partMap = new Map() + + for (const part of formatter.formatToParts(date)) { + if (part.type !== 'literal') { + partMap.set(part.type, part.value) + } + } + + const year = partMap.get('year') || '0000' + const month = partMap.get('month') || '00' + const day = partMap.get('day') || '00' + const hour = partMap.get('hour') || '00' + const minute = partMap.get('minute') || '00' + + return { + dateKey: `${year}-${month}-${day}`, + timeKey: `${hour}:${minute}` + } +} + export const getHrSyncConfigStatus = () => { const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => { const value = process.env[field] @@ -202,7 +261,8 @@ export const getHrSyncConfigStatus = () => { enabled: process.env.HR_SYNC_ENABLED === 'true', complete: missingFields.length === 0, missingFields, - scheduleTimes: getConfiguredScheduleTimes() + scheduleTimes: getConfiguredScheduleTimes(), + scheduleTimezone: getConfiguredScheduleTimezone() } } @@ -258,8 +318,8 @@ const formatDepartmentName = (value: string | null | undefined) => value || '未 const inferTriggerMode = (operator: string): SyncTriggerMode => operator.includes('scheduler') ? 'scheduler' : 'manual' -const getConfiguredScheduleTimes = () => - Array.from( +const getConfiguredScheduleTimes = () => { + const scheduleTimes = Array.from( new Set( (process.env.HR_SYNC_SCHEDULES || process.env.HR_SYNC_SCHEDULE || '03:00') .split(',') @@ -268,6 +328,9 @@ const getConfiguredScheduleTimes = () => ) ) + return scheduleTimes.length > 0 ? scheduleTimes : ['03:00'] +} + const normalizePhone = (value: unknown) => { const text = safeTrim(value) if (!text) return null @@ -293,16 +356,8 @@ const parseExtensionFlowerName = (extension: unknown) => { } } -const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' => { - const inactiveStatusValues = getInactiveStatusValues() - const sourceStatus = safeTrim(employeeStatus) - - if (sourceStatus && inactiveStatusValues.has(sourceStatus)) { - return 'inactive' - } - - return 'active' -} +const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' => + resolveSourcePersonStatus(safeTrim(employeeStatus)) const resolveDepartmentBinding = ( sourceDepartmentName: string | null, @@ -720,6 +775,45 @@ const getPersonSnapshotMap = async () => { ) } +const getOpenBorrowAssetMap = async () => { + const borrowRecords = await prisma.borrowRecord.findMany({ + where: { + return_time: null + }, + orderBy: [ + { person_id: 'asc' }, + { equipment_code: 'asc' } + ], + select: { + id: true, + equipment_id: true, + person_id: true, + equipment_code: true, + equipment: { + select: { + equipment_name: true + } + } + } + }) + + const assetMap = new Map() + + for (const record of borrowRecords) { + const current = assetMap.get(record.person_id) || [] + current.push({ + equipmentId: record.equipment_id, + borrowRecordId: record.id, + personId: record.person_id, + equipmentCode: record.equipment_code, + equipmentName: record.equipment?.equipment_name || '未命名资产' + }) + assetMap.set(record.person_id, current) + } + + return assetMap +} + const buildSyncChange = (change: HrSyncChangeDraft) => change const diffDepartmentSnapshots = ( @@ -779,7 +873,8 @@ const diffDepartmentSnapshots = ( const diffPersonSnapshots = ( beforeMap: Map, - afterMap: Map + afterMap: Map, + openBorrowAssetMap: Map ) => { const changes: HrSyncChangeDraft[] = [] const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) => @@ -789,6 +884,8 @@ const diffPersonSnapshots = ( for (const key of keys) { const before = beforeMap.get(key) const after = afterMap.get(key) + const currentPerson = after || before + const personDisplayName = resolveHrSyncPersonDisplayName(currentPerson || {}) if (!before && after && after.status === 'active') { changes.push( @@ -797,10 +894,13 @@ const diffPersonSnapshots = ( entityKey: key, entityName: after.realName, changeType: 'person_created', - title: `新人入职:${after.realName}`, + title: `新人入职:${personDisplayName}`, detail: `系统归属部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`, beforeSnapshot: null, - afterSnapshot: after + afterSnapshot: { + ...after, + displayName: personDisplayName + } }) ) continue @@ -810,6 +910,8 @@ const diffPersonSnapshots = ( continue } + const openBorrowAssets = openBorrowAssetMap.get(after.id || before.id) || [] + if (before.status !== after.status) { changes.push( buildSyncChange({ @@ -817,13 +919,21 @@ const diffPersonSnapshots = ( entityKey: key, entityName: after.realName, changeType: after.status === 'inactive' ? 'person_deactivated' : 'person_reactivated', - title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${after.realName}`, + title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${personDisplayName}`, detail: after.status === 'inactive' - ? '该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。' + ? `该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。${ + openBorrowAssets.length > 0 + ? ` 当前名下还有 ${openBorrowAssets.length} 件待处理资产,可在下方直接快捷入库。` + : ' 当前名下没有待处理资产。' + }` : '该人员重新进入同步名单,可继续参与借还与盘点流程。', beforeSnapshot: before, - afterSnapshot: after + afterSnapshot: { + ...after, + displayName: personDisplayName, + assets: openBorrowAssets + } }) ) } @@ -839,10 +949,13 @@ const diffPersonSnapshots = ( entityKey: key, entityName: after.realName, changeType: 'person_main_department_changed', - title: `主部门变动:${after.realName}`, + title: `主部门变动:${personDisplayName}`, detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName(after.mainDepartmentName)}”。`, beforeSnapshot: before, - afterSnapshot: after + afterSnapshot: { + ...after, + displayName: personDisplayName + } }) ) } @@ -854,10 +967,13 @@ const diffPersonSnapshots = ( entityKey: key, entityName: after.realName, changeType: 'person_departments_changed', - title: `关联部门变动:${after.realName}`, + title: `关联部门变动:${personDisplayName}`, detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList(after.departmentNames)}”。`, beforeSnapshot: before, - afterSnapshot: after + afterSnapshot: { + ...after, + displayName: personDisplayName + } }) ) } @@ -869,10 +985,18 @@ const diffPersonSnapshots = ( entityKey: key, entityName: after.realName, changeType: 'person_position_changed', - title: `岗位变动:${after.realName}`, - detail: `岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。`, + title: `岗位变动:${personDisplayName}`, + detail: + `岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。` + + (openBorrowAssets.length > 0 + ? ` 当前名下还有 ${openBorrowAssets.length} 件资产,可在下方直接快捷入库。` + : ''), beforeSnapshot: before, - afterSnapshot: after + afterSnapshot: { + ...after, + displayName: personDisplayName, + assets: openBorrowAssets + } }) ) } @@ -910,7 +1034,13 @@ const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary => change_type: change.changeType })) - const previewText = previewChanges.map((change, index) => `${index + 1}. ${change.title}`).join('\n') + const previewText = previewChanges + .map((change, index) => `${index + 1}. ${buildHrSyncPreviewLine({ + changeType: change.change_type, + title: change.title, + detail: change.detail + })}`) + .join('\n') return { title: `组织同步发现 ${changes.length} 项变化`, @@ -981,44 +1111,41 @@ const persistHrSyncArtifacts = async ({ }) } - let notificationId: number | null = null - if (status === 'failed' || changes.length > 0) { - const notification = await tx.notification.create({ - data: { - title: summary.title, - content: summary.content, - type: 'hr_sync', - level: status === 'failed' ? 'error' : 'warning', - action_url: '/settings?tab=sync', - related_sync_job_id: syncJob.id, - metadata: toJsonText({ - status, - changes_count: changes.length, - counts: summary.counts, - preview_changes: summary.previewChanges - }) - } - }) - - notificationId = notification.id - - const adminUsers = await tx.user.findMany({ - where: { - role: 'admin', - status: 'active' - }, - select: { id: true } - }) - - if (adminUsers.length > 0) { - await tx.userNotification.createMany({ - data: adminUsers.map((user) => ({ - user_id: user.id, - notification_id: notification.id - })), - skipDuplicates: true + const notification = await tx.notification.create({ + data: { + title: summary.title, + content: summary.content, + type: 'hr_sync', + level: status === 'failed' ? 'error' : changes.length > 0 ? 'warning' : 'info', + action_url: '/notifications', + related_sync_job_id: syncJob.id, + metadata: toJsonText({ + status, + changes_count: changes.length, + counts: summary.counts, + preview_changes: summary.previewChanges }) } + }) + + const notificationId: number | null = notification.id + + const adminUsers = await tx.user.findMany({ + where: { + role: 'admin', + status: 'active' + }, + select: { id: true } + }) + + if (adminUsers.length > 0) { + await tx.userNotification.createMany({ + data: adminUsers.map((user) => ({ + user_id: user.id, + notification_id: notification.id + })), + skipDuplicates: true + }) } return { @@ -1459,6 +1586,9 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo const deactivatedResult = await prisma.person.updateMany({ where: { source_system: HR_SOURCE_SYSTEM, + status: { + not: 'inactive' + }, source_userid: { notIn: sourceUserIds } @@ -1651,14 +1781,15 @@ export const runHrSync = async ( } } - const [afterDepartmentMap, afterPersonMap] = await Promise.all([ + const [afterDepartmentMap, afterPersonMap, openBorrowAssetMap] = await Promise.all([ getDepartmentSnapshotMap(), - getPersonSnapshotMap() + getPersonSnapshotMap(), + getOpenBorrowAssetMap() ]) const changes: HrSyncChangeDraft[] = [ ...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap), - ...diffPersonSnapshots(beforePersonMap, afterPersonMap) + ...diffPersonSnapshots(beforePersonMap, afterPersonMap, openBorrowAssetMap) ] if (equipmentDepartmentMigration.unresolved > 0) { @@ -1753,12 +1884,12 @@ export const startHrSyncScheduler = () => { } const scheduleTimes = getConfiguredScheduleTimes() + const scheduleTimezone = getConfiguredScheduleTimezone() setInterval(async () => { const now = new Date() - const hh = String(now.getHours()).padStart(2, '0') - const mm = String(now.getMinutes()).padStart(2, '0') - const currentTime = `${hh}:${mm}` - const runKey = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}@${currentTime}` + const currentClock = getCurrentClockInTimeZone(now, scheduleTimezone) + const currentTime = currentClock.timeKey + const runKey = `${currentClock.dateKey}@${currentTime}` if (!scheduleTimes.includes(currentTime) || schedulerRunning || lastSchedulerRunKeys.has(runKey)) { return @@ -1768,7 +1899,9 @@ export const startHrSyncScheduler = () => { try { await runHrSync('system-scheduler', 'scheduler') lastSchedulerRunKeys.add(runKey) - console.log(`[hr-sync] completed at ${now.toISOString()}`) + console.log( + `[hr-sync] completed at ${now.toISOString()} (schedule_timezone=${scheduleTimezone} ${currentTime})` + ) } catch (error) { console.error('[hr-sync] failed:', error) } finally { @@ -1776,5 +1909,7 @@ export const startHrSyncScheduler = () => { } }, 60 * 1000) - console.log(`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}`) + console.log( + `[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}, timezone=${scheduleTimezone}` + ) } diff --git a/backend/src/services/inventoryService.ts b/backend/src/services/inventoryService.ts index 88342fe..b8b9b18 100644 --- a/backend/src/services/inventoryService.ts +++ b/backend/src/services/inventoryService.ts @@ -1,13 +1,22 @@ import { randomBytes } from 'crypto' import type { PrismaClient } from '@prisma/client' import prisma from '../utils/prisma' -import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' +import { buildBusinessDepartmentWhere } from '../utils/organization' +import { + normalizeInventoryScopeInput, + resolveUniqueActiveBorrowRecord, + type NormalizeInventoryScopeInputParams, + type NormalizedInventoryScopeInput +} from '../utils/inventoryScope' +import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle' -type InventoryScopeOptions = { - departmentId?: number | null - assetTypeId?: number | null +type ScopeEntity = { + id: number + name: string } +type InventoryScopeOptions = NormalizeInventoryScopeInputParams | NormalizedInventoryScopeInput + type FrozenParticipantDraft = { person_id: number person_name: string @@ -37,8 +46,9 @@ type FrozenItemDraft = { } type InventoryScopeBuildResult = { - scopeDepartment: { id: number; name: string } | null - scopeAssetType: { id: number; name: string } | null + scopeDepartments: ScopeEntity[] + scopeAssetTypes: ScopeEntity[] + scopeStorageLocations: ScopeEntity[] participants: FrozenParticipantDraft[] items: FrozenItemDraft[] } @@ -62,54 +72,101 @@ const INVENTORY_PARTICIPANT_STATUS = { const EXCLUDED_EQUIPMENT_STATUSES = ['已报废'] -const buildScopeWhere = ({ departmentId, assetTypeId }: InventoryScopeOptions) => { +const buildScopeWhere = ({ + departmentIds, + assetTypeIds, + storageLocationIds +}: NormalizedInventoryScopeInput) => { const where: Record = { status: { notIn: EXCLUDED_EQUIPMENT_STATUSES } } - if (departmentId) { - where.department_id = departmentId + if (departmentIds.length > 0) { + where.department_id = { + in: departmentIds + } } - if (assetTypeId) { - where.asset_type_id = assetTypeId + if (assetTypeIds.length > 0) { + where.asset_type_id = { + in: assetTypeIds + } + } + + if (storageLocationIds.length > 0) { + where.storage_location_id = { + in: storageLocationIds + } } return where } -const getScopeMeta = async ({ departmentId, assetTypeId }: InventoryScopeOptions) => { - const [department, assetType] = await Promise.all([ - departmentId - ? prisma.department.findFirst({ +const getScopeMeta = async ({ + departmentIds, + assetTypeIds, + storageLocationIds +}: NormalizedInventoryScopeInput) => { + const [departments, assetTypes, storageLocations] = await Promise.all([ + departmentIds.length > 0 + ? prisma.department.findMany({ where: { - id: departmentId, - ...buildSyncedBusinessDepartmentWhere() + id: { + in: departmentIds + }, + AND: [ + buildBusinessDepartmentWhere(), + { status: 'active' } + ] }, - select: { id: true, name: true, status: true } + orderBy: { name: 'asc' }, + select: { id: true, name: true } }) - : Promise.resolve(null), - assetTypeId - ? prisma.assetType.findUnique({ - where: { id: assetTypeId }, - select: { id: true, name: true, status: true } + : Promise.resolve([]), + assetTypeIds.length > 0 + ? prisma.assetType.findMany({ + where: { + id: { + in: assetTypeIds + }, + status: 'active' + }, + orderBy: { name: 'asc' }, + select: { id: true, name: true } }) - : Promise.resolve(null) + : Promise.resolve([]), + storageLocationIds.length > 0 + ? prisma.storageLocation.findMany({ + where: { + id: { + in: storageLocationIds + }, + status: 'active' + }, + orderBy: { name: 'asc' }, + select: { id: true, name: true } + }) + : Promise.resolve([]) ]) - if (departmentId && (!department || department.status !== 'active')) { + if (departmentIds.length > 0 && departments.length !== departmentIds.length) { throw new Error('所选业务部门不存在或已停用') } - if (assetTypeId && (!assetType || assetType.status !== 'active')) { + if (assetTypeIds.length > 0 && assetTypes.length !== assetTypeIds.length) { throw new Error('所选资产类型不存在或已停用') } + if (storageLocationIds.length > 0 && storageLocations.length !== storageLocationIds.length) { + throw new Error('所选地区不存在或已停用') + } + return { - scopeDepartment: department ? { id: department.id, name: department.name } : null, - scopeAssetType: assetType ? { id: assetType.id, name: assetType.name } : null + scopeDepartments: departments, + scopeAssetTypes: assetTypes, + scopeStorageLocations: storageLocations } } @@ -243,10 +300,16 @@ const buildAssetTypeBreakdown = (items: FrozenItemDraft[]) => export const buildInventoryScope = async ( options: InventoryScopeOptions ): Promise => { - const meta = await getScopeMeta(options) + const normalizedScope = normalizeInventoryScopeInput(options) + const meta = await getScopeMeta(normalizedScope) const equipments = await prisma.equipment.findMany({ - where: buildScopeWhere(options), - orderBy: [{ department_id: 'asc' }, { asset_type_id: 'asc' }, { created_at: 'desc' }], + where: buildScopeWhere(normalizedScope), + orderBy: [ + { storage_location_id: 'asc' }, + { department_id: 'asc' }, + { asset_type_id: 'asc' }, + { created_at: 'desc' } + ], select: { id: true, equipment_code: true, @@ -267,6 +330,12 @@ export const buildInventoryScope = async ( id: true, name: true } + }, + storageLocation: { + select: { + id: true, + name: true + } } } }) @@ -281,12 +350,13 @@ export const buildInventoryScope = async ( for (const equipment of equipments) { const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap) const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || [] - const activeBorrowRecord = - activeBorrowRecords.length === 1 && activeBorrowRecords[0].person?.status === 'active' - ? activeBorrowRecords[0] - : null + const activeBorrowRecord = resolveUniqueActiveBorrowRecord(activeBorrowRecords) - const participantPersonId = activeBorrowRecord?.person?.id || null + if (!activeBorrowRecord?.person) { + continue + } + + const participantPersonId = activeBorrowRecord.person.id const snapshotDepartmentId = activeBorrowRecord?.person?.mainDepartment?.id || equipment.department?.id || @@ -297,19 +367,17 @@ export const buildInventoryScope = async ( equipment.department?.name || null - if (participantPersonId && activeBorrowRecord?.person) { - 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 - }) - } + 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, @@ -327,13 +395,14 @@ export const buildInventoryScope = async ( snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null, snapshot_status: equipment.status, source_borrow_record_id: activeBorrowRecord?.id || null, - is_unassigned: !activeBorrowRecord + is_unassigned: false }) } return { - scopeDepartment: meta.scopeDepartment, - scopeAssetType: meta.scopeAssetType, + scopeDepartments: meta.scopeDepartments, + scopeAssetTypes: meta.scopeAssetTypes, + scopeStorageLocations: meta.scopeStorageLocations, participants: Array.from(participantMap.values()).sort((left, right) => left.person_nickname.localeCompare(right.person_nickname, 'zh-CN') ), @@ -343,13 +412,17 @@ export const buildInventoryScope = async ( export const previewInventoryTask = async (options: InventoryScopeOptions) => { const scope = await buildInventoryScope(options) + const unassignedCount = scope.items.filter((item) => item.is_unassigned).length return { - scope_department: scope.scopeDepartment, - scope_asset_type: scope.scopeAssetType, + scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null, + scope_asset_type: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0] : null, + scope_departments: scope.scopeDepartments, + scope_asset_types: scope.scopeAssetTypes, + scope_storage_locations: scope.scopeStorageLocations, participants_count: scope.participants.length, items_count: scope.items.length, - unassigned_count: scope.items.filter((item) => item.is_unassigned).length, + unassigned_count: unassignedCount, department_breakdown: buildDepartmentBreakdown(scope.items), asset_type_breakdown: buildAssetTypeBreakdown(scope.items), participants_sample: scope.participants.slice(0, 10), @@ -358,8 +431,11 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => { } const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({ - scope_department: scope.scopeDepartment, - scope_asset_type: scope.scopeAssetType, + scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null, + scope_asset_type: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0] : null, + scope_departments: scope.scopeDepartments, + scope_asset_types: scope.scopeAssetTypes, + scope_storage_locations: scope.scopeStorageLocations, participants_count: scope.participants.length, items_count: scope.items.length, unassigned_count: scope.items.filter((item) => item.is_unassigned).length, @@ -447,34 +523,78 @@ const refreshParticipantStatus = async (tx: PrismaClient, participantId: number) }) } -const serializeTaskListItem = (task: any) => ({ - id: task.id, - title: task.title, - description: task.description, - status: task.status, - public_token: task.public_token, - scope_department_id: task.scope_department_id, - scope_department_name: task.scope_department_name, - scope_asset_type_id: task.scope_asset_type_id, - scope_asset_type_name: task.scope_asset_type_name, - participants_count: task.participants_count, - items_count: task.items_count, - completed_count: task.completed_count, - exception_count: task.exception_count, - unassigned_count: task.unassigned_count, - created_by: task.created_by, - created_by_name: task.creator?.name || task.creator?.username || null, - closed_at: task.closed_at, - created_at: task.created_at, - updated_at: task.updated_at -}) +const parseScopeSnapshot = (task: any) => { + let snapshot = null + + if (task.scope_snapshot_json) { + try { + snapshot = JSON.parse(task.scope_snapshot_json) + } catch { + snapshot = null + } + } + + const scopeDepartments = Array.isArray(snapshot?.scope_departments) + ? snapshot.scope_departments + : task.scope_department_id && task.scope_department_name + ? [{ id: task.scope_department_id, name: task.scope_department_name }] + : [] + + const scopeAssetTypes = Array.isArray(snapshot?.scope_asset_types) + ? snapshot.scope_asset_types + : task.scope_asset_type_id && task.scope_asset_type_name + ? [{ id: task.scope_asset_type_id, name: task.scope_asset_type_name }] + : [] + + const scopeStorageLocations = Array.isArray(snapshot?.scope_storage_locations) + ? snapshot.scope_storage_locations + : [] + + return { + scope_snapshot: snapshot, + scope_departments: scopeDepartments, + scope_asset_types: scopeAssetTypes, + scope_storage_locations: scopeStorageLocations + } +} + +const serializeTaskListItem = (task: any) => { + const serializedScope = parseScopeSnapshot(task) + + return { + id: task.id, + title: task.title, + description: task.description, + status: task.status, + public_token: task.public_token, + scope_department_id: task.scope_department_id, + scope_department_name: task.scope_department_name, + scope_asset_type_id: task.scope_asset_type_id, + scope_asset_type_name: task.scope_asset_type_name, + participants_count: task.participants_count, + items_count: task.items_count, + completed_count: task.completed_count, + exception_count: task.exception_count, + unassigned_count: task.unassigned_count, + created_by: task.created_by, + created_by_name: task.creator?.name || task.creator?.username || null, + closed_at: task.closed_at, + created_at: task.created_at, + updated_at: task.updated_at, + ...serializedScope + } +} export const createInventoryTask = async ( input: { title: string description?: string | null - department_id?: number | null - asset_type_id?: number | null + department_id?: number | null + asset_type_id?: number | null + storage_location_id?: number | null + department_ids?: number[] | null + asset_type_ids?: number[] | null + storage_location_ids?: number[] | null }, operatorUserId: number ) => { @@ -484,8 +604,12 @@ export const createInventoryTask = async ( } const scope = await buildInventoryScope({ - departmentId: input.department_id || null, - assetTypeId: input.asset_type_id || null + department_id: input.department_id || null, + asset_type_id: input.asset_type_id || null, + storage_location_id: input.storage_location_id || null, + department_ids: input.department_ids || [], + asset_type_ids: input.asset_type_ids || [], + storage_location_ids: input.storage_location_ids || [] }) const task = await prisma.$transaction(async (tx) => { @@ -495,10 +619,10 @@ export const createInventoryTask = async ( description: input.description?.trim() || null, status: INVENTORY_TASK_STATUSES.active, public_token: createPublicToken(), - scope_department_id: scope.scopeDepartment?.id || null, - scope_department_name: scope.scopeDepartment?.name || null, - scope_asset_type_id: scope.scopeAssetType?.id || null, - scope_asset_type_name: scope.scopeAssetType?.name || null, + scope_department_id: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].id : null, + scope_department_name: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].name : null, + scope_asset_type_id: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].id : null, + scope_asset_type_name: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].name : null, scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)), created_by: operatorUserId } @@ -642,6 +766,8 @@ export const getInventoryTaskDetail = async (taskId: number) => { throw new Error('盘点任务不存在') } + const serializedScope = parseScopeSnapshot(task) + const departmentBreakdown = Array.from( task.items.reduce((map, item) => { const key = item.snapshot_department_name || '未归属部门' @@ -684,7 +810,7 @@ export const getInventoryTaskDetail = async (taskId: number) => { return { ...serializeTaskListItem(task), - scope_snapshot: task.scope_snapshot_json ? JSON.parse(task.scope_snapshot_json) : null, + ...serializedScope, participants: task.participants, items: task.items, recent_actions: task.actions, @@ -729,6 +855,29 @@ export const closeInventoryTask = async (taskId: number, operatorUserId: number) }) } +export const deleteInventoryTask = async (taskId: number) => { + const task = await prisma.inventoryTask.findUnique({ + where: { id: taskId }, + select: { + id: true, + status: true + } + }) + + if (!task) { + throw new Error('盘点任务不存在') + } + + const blockedReason = getInventoryTaskDeleteBlockedReason(task.status) + if (blockedReason) { + throw new Error(blockedReason) + } + + await prisma.inventoryTask.delete({ + where: { id: taskId } + }) +} + export const getInventoryTaskPublicInfo = async (publicToken: string) => { const task = await prisma.inventoryTask.findUnique({ where: { @@ -746,6 +895,7 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => { completed_count: true, exception_count: true, unassigned_count: true, + scope_snapshot_json: true, created_at: true, closed_at: true } @@ -755,7 +905,10 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => { throw new Error('盘点任务不存在') } - return task + return { + ...task, + ...parseScopeSnapshot(task) + } } export const searchTaskParticipants = async (publicToken: string, keyword: string) => { @@ -875,7 +1028,13 @@ export const getParticipantTaskContext = async (taskId: number, participantId: n throw new Error('盘点任务会话已失效') } - return { task, participant } + return { + task: { + ...task, + ...parseScopeSnapshot(task) + }, + participant + } } export const getParticipantItems = async (taskId: number, participantId: number) => { diff --git a/backend/src/services/printService.ts b/backend/src/services/printService.ts index 631c5ff..33c9c15 100644 --- a/backend/src/services/printService.ts +++ b/backend/src/services/printService.ts @@ -1,4 +1,6 @@ -import { createHash, randomBytes } from 'crypto' +import { promises as fs } from 'fs' +import path from 'path' +import { createHash, randomBytes, randomUUID } from 'crypto' import type { Prisma, PrintJob, PrintTerminal } from '@prisma/client' import prisma from '../utils/prisma' @@ -60,11 +62,24 @@ export interface AssetLabelPayloadItem { } | null } +export interface AssetLabelRenderedImagePayload { + equipment_id: number + image_data_url: string +} + +export interface AssetLabelStoredRenderFilePayload { + equipment_id: number + storage_key: string + content_type: 'image/png' +} + export interface AssetLabelJobPayload { job_type: 'asset_label' generated_at: string equipment_ids: number[] items: AssetLabelPayloadItem[] + rendered_labels?: AssetLabelRenderedImagePayload[] + rendered_label_files?: AssetLabelStoredRenderFilePayload[] } export interface PairPrintTerminalInput { @@ -129,6 +144,9 @@ const PRINT_PAIR_CODE_TTL_MINUTES = 10 const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50 const MAX_RETRY_ATTEMPTS = 3 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') type ParsedTerminalData = { labelProfile: PrintLabelProfileInput | null @@ -351,6 +369,213 @@ const sanitizeEquipmentIds = (value: unknown) => { return uniqueIds } +const decodePngDataUrl = (value: unknown) => { + if (typeof value !== 'string') { + throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422) + } + + const normalized = value.trim() + if (!PNG_DATA_URL_PATTERN.test(normalized) || !normalized.startsWith(PNG_DATA_URL_PREFIX)) { + throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422) + } + + const base64 = normalized.slice(PNG_DATA_URL_PREFIX.length) + + try { + const buffer = Buffer.from(base64, 'base64') + if (buffer.length === 0) { + throw new Error('empty image') + } + return buffer + } catch { + throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422) + } +} + +export const sanitizeRenderedLabelImages = ( + value: unknown, + equipmentIds: number[] +): AssetLabelRenderedImagePayload[] => { + if (!Array.isArray(value)) { + throw new PrintServiceError('当前页面缺少网页成品标签图,请刷新后重新发送打印', 422) + } + + const imageMap = new Map() + + for (const item of value) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422) + } + + const equipmentId = Number((item as { equipment_id?: unknown }).equipment_id) + if (!Number.isInteger(equipmentId) || equipmentId <= 0) { + throw new PrintServiceError('网页成品标签图缺少有效的资产编号,请刷新页面后重试', 422) + } + + if (imageMap.has(equipmentId)) { + throw new PrintServiceError(`资产 ${equipmentId} 的成品标签图重复提交,请重新发送`, 422) + } + + const imageDataUrl = String((item as { image_data_url?: unknown }).image_data_url || '').trim() + decodePngDataUrl(imageDataUrl) + + imageMap.set(equipmentId, { + equipment_id: equipmentId, + image_data_url: imageDataUrl + }) + } + + if (imageMap.size !== equipmentIds.length) { + throw new PrintServiceError('网页成品标签图数量不完整,请刷新页面后重新发送打印', 422) + } + + return equipmentIds.map((equipmentId) => { + const matched = imageMap.get(equipmentId) + if (!matched) { + throw new PrintServiceError( + `资产 ${equipmentId} 缺少网页成品标签图,请刷新页面后重新发送打印`, + 422 + ) + } + + return matched + }) +} + +export const stripRenderedLabelData = (payload: AssetLabelJobPayload | null) => { + if (!payload) { + return null + } + + const { rendered_labels: _renderedLabels, rendered_label_files: _storedFiles, ...rest } = payload + return rest +} + +const sanitizeStorageKey = (storageKey: string) => { + const normalized = storageKey.replace(/\\/g, '/').trim().replace(/^\/+/, '') + if (!normalized || normalized.includes('..')) { + throw new PrintServiceError('打印任务中的标签附件路径无效', 500) + } + return normalized +} + +const resolveStoredRenderAbsolutePath = (storageKey: string) => { + const normalizedKey = sanitizeStorageKey(storageKey) + const absolutePath = path.resolve(PRINT_JOB_RENDER_STORAGE_ROOT, normalizedKey) + const relativePath = path.relative(PRINT_JOB_RENDER_STORAGE_ROOT, absolutePath) + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new PrintServiceError('打印任务中的标签附件路径无效', 500) + } + return absolutePath +} + +const storeRenderedLabelFiles = async (renderedLabels: AssetLabelRenderedImagePayload[]) => { + const batchKey = `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${randomUUID()}` + const batchDirectory = path.join(PRINT_JOB_RENDER_STORAGE_ROOT, batchKey) + await fs.mkdir(batchDirectory, { recursive: true }) + + const storedFiles: AssetLabelStoredRenderFilePayload[] = [] + + try { + for (const [index, renderedLabel] of renderedLabels.entries()) { + const fileName = `${String(index + 1).padStart(2, '0')}-${renderedLabel.equipment_id}.png` + const absolutePath = path.join(batchDirectory, fileName) + const storageKey = path.posix.join(batchKey, fileName) + const imageBuffer = decodePngDataUrl(renderedLabel.image_data_url) + + await fs.writeFile(absolutePath, imageBuffer) + storedFiles.push({ + equipment_id: renderedLabel.equipment_id, + storage_key: storageKey, + content_type: 'image/png' + }) + } + + return { + batchDirectory, + storedFiles + } + } catch (error) { + await fs.rm(batchDirectory, { recursive: true, force: true }) + throw error + } +} + +const removeStoredRenderBatch = async (batchDirectory?: string | null) => { + if (!batchDirectory) { + return + } + + await fs.rm(batchDirectory, { recursive: true, force: true }) +} + +const hydrateStoredRenderedLabelFiles = async ( + storedFiles: AssetLabelStoredRenderFilePayload[] +): Promise => { + return Promise.all( + storedFiles.map(async (storedFile) => { + const absolutePath = resolveStoredRenderAbsolutePath(storedFile.storage_key) + const fileBuffer = await fs.readFile(absolutePath) + + return { + equipment_id: storedFile.equipment_id, + image_data_url: `${PNG_DATA_URL_PREFIX}${fileBuffer.toString('base64')}` + } + }) + ) +} + +const collectStoredRenderBatchDirectories = (payload: AssetLabelJobPayload | null) => { + if (!payload?.rendered_label_files?.length) { + return [] + } + + return Array.from( + new Set( + payload.rendered_label_files.map((storedFile) => + path.dirname(resolveStoredRenderAbsolutePath(storedFile.storage_key)) + ) + ) + ) +} + +const removeStoredRenderFilesForPayload = async (payload: AssetLabelJobPayload | null) => { + const batchDirectories = collectStoredRenderBatchDirectories(payload) + await Promise.all(batchDirectories.map((batchDirectory) => removeStoredRenderBatch(batchDirectory))) +} + +const resolvePrintJobPayloadForResponse = async ( + payload: AssetLabelJobPayload | null, + options: { + includeRenderedLabels?: boolean + } = {} +) => { + if (!payload) { + return null + } + + const sanitizedPayload = stripRenderedLabelData(payload) + if (!options.includeRenderedLabels) { + return sanitizedPayload + } + + if (payload.rendered_labels?.length) { + return { + ...sanitizedPayload, + rendered_labels: payload.rendered_labels + } + } + + if (!payload.rendered_label_files?.length) { + return sanitizedPayload + } + + return { + ...sanitizedPayload, + rendered_labels: await hydrateStoredRenderedLabelFiles(payload.rendered_label_files) + } +} + export const resolveEffectiveTerminalStatus = ( terminal: Pick, now: Date = new Date() @@ -382,6 +607,13 @@ 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 @@ -496,8 +728,14 @@ const buildPrintJobSummary = (payload: AssetLabelJobPayload | null) => { } } -const mapPrintJobRecord = (job: PrintJobListRecord) => { +const mapPrintJobRecord = async ( + job: PrintJobListRecord, + options: { + includeRenderedLabels?: boolean + } = {} +) => { const payload = parseAssetLabelJobPayload(job.payload_json) + const responsePayload = await resolvePrintJobPayloadForResponse(payload, options) const targetParsed = parseTerminalData(job.targetTerminal) return { @@ -511,7 +749,7 @@ const mapPrintJobRecord = (job: PrintJobListRecord) => { completed_at: job.completed_at, created_at: job.created_at, updated_at: job.updated_at, - payload, + payload: responsePayload, summary: buildPrintJobSummary(payload), target_terminal: { id: job.targetTerminal.id, @@ -778,8 +1016,9 @@ export const updatePrintTerminalAdminStatus = async ( throw new PrintServiceError('打印终端不存在', 404) } - if (nextStatus === PRINT_TERMINAL_STATUS.disabled && existing.is_default) { - throw new PrintServiceError('默认打印电脑不能直接停用,请先切换默认终端', 422) + const statusChangeError = getPrintTerminalAdminStatusChangeError(existing, nextStatus) + if (statusChangeError) { + throw new PrintServiceError(statusChangeError, 422) } const updated = await prisma.printTerminal.update({ @@ -796,53 +1035,75 @@ export const updatePrintTerminalAdminStatus = async ( }) } -export const createAssetLabelPrintJob = async (equipmentIdsInput: unknown, requestedBy: number) => { - const equipmentIds = sanitizeEquipmentIds(equipmentIdsInput) - const [targetTerminal, payload] = await Promise.all([ +export const createAssetLabelPrintJob = async (input: unknown, requestedBy: number) => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new PrintServiceError('当前页面不是最新版本,请刷新后重试', 422) + } + + const equipmentIds = sanitizeEquipmentIds((input as { equipment_ids?: unknown }).equipment_ids) + const renderedLabels = sanitizeRenderedLabelImages( + (input as { rendered_labels?: unknown }).rendered_labels, + equipmentIds + ) + const [targetTerminal, basePayload] = await Promise.all([ getDefaultTargetTerminal(), buildAssetLabelPayload(equipmentIds) ]) - const 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 - } + const storedRenderBatch = await storeRenderedLabelFiles(renderedLabels) + + let job: PrintJobListRecord + + try { + const payload: AssetLabelJobPayload = { + ...basePayload, + 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) }, - 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) return { - job: mapPrintJobRecord(job), + job: await mapPrintJobRecord(job), queue_notice: parsedTarget.readiness.reason === 'ready' ? '打印任务已入队,等待默认打印电脑领取' @@ -909,7 +1170,7 @@ export const listPrintJobs = async ( ]) return { - list: jobs.map((job) => mapPrintJobRecord(job)), + list: await Promise.all(jobs.map((job) => mapPrintJobRecord(job))), pagination: { page: safePage, pageSize: safePageSize, @@ -938,6 +1199,8 @@ export const cancelQueuedPrintJob = async (jobId: number) => { status: PRINT_JOB_STATUS.cancelled } }) + + await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json)) } export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => { @@ -1191,10 +1454,35 @@ export const claimNextPrintJob = async (terminalId: number) => { } } - return { - terminal: terminalView, - job: mapPrintJobRecord(claimedJob), - message: '领取成功' + try { + return { + terminal: terminalView, + job: await mapPrintJobRecord(claimedJob, { + includeRenderedLabels: true + }), + message: '领取成功' + } + } catch (error) { + const errorMessage = + error instanceof Error && error.message + ? `任务成品标签图读取失败,请在系统里重新发送打印:${error.message}` + : '任务成品标签图读取失败,请在系统里重新发送打印' + + await prisma.printJob.update({ + where: { + id: claimedJob.id + }, + data: { + status: PRINT_JOB_STATUS.failed, + error_message: errorMessage + } + }) + + return { + terminal: terminalView, + job: null, + message: errorMessage + } } } @@ -1314,8 +1602,12 @@ export const updateClaimedPrintJobStatus = async ( } }) + if (updated.status === PRINT_JOB_STATUS.completed || updated.status === PRINT_JOB_STATUS.failed) { + await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(updated.payload_json)) + } + return { - job: mapPrintJobRecord(updated), + job: await mapPrintJobRecord(updated), message } } diff --git a/backend/src/utils/__tests__/borrowAnomaly.test.ts b/backend/src/utils/__tests__/borrowAnomaly.test.ts new file mode 100644 index 0000000..18e31aa --- /dev/null +++ b/backend/src/utils/__tests__/borrowAnomaly.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + getBorrowAnomalyMeta, + isBorrowRecordMissingState +} from '../borrowAnomaly' + +test('已借出但没有未归还借用单的单件独立资产可进入异常借出处理', () => { + assert.deepEqual( + getBorrowAnomalyMeta({ + status: '已借出', + activeBorrowCount: 0, + totalQuantity: 1, + parentId: null + }), + { + state: 'borrow_record_missing', + canHandle: true, + message: '资产状态为已借出,但系统中没有对应借用记录', + blockedReason: null + } + ) +}) + +test('存在未归还借用单时按正常可归还资产处理', () => { + assert.deepEqual( + getBorrowAnomalyMeta({ + status: '已借出', + activeBorrowCount: 1, + totalQuantity: 1, + parentId: null + }), + { + state: 'returnable', + canHandle: false, + message: '资产存在未归还借用记录,可按正常归还处理', + blockedReason: null + } + ) +}) + +test('已挂载子资产不允许直接做异常借出处理', () => { + assert.deepEqual( + getBorrowAnomalyMeta({ + status: '已借出', + activeBorrowCount: 0, + totalQuantity: 1, + parentId: 88 + }), + { + state: 'borrow_record_missing', + canHandle: false, + message: '资产状态为已借出,但系统中没有对应借用记录', + blockedReason: '该资产当前作为已挂载子资产存在,请先处理所属父资产' + } + ) +}) + +test('多数量资产先拦截人工核对,不开放异常借出处理', () => { + assert.deepEqual( + getBorrowAnomalyMeta({ + status: '已借出', + activeBorrowCount: 0, + totalQuantity: 3, + parentId: null + }), + { + state: 'borrow_record_missing', + canHandle: false, + message: '资产状态为已借出,但系统中没有对应借用记录', + blockedReason: '多数量资产暂不支持异常借出处理,请先人工核对' + } + ) +}) + +test('辅助判断函数能识别借用单缺失状态', () => { + assert.equal(isBorrowRecordMissingState('borrow_record_missing'), true) + assert.equal(isBorrowRecordMissingState('returnable'), false) + assert.equal(isBorrowRecordMissingState('none'), false) +}) diff --git a/backend/src/utils/__tests__/borrowDepartment.test.ts b/backend/src/utils/__tests__/borrowDepartment.test.ts new file mode 100644 index 0000000..73cb44d --- /dev/null +++ b/backend/src/utils/__tests__/borrowDepartment.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + normalizeReturnDepartmentInput, + resolveBorrowDepartmentUpdate +} from '../borrowDepartment' + +test('归还时保留当前归属不生成部门更新', () => { + assert.deepEqual( + normalizeReturnDepartmentInput({ + department_action: 'keep' + }), + { + departmentAction: 'keep', + departmentId: null, + equipmentUpdateData: null + } + ) +}) + +test('归还时清空归属会生成 department_id = null', () => { + assert.deepEqual( + normalizeReturnDepartmentInput({ + department_action: 'clear' + }), + { + departmentAction: 'clear', + departmentId: null, + equipmentUpdateData: { + department_id: null + } + } + ) +}) + +test('归还时指定部门会生成 department_id 更新', () => { + assert.deepEqual( + normalizeReturnDepartmentInput({ + department_action: 'set', + department_id: 18 + }), + { + departmentAction: 'set', + departmentId: 18, + equipmentUpdateData: { + department_id: 18 + } + } + ) +}) + +test('归还时指定部门但未传 department_id 会抛错', () => { + assert.throws( + () => + normalizeReturnDepartmentInput({ + department_action: 'set' + }), + /请选择要改成的业务部门/ + ) +}) + +test('出库时使用者有主部门就同步资产归属', () => { + assert.deepEqual(resolveBorrowDepartmentUpdate(23), { + department_id: 23 + }) +}) + +test('出库时使用者没有主部门则保持资产原归属', () => { + assert.equal(resolveBorrowDepartmentUpdate(null), null) + assert.equal(resolveBorrowDepartmentUpdate(undefined), null) +}) diff --git a/backend/src/utils/__tests__/equipmentDeletion.test.ts b/backend/src/utils/__tests__/equipmentDeletion.test.ts new file mode 100644 index 0000000..b86a37a --- /dev/null +++ b/backend/src/utils/__tests__/equipmentDeletion.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { getEquipmentDeleteBlockedReason } from '../equipmentDeletion' + +test('允许删除完全未进入任何业务流程的资产', () => { + assert.equal( + getEquipmentDeleteBlockedReason({ + borrowRecordCount: 0, + isMountedChild: false, + childCount: 0, + mountHistoryCount: 0, + disposalRecordCount: 0, + maintenanceRecordCount: 0, + inventoryTaskItemCount: 0 + }), + null + ) +}) + +test('只要存在任何借用记录就禁止删除', () => { + assert.equal( + getEquipmentDeleteBlockedReason({ + borrowRecordCount: 2, + isMountedChild: false, + childCount: 0, + mountHistoryCount: 0, + disposalRecordCount: 0, + maintenanceRecordCount: 0, + inventoryTaskItemCount: 0 + }), + '该资产已存在借用记录,无法删除' + ) +}) + +test('挂载中的子资产不允许删除', () => { + assert.equal( + getEquipmentDeleteBlockedReason({ + borrowRecordCount: 0, + isMountedChild: true, + childCount: 0, + mountHistoryCount: 0, + disposalRecordCount: 0, + maintenanceRecordCount: 0, + inventoryTaskItemCount: 0 + }), + '该资产当前作为子资产挂载中,无法删除' + ) +}) + +test('存在子资产时不允许删除父资产', () => { + assert.equal( + getEquipmentDeleteBlockedReason({ + borrowRecordCount: 0, + isMountedChild: false, + childCount: 3, + mountHistoryCount: 0, + disposalRecordCount: 0, + maintenanceRecordCount: 0, + inventoryTaskItemCount: 0 + }), + '该资产存在已挂载的子资产,无法删除' + ) +}) + +test('存在挂载、维修或报废历史时不允许删除', () => { + assert.equal( + getEquipmentDeleteBlockedReason({ + borrowRecordCount: 0, + isMountedChild: false, + childCount: 0, + mountHistoryCount: 1, + disposalRecordCount: 0, + maintenanceRecordCount: 0, + inventoryTaskItemCount: 0 + }), + '该资产已存在维修、报废或挂载历史,无法删除' + ) +}) + +test('存在盘点任务项时不允许删除', () => { + assert.equal( + getEquipmentDeleteBlockedReason({ + borrowRecordCount: 0, + isMountedChild: false, + childCount: 0, + mountHistoryCount: 0, + disposalRecordCount: 0, + maintenanceRecordCount: 0, + inventoryTaskItemCount: 1 + }), + '该资产已存在盘点记录,无法删除' + ) +}) diff --git a/backend/src/utils/__tests__/inventoryScope.test.ts b/backend/src/utils/__tests__/inventoryScope.test.ts new file mode 100644 index 0000000..3df8ec0 --- /dev/null +++ b/backend/src/utils/__tests__/inventoryScope.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + normalizeInventoryScopeInput, + resolveUniqueActiveBorrowRecord +} from '../inventoryScope' + +test('盘点范围会把旧单选字段归一成数组', () => { + assert.deepEqual( + normalizeInventoryScopeInput({ + department_id: 3, + asset_type_id: '9', + storage_location_ids: [] + }), + { + departmentIds: [3], + assetTypeIds: [9], + storageLocationIds: [] + } + ) +}) + +test('盘点范围会合并新旧字段、去重并过滤非法值', () => { + assert.deepEqual( + normalizeInventoryScopeInput({ + department_id: 3, + department_ids: [3, '8', 0, 'abc', 8], + asset_type_ids: ['5', 5, 12, null], + storage_location_ids: [2, '7', -1, undefined, 2] + }), + { + departmentIds: [3, 8], + assetTypeIds: [5, 12], + storageLocationIds: [2, 7] + } + ) +}) + +test('只有唯一且人员仍为 active 的未归还记录才算当前使用人', () => { + const record = resolveUniqueActiveBorrowRecord([ + { + id: 11, + person: { + id: 2, + status: 'active' + } + } + ]) + + assert.equal(record?.id, 11) +}) + +test('多条未归还记录时不纳入盘点参与人', () => { + assert.equal( + resolveUniqueActiveBorrowRecord([ + { + id: 11, + person: { + id: 2, + status: 'active' + } + }, + { + id: 12, + person: { + id: 3, + status: 'active' + } + } + ]), + null + ) +}) + +test('人员已离职时不纳入盘点参与人', () => { + assert.equal( + resolveUniqueActiveBorrowRecord([ + { + id: 11, + person: { + id: 2, + status: 'inactive' + } + } + ]), + null + ) +}) diff --git a/backend/src/utils/__tests__/inventoryTaskLifecycle.test.ts b/backend/src/utils/__tests__/inventoryTaskLifecycle.test.ts new file mode 100644 index 0000000..32d0af9 --- /dev/null +++ b/backend/src/utils/__tests__/inventoryTaskLifecycle.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { getInventoryTaskDeleteBlockedReason } from '../inventoryTaskLifecycle' + +test('进行中的盘点任务允许删除', () => { + assert.equal(getInventoryTaskDeleteBlockedReason('active'), null) +}) + +test('已结束的盘点任务也允许删除', () => { + assert.equal(getInventoryTaskDeleteBlockedReason('closed'), null) +}) diff --git a/backend/src/utils/borrowAnomaly.ts b/backend/src/utils/borrowAnomaly.ts new file mode 100644 index 0000000..a11b3aa --- /dev/null +++ b/backend/src/utils/borrowAnomaly.ts @@ -0,0 +1,57 @@ +export type BorrowAnomalyState = 'none' | 'returnable' | 'borrow_record_missing' + +export interface BorrowAnomalyMeta { + state: BorrowAnomalyState + canHandle: boolean + message: string | null + blockedReason: string | null +} + +export const isBorrowRecordMissingState = (state?: string | null) => state === 'borrow_record_missing' + +export const getBorrowAnomalyMeta = ({ + status, + activeBorrowCount, + totalQuantity, + parentId +}: { + status?: string | null + activeBorrowCount?: number | null + totalQuantity?: number | null + parentId?: number | null +}): BorrowAnomalyMeta => { + const normalizedActiveBorrowCount = Math.max(0, Number(activeBorrowCount) || 0) + const normalizedTotalQuantity = Math.max(0, Number(totalQuantity) || 0) + + if (normalizedActiveBorrowCount > 0) { + return { + state: 'returnable', + canHandle: false, + message: '资产存在未归还借用记录,可按正常归还处理', + blockedReason: null + } + } + + if (status !== '已借出') { + return { + state: 'none', + canHandle: false, + message: null, + blockedReason: null + } + } + + let blockedReason: string | null = null + if (parentId) { + blockedReason = '该资产当前作为已挂载子资产存在,请先处理所属父资产' + } else if (normalizedTotalQuantity !== 1) { + blockedReason = '多数量资产暂不支持异常借出处理,请先人工核对' + } + + return { + state: 'borrow_record_missing', + canHandle: blockedReason === null, + message: '资产状态为已借出,但系统中没有对应借用记录', + blockedReason + } +} diff --git a/backend/src/utils/borrowDepartment.ts b/backend/src/utils/borrowDepartment.ts new file mode 100644 index 0000000..52dfeb9 --- /dev/null +++ b/backend/src/utils/borrowDepartment.ts @@ -0,0 +1,82 @@ +export type ReturnDepartmentAction = 'keep' | 'clear' | 'set' + +export interface NormalizeReturnDepartmentInputParams { + department_action?: unknown + department_id?: unknown +} + +export interface NormalizedReturnDepartmentInput { + departmentAction: ReturnDepartmentAction + departmentId: number | null + equipmentUpdateData: { + department_id: number | null + } | null +} + +const toOptionalPositiveInteger = (value: unknown) => { + if (value === undefined || value === null || value === '') { + return null + } + + const numericValue = Number(value) + if (!Number.isInteger(numericValue) || numericValue <= 0) { + return null + } + + return numericValue +} + +export const normalizeReturnDepartmentInput = ( + input: NormalizeReturnDepartmentInputParams +): NormalizedReturnDepartmentInput => { + const rawAction = typeof input.department_action === 'string' + ? input.department_action.trim() + : '' + + const departmentAction: ReturnDepartmentAction = + rawAction === 'clear' || rawAction === 'set' + ? rawAction + : 'keep' + + if (departmentAction === 'keep') { + return { + departmentAction, + departmentId: null, + equipmentUpdateData: null + } + } + + if (departmentAction === 'clear') { + return { + departmentAction, + departmentId: null, + equipmentUpdateData: { + department_id: null + } + } + } + + const departmentId = toOptionalPositiveInteger(input.department_id) + if (!departmentId) { + throw new Error('请选择要改成的业务部门') + } + + return { + departmentAction, + departmentId, + equipmentUpdateData: { + department_id: departmentId + } + } +} + +export const resolveBorrowDepartmentUpdate = (mainDepartmentId?: number | null) => { + const departmentId = toOptionalPositiveInteger(mainDepartmentId) + if (!departmentId) { + return null + } + + return { + department_id: departmentId + } +} diff --git a/backend/src/utils/equipmentDeletion.ts b/backend/src/utils/equipmentDeletion.ts new file mode 100644 index 0000000..1364bfb --- /dev/null +++ b/backend/src/utils/equipmentDeletion.ts @@ -0,0 +1,41 @@ +export interface EquipmentDeleteCheckInput { + borrowRecordCount: number + isMountedChild: boolean + childCount: number + mountHistoryCount: number + disposalRecordCount: number + maintenanceRecordCount: number + inventoryTaskItemCount: number +} + +export const getEquipmentDeleteBlockedReason = ({ + borrowRecordCount, + isMountedChild, + childCount, + mountHistoryCount, + disposalRecordCount, + maintenanceRecordCount, + inventoryTaskItemCount +}: EquipmentDeleteCheckInput) => { + if (borrowRecordCount > 0) { + return '该资产已存在借用记录,无法删除' + } + + if (isMountedChild) { + return '该资产当前作为子资产挂载中,无法删除' + } + + if (childCount > 0) { + return '该资产存在已挂载的子资产,无法删除' + } + + if (mountHistoryCount > 0 || disposalRecordCount > 0 || maintenanceRecordCount > 0) { + return '该资产已存在维修、报废或挂载历史,无法删除' + } + + if (inventoryTaskItemCount > 0) { + return '该资产已存在盘点记录,无法删除' + } + + return null +} diff --git a/backend/src/utils/inventoryScope.ts b/backend/src/utils/inventoryScope.ts new file mode 100644 index 0000000..369467f --- /dev/null +++ b/backend/src/utils/inventoryScope.ts @@ -0,0 +1,70 @@ +export interface NormalizeInventoryScopeInputParams { + department_id?: unknown + department_ids?: unknown + departmentIds?: unknown + asset_type_id?: unknown + asset_type_ids?: unknown + assetTypeIds?: unknown + storage_location_id?: unknown + storage_location_ids?: unknown + storageLocationIds?: unknown +} + +export interface NormalizedInventoryScopeInput { + departmentIds: number[] + assetTypeIds: number[] + storageLocationIds: number[] +} + +const toNormalizedScopeIds = (values: unknown[]) => + Array.from( + new Set( + values + .map((value) => Number(value)) + .filter((value) => Number.isInteger(value) && value > 0) + ) + ) + +const normalizeScopeIdArray = (primary: unknown, fallback?: unknown) => { + const mergedValues: unknown[] = [] + + if (Array.isArray(primary)) { + mergedValues.push(...primary) + } else if (primary !== undefined) { + mergedValues.push(primary) + } + + if (Array.isArray(fallback)) { + mergedValues.push(...fallback) + } else if (fallback !== undefined) { + mergedValues.push(fallback) + } + + return toNormalizedScopeIds(mergedValues) +} + +export const normalizeInventoryScopeInput = ( + input: NormalizeInventoryScopeInputParams +): NormalizedInventoryScopeInput => ({ + departmentIds: normalizeScopeIdArray(input.departmentIds ?? input.department_ids, input.department_id), + assetTypeIds: normalizeScopeIdArray(input.assetTypeIds ?? input.asset_type_ids, input.asset_type_id), + storageLocationIds: normalizeScopeIdArray( + input.storageLocationIds ?? input.storage_location_ids, + input.storage_location_id + ) +}) + +export const resolveUniqueActiveBorrowRecord = ( + records: T[] +) => { + if (records.length !== 1) { + return null + } + + const [record] = records + if (!record?.person || record.person.status !== 'active') { + return null + } + + return record +} diff --git a/backend/src/utils/inventoryTaskLifecycle.ts b/backend/src/utils/inventoryTaskLifecycle.ts new file mode 100644 index 0000000..8a67ae3 --- /dev/null +++ b/backend/src/utils/inventoryTaskLifecycle.ts @@ -0,0 +1,3 @@ +export const getInventoryTaskDeleteBlockedReason = (status?: string | null) => { + return null +} diff --git a/docker-compose.yml b/docker-compose.yml index 09e7fa9..a275cc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,7 @@ services: HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00} HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-} HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-3} - HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5} + HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-2,5} HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流} HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-} DOWNLOADS_DIR: /app/downloads diff --git a/downloads/print-agent/latest/manifest.json b/downloads/print-agent/latest/manifest.json index 635bc59..de3a9d1 100644 --- a/downloads/print-agent/latest/manifest.json +++ b/downloads/print-agent/latest/manifest.json @@ -1,9 +1,9 @@ { - "version": "0.2.0", - "published_at": "2026-03-25T17:45:51+08:00", - "filename": "print-agent-setup.exe", - "notes": "打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。", - "source_type": "server_release", - "relative_path": "downloads/print-agent/latest/print-agent-setup.exe", - "relativePath": "downloads/print-agent/latest/print-agent-setup.exe" -} + "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 diff --git a/downloads/print-agent/releases/0.2.1/manifest.json b/downloads/print-agent/releases/0.2.1/manifest.json new file mode 100644 index 0000000..e010a41 --- /dev/null +++ b/downloads/print-agent/releases/0.2.1/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "0.2.1", + "published_at": "2026-03-26T15:07:34+08:00", + "filename": "print-agent-setup-0.2.1.exe", + "notes": "打印助手 v0.2.1,已切换为网页成品标签图直出模式,远程打印会直接复用网页版式,带正式安装器、控制面板卸载入口、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/releases/0.2.1/print-agent-setup-0.2.1.exe", + "relativePath": "downloads/print-agent/releases/0.2.1/print-agent-setup-0.2.1.exe" +} diff --git a/downloads/print-agent/releases/0.2.3/manifest.json b/downloads/print-agent/releases/0.2.3/manifest.json new file mode 100644 index 0000000..d1d439e --- /dev/null +++ b/downloads/print-agent/releases/0.2.3/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "0.2.3", + "published_at": "2026-03-26T15:44:48+08:00", + "filename": "print-agent-setup-0.2.3.exe", + "notes": "打印助手 v0.2.3,已切换为真正的小体积中文安装包路线,远程打印继续直出网页成品标签图;首次安装前需先装 .NET 8 Windows Desktop Runtime(x64)。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/releases/0.2.3/print-agent-setup-0.2.3.exe", + "relativePath": "downloads/print-agent/releases/0.2.3/print-agent-setup-0.2.3.exe" +} \ No newline at end of file diff --git a/downloads/print-agent/releases/0.2.4/manifest.json b/downloads/print-agent/releases/0.2.4/manifest.json new file mode 100644 index 0000000..9c71897 --- /dev/null +++ b/downloads/print-agent/releases/0.2.4/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "0.2.4", + "published_at": "2026-03-26T15:53:45+08:00", + "filename": "print-agent-setup-0.2.4.exe", + "notes": "打印助手 v0.2.4,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/releases/0.2.4/print-agent-setup-0.2.4.exe", + "relativePath": "downloads/print-agent/releases/0.2.4/print-agent-setup-0.2.4.exe" +} \ No newline at end of file diff --git a/downloads/print-agent/releases/0.2.5/manifest.json b/downloads/print-agent/releases/0.2.5/manifest.json new file mode 100644 index 0000000..e4269e2 --- /dev/null +++ b/downloads/print-agent/releases/0.2.5/manifest.json @@ -0,0 +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 diff --git a/frontend/scripts/test-auth-mode.ts b/frontend/scripts/test-auth-mode.ts new file mode 100644 index 0000000..f253246 --- /dev/null +++ b/frontend/scripts/test-auth-mode.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { resolveApiBaseUrl, resolveAuthMode } from '../src/utils/authMode.ts' + +assert.equal(resolveAuthMode(undefined), 'local', '未配置认证模式时必须默认走本地登录') +assert.equal(resolveAuthMode(''), 'local', '空认证模式不应误判为 Keycloak') +assert.equal(resolveAuthMode('local'), 'local', '显式 local 应保持本地登录') +assert.equal(resolveAuthMode('keycloak'), 'keycloak', '只有显式 keycloak 才允许走统一认证') +assert.equal(resolveAuthMode('KEYCLOAK'), 'local', '错误大小写不应被当作有效 Keycloak 配置') + +assert.equal(resolveApiBaseUrl(undefined), '/api', '未配置接口地址时必须默认走同域 /api') +assert.equal(resolveApiBaseUrl(''), '/api', '空接口地址不应退回 localhost') +assert.equal(resolveApiBaseUrl(' /api/v2 '), '/api/v2', '显式接口地址应保留并自动去空格') + +console.log('auth mode tests: ok') diff --git a/frontend/scripts/test-borrow-anomaly.ts b/frontend/scripts/test-borrow-anomaly.ts new file mode 100644 index 0000000..5de4a77 --- /dev/null +++ b/frontend/scripts/test-borrow-anomaly.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { canHandleBorrowAnomaly } from '../src/utils/borrowAnomaly.ts' + +test('支持后端返回的 camelCase canHandle 字段', () => { + const result = canHandleBorrowAnomaly({ + borrow_anomaly: { + state: 'borrow_record_missing', + canHandle: true + } + }) + + assert.equal(result, true) +}) + +test('兼容旧的 snake_case can_handle 字段', () => { + const result = canHandleBorrowAnomaly({ + borrow_anomaly: { + state: 'borrow_record_missing', + can_handle: true + } + }) + + assert.equal(result, true) +}) + +test('非借用单缺失状态时不可处理', () => { + const result = canHandleBorrowAnomaly({ + borrow_anomaly: { + state: 'none', + canHandle: true, + can_handle: true + } + }) + + assert.equal(result, false) +}) diff --git a/frontend/scripts/test-borrow-manual-search.ts b/frontend/scripts/test-borrow-manual-search.ts new file mode 100644 index 0000000..750f119 --- /dev/null +++ b/frontend/scripts/test-borrow-manual-search.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import { + filterManualBorrowCandidates, + findPreferredManualBorrowCandidate, + isManualBorrowCandidate +} from '../src/views/borrow/manualBorrowSearch.ts' + +const sampleList = [ + { + equipment_code: 'JS-PC-1001', + equipment_name: '设计部高配主机', + equipment_model: 'RTX4090-WORKSTATION', + equipment_type: '主机', + status: '可用', + remaining_quantity: 1, + parent_id: null, + storageLocation: { name: '设计中心-A区' } + }, + { + equipment_code: 'JS-PC-1002', + equipment_name: '普通办公主机', + equipment_model: 'OFFICE-01', + equipment_type: '主机', + status: '可用', + remaining_quantity: 2, + parent_id: null, + storageLocation: { name: '行政办公室' } + }, + { + equipment_code: 'JS-MEM-2001', + equipment_name: '内存条', + equipment_model: '32G DDR5', + equipment_type: '内存条', + status: '可用', + remaining_quantity: 1, + parent_id: 88, + storageLocation: { name: '设计中心-A区' } + }, + { + equipment_code: 'JS-MON-3001', + equipment_name: '曲面显示器', + equipment_model: '34WQHD', + equipment_type: '显示器', + status: '已借出', + remaining_quantity: 0, + parent_id: null, + storageLocation: { name: '摄影棚' } + } +] + +assert.equal(isManualBorrowCandidate(sampleList[0]), true, '独立且可用的资产应允许手动借出搜索') +assert.equal(isManualBorrowCandidate(sampleList[2]), false, '已挂载子资产不应出现在借出搜索中') +assert.equal(isManualBorrowCandidate(sampleList[3]), false, '已借出资产不应出现在借出搜索中') + +const byCode = filterManualBorrowCandidates(sampleList, 'JS-PC-1002') +assert.equal(byCode.length, 1, '按准确编码搜索应只返回该资产') +assert.equal(byCode[0].equipment_code, 'JS-PC-1002', '准确编码搜索必须命中目标资产') + +const byKeyword = filterManualBorrowCandidates(sampleList, '设计') +assert.equal(byKeyword.length, 1, '按名称关键词搜索应排除不可借资产') +assert.equal(byKeyword[0].equipment_code, 'JS-PC-1001', '名称关键词应返回正确资产') + +const byModel = filterManualBorrowCandidates(sampleList, '4090') +assert.equal(byModel[0].equipment_code, 'JS-PC-1001', '型号关键词应支持搜索') + +const byLocation = filterManualBorrowCandidates(sampleList, '行政') +assert.equal(byLocation[0].equipment_code, 'JS-PC-1002', '地址关键词应支持辅助定位') + +const preferredExact = findPreferredManualBorrowCandidate(byCode, 'js-pc-1002') +assert.equal(preferredExact?.equipment_code, 'JS-PC-1002', '精确编码时应优先自动选中') + +const preferredSingle = findPreferredManualBorrowCandidate(byKeyword, '设计') +assert.equal(preferredSingle?.equipment_code, 'JS-PC-1001', '只有一条结果时应允许直接读取') + +const multipleResults = filterManualBorrowCandidates(sampleList, '主机') +assert.equal(findPreferredManualBorrowCandidate(multipleResults, '主机'), null, '多条结果时不应擅自自动选中') + +console.log('borrow manual search tests: ok') diff --git a/frontend/scripts/test-borrow-person-search.ts b/frontend/scripts/test-borrow-person-search.ts new file mode 100644 index 0000000..fb412b9 --- /dev/null +++ b/frontend/scripts/test-borrow-person-search.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict' +import { + buildManualBorrowPersonDisplay, + filterManualBorrowPersons, + findPreferredManualBorrowPerson, + isManualBorrowSelectedPersonKeyword, + shouldShowManualBorrowPersonResults +} from '../src/views/borrow/manualBorrowPersonSearch.ts' + +const samplePersons = [ + { + id: 1, + nickname: '阿杰', + real_name: '王杰', + id_number: 'A1001', + department: '设计中心', + department_names: ['设计中心', '品牌设计部'], + phone: '13800000001', + status: 'active' + }, + { + id: 2, + nickname: '小周', + real_name: '周晴', + id_number: 'A1002', + department: '行政部', + department_names: ['行政部'], + phone: '13800000002', + status: 'active' + }, + { + id: 3, + nickname: '离职张', + real_name: '张离', + id_number: 'A1003', + department: '设计中心', + department_names: ['设计中心'], + phone: '13800000003', + status: 'inactive' + } +] + +const byNickname = filterManualBorrowPersons(samplePersons as any, '阿杰') +assert.equal(byNickname.length, 1, '花名精确匹配应只返回一个人') +assert.equal(byNickname[0].id, 1, '花名匹配应返回正确人员') + +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 byDepartment = filterManualBorrowPersons(samplePersons as any, '设计') +assert.equal(byDepartment.length, 1, '搜索部门时应排除已停用人员') +assert.equal(byDepartment[0].id, 1, '部门搜索结果不正确') + +const byPhone = filterManualBorrowPersons(samplePersons as any, '0002') +assert.equal(byPhone[0].id, 2, '手机号搜索应支持辅助定位') + +const preferredExact = findPreferredManualBorrowPerson(byNickname as any, '阿杰') +assert.equal(preferredExact?.id, 1, '精确匹配时应支持直接选中') + +const preferredSingle = findPreferredManualBorrowPerson(byDepartment as any, '设计') +assert.equal(preferredSingle?.id, 1, '只有一条结果时应支持直接选中') + +const selectedDisplay = buildManualBorrowPersonDisplay(samplePersons[0] as any) +assert.equal(selectedDisplay, '阿杰 王杰', '选中后输入框应回填花名和姓名') +assert.equal( + isManualBorrowSelectedPersonKeyword(samplePersons[0] as any, selectedDisplay), + true, + '选中人员后,回填文案不应被当成新的搜索条件清掉' +) +assert.equal( + shouldShowManualBorrowPersonResults(samplePersons[0] as any, selectedDisplay), + false, + '选中人员后不应继续显示搜索结果框' +) +assert.equal( + shouldShowManualBorrowPersonResults(samplePersons[0] as any, '行政'), + true, + '改了关键词后应继续展示新搜索结果' +) + +const multiResults = filterManualBorrowPersons( + [ + ...samplePersons, + { + id: 4, + nickname: '阿杰2', + real_name: '王杰同名', + id_number: 'A1004', + department: '设计中心', + department_names: ['设计中心'], + phone: '13800000004', + status: 'active' + } + ] as any, + '王' +) +assert.equal(findPreferredManualBorrowPerson(multiResults as any, '王'), null, '多条结果时不应自动选中') + +console.log('borrow person search tests: ok') diff --git a/frontend/scripts/test-borrow-scan-state.ts b/frontend/scripts/test-borrow-scan-state.ts new file mode 100644 index 0000000..1423f9b --- /dev/null +++ b/frontend/scripts/test-borrow-scan-state.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import { canSubmitBorrowScan, hasSelectedBorrowPerson } from '../src/views/borrow/borrowScanState.ts' + +assert.equal(hasSelectedBorrowPerson(null), false, '未选使用者时不应允许提交') +assert.equal(hasSelectedBorrowPerson(Number.NaN), false, '无效使用者编号不应允许提交') +assert.equal(hasSelectedBorrowPerson(12), true, '选中使用者后应识别为有效选择') + +assert.equal( + canSubmitBorrowScan({ hasEquipment: true, selectedPersonId: 12, loading: false }), + true, + '已有资产且已选使用者时,应允许直接点击确认出库' +) +assert.equal( + canSubmitBorrowScan({ hasEquipment: true, selectedPersonId: null, loading: false }), + false, + '未选使用者时不应允许提交' +) +assert.equal( + canSubmitBorrowScan({ hasEquipment: false, selectedPersonId: 12, loading: false }), + false, + '未识别资产时不应允许提交' +) +assert.equal( + canSubmitBorrowScan({ hasEquipment: true, selectedPersonId: 12, loading: true }), + false, + '提交中不应重复点击' +) + +console.log('borrow scan state tests: ok') diff --git a/frontend/scripts/test-notification-quick-return.ts b/frontend/scripts/test-notification-quick-return.ts new file mode 100644 index 0000000..427a1ee --- /dev/null +++ b/frontend/scripts/test-notification-quick-return.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import type { BorrowRecord, HrSyncBorrowedAssetPreview } from '../src/types.ts' +import { + buildNotificationQuickReturnAssetKey, + findNotificationQuickReturnRecord +} from '../src/views/notifications/notificationQuickReturn.ts' + +const assetWithBorrowRecord: HrSyncBorrowedAssetPreview = { + equipmentCode: 'JS-PC-3001', + equipmentName: '主机', + borrowRecordId: 901 +} + +const assetWithoutBorrowRecord: HrSyncBorrowedAssetPreview = { + equipmentCode: 'JS-MON-2001', + equipmentName: '显示器' +} + +const borrowRecords: BorrowRecord[] = [ + { + id: 901, + equipment_id: 3001, + equipment_code: 'JS-PC-3001', + person_id: 11, + person_name: '张亮', + person_nickname: '三藏', + borrow_time: '2026-03-26T09:00:00.000Z', + status: '已借出', + created_at: '2026-03-26T09:00:00.000Z', + updated_at: '2026-03-26T09:00:00.000Z' + }, + { + id: 902, + equipment_id: 3001, + equipment_code: 'JS-PC-3001', + person_id: 12, + person_name: '李想', + person_nickname: '小李', + borrow_time: '2026-03-26T10:00:00.000Z', + status: '已借出', + created_at: '2026-03-26T10:00:00.000Z', + updated_at: '2026-03-26T10:00:00.000Z' + } +] + +assert.equal( + buildNotificationQuickReturnAssetKey(assetWithBorrowRecord), + 'borrow-record:901', + '带借用记录编号的资产应优先用借用记录生成稳定 key' +) + +assert.equal( + buildNotificationQuickReturnAssetKey(assetWithoutBorrowRecord), + 'equipment:JS-MON-2001', + '没有借用记录编号时应回退到资产编码' +) + +assert.equal( + findNotificationQuickReturnRecord(borrowRecords, assetWithBorrowRecord)?.id, + 901, + '带借用记录编号时应优先命中对应借用记录' +) + +assert.equal( + findNotificationQuickReturnRecord(borrowRecords, assetWithoutBorrowRecord), + null, + '存在多条借用记录且没有指定借用记录编号时,不应擅自自动选中' +) + +assert.equal( + findNotificationQuickReturnRecord([borrowRecords[0]], assetWithoutBorrowRecord)?.id, + 901, + '只有一条借用记录时,应允许自动选中' +) + +console.log('notification quick return tests: ok') diff --git a/frontend/scripts/test-return-manual-search.ts b/frontend/scripts/test-return-manual-search.ts new file mode 100644 index 0000000..72f780a --- /dev/null +++ b/frontend/scripts/test-return-manual-search.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { + filterManualReturnAssetCandidates, + findPreferredManualReturnAssetCandidate, + isManualReturnAssetCandidate +} from '../src/views/borrow/manualReturnSearch.ts' + +const sampleList = [ + { + equipment_id: 11, + equipment_code: 'JS-PC-1883', + equipment_name: '品牌显示器', + equipment_model: 'U2723QE', + equipment_type: '显示器', + active_borrow_count: 1, + borrower_names: ['凤求凰 · 王静宁'], + borrower_identifiers: ['513701199912094029'], + borrower_departments: ['品牌策划中心'], + search_state: 'returnable', + can_return: true, + storageLocation: { name: '常州-天安' } + }, + { + equipment_id: 12, + equipment_code: 'JS-PC-2094', + equipment_name: '常州办公显示器', + equipment_model: 'AOC-34', + equipment_type: '显示器', + active_borrow_count: 0, + search_state: 'borrow_record_missing', + can_return: false, + storageLocation: { name: '常州-天安' } + }, + { + equipment_id: 13, + equipment_code: 'JS-MEM-2001', + equipment_name: '内存条', + equipment_model: '32G DDR5', + equipment_type: '内存条', + active_borrow_count: 0, + storageLocation: { name: '仓库' } + } +] + +assert.equal(isManualReturnAssetCandidate(sampleList[0]), true, '存在未归还记录的资产应允许出现在归还搜索中') +assert.equal(isManualReturnAssetCandidate(sampleList[1]), true, '借用单缺失但状态仍为已借出的资产也应允许被搜到') +assert.equal(isManualReturnAssetCandidate(sampleList[2]), false, '没有未归还记录的资产不应出现在归还搜索中') + +const byCode = filterManualReturnAssetCandidates(sampleList, 'JS-PC-1883') +assert.equal(byCode.length, 1, '按准确编码搜索应只返回对应资产') +assert.equal(byCode[0].equipment_id, 11, '准确编码搜索结果不正确') + +const byName = filterManualReturnAssetCandidates(sampleList, '显示器') +assert.equal(byName.length, 2, '按名称搜索应命中对应的借出资产') + +const byModel = filterManualReturnAssetCandidates(sampleList, 'AOC') +assert.equal(byModel[0].equipment_id, 12, '型号搜索应支持辅助定位') + +const byLocation = filterManualReturnAssetCandidates(sampleList, '天安') +assert.equal(byLocation.length, 2, '地址搜索应支持辅助定位') + +const byCodeSuffix = filterManualReturnAssetCandidates(sampleList, '2094') +assert.equal(byCodeSuffix.length, 1, '按资产编码片段搜索时,不应被借用人身份证号误导') +assert.equal(byCodeSuffix[0].equipment_code, 'JS-PC-2094', '资产编码片段搜索应优先命中实际资产') +assert.equal(byCodeSuffix[0].search_state, 'borrow_record_missing', '借用单缺失资产应保留异常标记') + +const preferredExact = findPreferredManualReturnAssetCandidate(byCode, 'js-pc-1883') +assert.equal(preferredExact?.equipment_id, 11, '准确编码时应优先自动选中') + +const preferredSingle = findPreferredManualReturnAssetCandidate(byCodeSuffix, 'js-pc-2094') +assert.equal(preferredSingle?.equipment_id, 12, '只有一条结果时应允许直接选中') + +const multiResults = filterManualReturnAssetCandidates(sampleList, 'JS-') +assert.equal( + findPreferredManualReturnAssetCandidate(multiResults, 'JS-'), + null, + '多条结果时不应擅自自动选中' +) + +console.log('return manual asset search tests: ok') diff --git a/frontend/scripts/test-return-record-search.ts b/frontend/scripts/test-return-record-search.ts new file mode 100644 index 0000000..09c60da --- /dev/null +++ b/frontend/scripts/test-return-record-search.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { + buildManualReturnRecordDisplay, + filterManualReturnRecordCandidates, + findPreferredManualReturnRecordCandidate, + isManualReturnSelectedRecordKeyword, + shouldShowManualReturnRecordResults +} from '../src/views/borrow/manualReturnRecordSearch.ts' + +const sampleRecords = [ + { + id: 101, + person_id: 1, + person_name: '王杰', + person_nickname: '阿杰', + person_identifier: 'A1001', + borrow_time: '2026-03-25T09:30:00.000Z', + status: '已借出', + person: { + id: 1, + nickname: '阿杰', + real_name: '王杰', + department: '设计中心', + department_names: ['设计中心', '品牌设计部'], + phone: '13800000001', + status: 'active' + } + }, + { + id: 102, + person_id: 2, + person_name: '周晴', + person_nickname: '小周', + person_identifier: 'A1002', + borrow_time: '2026-03-25T11:00:00.000Z', + status: '已借出', + person: { + id: 2, + nickname: '小周', + real_name: '周晴', + department: '行政部', + department_names: ['行政部'], + phone: '13800000002', + status: 'active' + } + } +] + +const byNickname = filterManualReturnRecordCandidates(sampleRecords as any, '阿杰') +assert.equal(byNickname.length, 1, '花名搜索应返回正确的借用记录') +assert.equal(byNickname[0].id, 101, '花名搜索结果不正确') + +const byRealName = filterManualReturnRecordCandidates(sampleRecords as any, '周晴') +assert.equal(byRealName[0].id, 102, '姓名搜索应支持') + +const byIdentifier = filterManualReturnRecordCandidates(sampleRecords as any, 'A1001') +assert.equal(byIdentifier[0].id, 101, '工号搜索应支持') + +const byDepartment = filterManualReturnRecordCandidates(sampleRecords as any, '设计') +assert.equal(byDepartment[0].id, 101, '部门搜索应支持') + +const preferredExact = findPreferredManualReturnRecordCandidate(byNickname as any, '阿杰') +assert.equal(preferredExact?.id, 101, '精确匹配时应优先自动选中') + +const preferredSingle = findPreferredManualReturnRecordCandidate(byDepartment as any, '设计') +assert.equal(preferredSingle?.id, 101, '只有一条结果时应支持直接选中') + +const display = buildManualReturnRecordDisplay(sampleRecords[0] as any) +assert.equal(display, '阿杰 王杰', '选中后输入框应回填花名和姓名') +assert.equal( + isManualReturnSelectedRecordKeyword(sampleRecords[0] as any, display), + true, + '回填后的文案不应被误判成新的搜索词' +) +assert.equal( + shouldShowManualReturnRecordResults(sampleRecords[0] as any, display), + false, + '选中记录后不应继续展示搜索结果框' +) +assert.equal( + shouldShowManualReturnRecordResults(sampleRecords[0] as any, '行政'), + true, + '改了关键词后应继续展示新搜索结果' +) + +console.log('return manual record search tests: ok') diff --git a/frontend/scripts/test-return-scan-state.ts b/frontend/scripts/test-return-scan-state.ts new file mode 100644 index 0000000..d173bbc --- /dev/null +++ b/frontend/scripts/test-return-scan-state.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import { canSubmitReturnScan, hasSelectedReturnRecord } from '../src/views/borrow/returnScanState.ts' + +assert.equal(hasSelectedReturnRecord(null), false, '未选归还记录时不应允许提交') +assert.equal(hasSelectedReturnRecord(Number.NaN), false, '无效归还记录编号不应允许提交') +assert.equal(hasSelectedReturnRecord(101), true, '选中归还记录后应识别为有效选择') + +assert.equal( + canSubmitReturnScan({ hasBorrowRecordInfo: true, selectedBorrowRecordId: 101, loading: false }), + true, + '已读取资产且已选归还记录时,应允许确认入库' +) +assert.equal( + canSubmitReturnScan({ hasBorrowRecordInfo: true, selectedBorrowRecordId: null, loading: false }), + false, + '未选归还记录时不应允许提交' +) +assert.equal( + canSubmitReturnScan({ hasBorrowRecordInfo: false, selectedBorrowRecordId: 101, loading: false }), + false, + '未读取资产时不应允许提交' +) +assert.equal( + canSubmitReturnScan({ hasBorrowRecordInfo: true, selectedBorrowRecordId: 101, loading: true }), + false, + '提交中不应重复点击' +) + +console.log('return scan state tests: ok') diff --git a/frontend/src/api/borrow.ts b/frontend/src/api/borrow.ts index a86d91c..268fb1d 100644 --- a/frontend/src/api/borrow.ts +++ b/frontend/src/api/borrow.ts @@ -7,6 +7,13 @@ import type { StorageLocation } from '@/types' +export type BorrowDepartmentAction = 'keep' | 'clear' | 'set' + +export interface ReturnDepartmentPayload { + department_action?: BorrowDepartmentAction + department_id?: number | null +} + export interface ScanBorrowResult { id: number equipment_code: string @@ -19,6 +26,25 @@ export interface ScanBorrowResult { storageLocation?: StorageLocation | null } +export interface ReturnSearchAssetCandidate { + equipment_id: number + equipment_code: string + equipment_name: string + equipment_model?: string | null + equipment_type?: string | null + storage_location_id?: number | null + storageLocation?: StorageLocation | null + active_borrow_count: number + borrower_names?: string[] + borrower_identifiers?: string[] + borrower_departments?: string[] + can_return?: boolean + can_handle_borrow_anomaly?: boolean + borrow_anomaly_blocked_reason?: string | null + search_state?: 'returnable' | 'borrow_record_missing' + state_message?: string +} + // 借用设备 export const borrowEquipment = async (data: { equipment_id?: number @@ -73,7 +99,7 @@ export const returnEquipment = async (data: { return_condition?: string notes?: string confirm_children_diff?: boolean -}) => { +} & ReturnDepartmentPayload) => { const response = await api.post>('/return', data) return response.data } @@ -86,7 +112,7 @@ export const scanReturn = async (data: { person_id?: number borrow_record_id?: number confirm_children_diff?: boolean -}) => { +} & ReturnDepartmentPayload) => { const response = await api.post< ApiResponse<{ id: number @@ -139,6 +165,44 @@ export const getBorrowRecordByCode = async (equipmentCode: string) => { return response.data } +export const searchReturnAssets = async (params: { keyword?: string }) => { + const response = await api.get>('/return/search-assets', { + params + }) + return response.data +} + +export const resolveBorrowAnomalyAsReturned = async (equipmentId: number) => { + const response = await api.post>(`/borrow/anomalies/${equipmentId}/mark-returned`) + return response.data +} + +export const registerBorrowAnomaly = async ( + equipmentId: number, + data: { + person_id: number + borrow_time: string + notes?: string + } +) => { + const response = await api.post>(`/borrow/anomalies/${equipmentId}/register-borrow`, data) + return response.data +} + // 获取借用记录列表 export const getBorrowRecordList = async (params: { page?: number diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4ab7253..41a90bb 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,10 +1,11 @@ import axios from 'axios' import type { AxiosInstance, AxiosResponse } from 'axios' import type { ApiResponse } from '@/types' +import { resolveApiBaseUrl, resolveAuthMode } from '@/utils/authMode' // 创建axios实例 const api: AxiosInstance = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', + baseURL: resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL), timeout: 10000, headers: { 'Content-Type': 'application/json' @@ -28,7 +29,46 @@ api.interceptors.request.use( } ) -const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' +const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) + +const getResponseErrorMessage = (error: any) => { + const responseData = error?.response?.data + if (responseData && typeof responseData === 'object') { + if (typeof responseData.message === 'string' && responseData.message.trim()) { + return responseData.message.trim() + } + + if ( + responseData.data && + typeof responseData.data === 'object' && + typeof responseData.data.message === 'string' && + responseData.data.message.trim() + ) { + return responseData.data.message.trim() + } + } + + if (typeof error?.message === 'string' && error.message.trim()) { + return error.message.trim() + } + + return '请求失败' +} + +const createApiRequestError = (error: any) => { + const normalizedError = new Error(getResponseErrorMessage(error)) as Error & { + status?: number + response?: any + originalError?: any + } + + normalizedError.name = 'ApiRequestError' + normalizedError.status = error?.response?.status + normalizedError.response = error?.response + normalizedError.originalError = error + + return normalizedError +} // 响应拦截器 api.interceptors.response.use( @@ -76,7 +116,7 @@ api.interceptors.response.use( console.error('请求失败') } } - return Promise.reject(error) + return Promise.reject(createApiRequestError(error)) } ) diff --git a/frontend/src/api/inventory.ts b/frontend/src/api/inventory.ts index 7cdc988..745d31d 100644 --- a/frontend/src/api/inventory.ts +++ b/frontend/src/api/inventory.ts @@ -10,7 +10,11 @@ import type { export interface InventoryTaskPreviewPayload { department_id?: number | null + department_ids?: number[] asset_type_id?: number | null + asset_type_ids?: number[] + storage_location_id?: number | null + storage_location_ids?: number[] } export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload { @@ -48,6 +52,11 @@ export const closeInventoryTask = async (id: number) => { return response.data } +export const deleteInventoryTask = async (id: number) => { + const response = await api.delete>(`/inventory/tasks/${id}`) + return response.data +} + export const getInventoryTaskUnassignedItems = async (id: number) => { const response = await api.get>(`/inventory/tasks/${id}/unassigned`) return response.data diff --git a/frontend/src/api/print.ts b/frontend/src/api/print.ts index a9ebd90..5d2b78b 100644 --- a/frontend/src/api/print.ts +++ b/frontend/src/api/print.ts @@ -1,5 +1,6 @@ import axios from 'axios' import api from './index' +import { resolveApiBaseUrl } from '@/utils/authMode' import type { ApiResponse, CreateAssetLabelPrintJobPayload, @@ -20,7 +21,7 @@ import type { UpdatePrintTerminalStatusPayload } from '@/types' -const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api' +const apiBaseUrl = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL) const downloadBaseUrl = apiBaseUrl.replace(/\/api\/?$/, '') const getAuthHeaders = () => { @@ -325,8 +326,9 @@ export const getPrintTerminals = async () => { return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal) } -export const createAssetLabelPrintJob = async (equipmentIds: number[]): Promise => { - const payload: CreateAssetLabelPrintJobPayload = { equipment_ids: equipmentIds } +export const createAssetLabelPrintJob = async ( + payload: CreateAssetLabelPrintJobPayload +): Promise => { const response = await api.post>('/print-jobs/asset-labels', payload) return { job: normalizePrintJob(response.data.data), diff --git a/frontend/src/api/publicInventory.ts b/frontend/src/api/publicInventory.ts index e1dbad4..781e0b2 100644 --- a/frontend/src/api/publicInventory.ts +++ b/frontend/src/api/publicInventory.ts @@ -1,5 +1,6 @@ import axios from 'axios' import type { AxiosResponse } from 'axios' +import { resolveApiBaseUrl } from '@/utils/authMode' import type { ApiResponse, InventoryTaskItem, @@ -10,7 +11,7 @@ import type { } from '@/types' const resolvePublicInventoryBaseUrl = () => { - const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api' + const baseUrl = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL) return `${baseUrl.replace(/\/$/, '')}/public/inventory` } diff --git a/frontend/src/api/sync.ts b/frontend/src/api/sync.ts index ae24bc2..88535ed 100644 --- a/frontend/src/api/sync.ts +++ b/frontend/src/api/sync.ts @@ -21,7 +21,9 @@ export const previewHrSync = async () => { } export const runHrSync = async () => { - const response = await api.post>('/sync/hr/run') + const response = await api.post>('/sync/hr/run', undefined, { + timeout: 180000 + }) return response.data } diff --git a/frontend/src/auth/keycloak.ts b/frontend/src/auth/keycloak.ts index e284ee5..e318178 100644 --- a/frontend/src/auth/keycloak.ts +++ b/frontend/src/auth/keycloak.ts @@ -1,7 +1,7 @@ import Keycloak from 'keycloak-js' const keycloak = new Keycloak({ - url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8080', + url: import.meta.env.VITE_KEYCLOAK_URL || '', realm: import.meta.env.VITE_KEYCLOAK_REALM || 'equipment-management', clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'equipment-client' }) diff --git a/frontend/src/components/BarcodePrint.vue b/frontend/src/components/BarcodePrint.vue index dc78c0b..62fb92d 100644 --- a/frontend/src/components/BarcodePrint.vue +++ b/frontend/src/components/BarcodePrint.vue @@ -137,7 +137,7 @@ const LABEL_PRESET = { headerHeightPx: 82, qrPanelWidthPx: 324, qrCodeSizePx: 286, - companyName: '常州简尚家具有限公司' + companyName: '常州简尚家居有限公司' } as const const equipmentList = ref([]) @@ -724,7 +724,20 @@ const handleRemotePrint = async () => { remoteSubmitting.value = true try { - const result = await createAssetLabelPrintJob(props.equipmentIds) + const images = await ensureImagesGenerated() + if (images.length !== equipmentList.value.length) { + throw new Error('标签图片生成不完整,请稍后重试') + } + + const renderedLabels = equipmentList.value.map((equipment, index) => ({ + equipment_id: equipment.id, + image_data_url: images[index] + })) + const equipmentIds = equipmentList.value.map((equipment) => equipment.id) + const result = await createAssetLabelPrintJob({ + equipment_ids: equipmentIds, + rendered_labels: renderedLabels + }) alert(result.queue_notice || `标签已发送到 ${defaultTerminal.value.name}`) diff --git a/frontend/src/components/BorrowAnomalyHandler.vue b/frontend/src/components/BorrowAnomalyHandler.vue new file mode 100644 index 0000000..80d7648 --- /dev/null +++ b/frontend/src/components/BorrowAnomalyHandler.vue @@ -0,0 +1,403 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 875fc1e..90c15f8 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,6 +7,7 @@ import keycloak from './auth/keycloak' import { useAuthStore } from './stores/auth' import api from './api' import type { User } from './types' +import { resolveAuthMode } from './utils/authMode' const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/' @@ -18,7 +19,7 @@ const bootstrap = async () => { const authStore = useAuthStore() - const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' + const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) const isPublicInventoryRoute = window.location.pathname.startsWith(PUBLIC_INVENTORY_ROUTE_PREFIX) if (authMode === 'local') { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7382313..e59bde1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import { useAuthStore } from '@/stores/auth' import keycloak from '@/auth/keycloak' +import { resolveAuthMode } from '@/utils/authMode' const routes: RouteRecordRaw[] = [ { @@ -111,7 +112,7 @@ const router = createRouter({ router.beforeEach((to, _from, next) => { const authStore = useAuthStore() - const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' + const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) const isAuthenticated = authMode === 'local' ? authStore.isAuthenticated diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index b67be07..621eb2d 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import type { User } from '@/types' import keycloak from '@/auth/keycloak' import api from '@/api' +import { resolveAuthMode } from '@/utils/authMode' interface AuthState { user: User | null @@ -34,7 +35,7 @@ export const useAuthStore = defineStore('auth', { this.isAuthenticated = false localStorage.removeItem('token') try { - const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' + const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE) if (authMode === 'local') { await api.post('/auth/logout') } else if (keycloak.authenticated) { @@ -46,4 +47,3 @@ export const useAuthStore = defineStore('auth', { } } }) - diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dcb757c..4d7360b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -4,6 +4,15 @@ export type EquipmentType = string // 资产状态 export type EquipmentStatus = '可用' | '已借出' | '维护中' | '已损坏' | '待报废' | '已报废' +export interface BorrowAnomalyInfo { + state: 'none' | 'returnable' | 'borrow_record_missing' + can_handle?: boolean + canHandle?: boolean + message?: string | null + blocked_reason?: string | null + blockedReason?: string | null +} + // 编码生成方式 export type CodeGenerationMode = 'auto' | 'manual' @@ -140,6 +149,7 @@ export interface Equipment { unit_price?: number } | null current_borrow?: EquipmentCurrentBorrow | null + borrow_anomaly?: BorrowAnomalyInfo | null children?: Array<{ id: number equipment_code: string @@ -239,6 +249,9 @@ export interface InventoryScopeEntity { export interface InventoryTaskScopeSnapshot { scope_department: InventoryScopeEntity | null scope_asset_type: InventoryScopeEntity | null + scope_departments: InventoryScopeEntity[] + scope_asset_types: InventoryScopeEntity[] + scope_storage_locations: InventoryScopeEntity[] participants_count: number items_count: number unassigned_count: number @@ -288,6 +301,9 @@ export interface InventoryPreviewItemSample { export interface InventoryTaskPreview { scope_department: InventoryScopeEntity | null scope_asset_type: InventoryScopeEntity | null + scope_departments: InventoryScopeEntity[] + scope_asset_types: InventoryScopeEntity[] + scope_storage_locations: InventoryScopeEntity[] participants_count: number items_count: number unassigned_count: number @@ -317,6 +333,10 @@ export interface InventoryTaskListItem { closed_at?: string | null created_at: string updated_at: string + scope_snapshot?: InventoryTaskScopeSnapshot | null + scope_departments?: InventoryScopeEntity[] + scope_asset_types?: InventoryScopeEntity[] + scope_storage_locations?: InventoryScopeEntity[] } export interface InventoryTaskParticipant { @@ -415,6 +435,10 @@ export interface PublicInventoryTaskInfo { unassigned_count: number created_at: string closed_at?: string | null + scope_snapshot?: InventoryTaskScopeSnapshot | null + scope_departments?: InventoryScopeEntity[] + scope_asset_types?: InventoryScopeEntity[] + scope_storage_locations?: InventoryScopeEntity[] } export interface PublicInventoryParticipantOption { @@ -433,6 +457,10 @@ export interface PublicInventorySessionTask { status: InventoryTaskStatus | string scope_department_name?: string | null scope_asset_type_name?: string | null + scope_snapshot?: InventoryTaskScopeSnapshot | null + scope_departments?: InventoryScopeEntity[] + scope_asset_types?: InventoryScopeEntity[] + scope_storage_locations?: InventoryScopeEntity[] completed_count: number exception_count: number items_count: number @@ -595,6 +623,7 @@ export interface HrSyncStatus { complete: boolean missingFields: string[] scheduleTimes: string[] + scheduleTimezone?: string } export interface HrSyncResult { @@ -657,6 +686,20 @@ export interface HrSyncChangeItem { created_at: string } +export interface HrSyncBorrowedAssetPreview { + equipmentCode: string + equipmentName: string + equipmentId?: number | null + borrowRecordId?: number | null + personId?: number | null +} + +export interface HrSyncPersonAfterSnapshot { + displayName?: string | null + assets?: HrSyncBorrowedAssetPreview[] + [key: string]: any +} + export interface HrSyncJobDetail extends HrSyncJobHistoryItem { changes: HrSyncChangeItem[] } @@ -775,6 +818,11 @@ export interface PrintJobPayload { [key: string]: unknown } +export interface AssetLabelRenderedImage { + equipment_id: number + image_data_url: string +} + export interface PrintJob { id: number job_type: PrintJobType | string @@ -806,6 +854,7 @@ export interface PrintJobListData extends PaginatedResponse {} export interface CreateAssetLabelPrintJobPayload { equipment_ids: number[] + rendered_labels: AssetLabelRenderedImage[] } export interface CreateAssetLabelPrintJobResult { diff --git a/frontend/src/utils/authMode.ts b/frontend/src/utils/authMode.ts new file mode 100644 index 0000000..fe61395 --- /dev/null +++ b/frontend/src/utils/authMode.ts @@ -0,0 +1,11 @@ +export type AuthMode = 'local' | 'keycloak' + +export const resolveAuthMode = (rawMode?: string | null): AuthMode => { + return rawMode === 'keycloak' ? 'keycloak' : 'local' +} + +export const resolveApiBaseUrl = (rawBaseUrl?: string | null): string => { + const normalizedBaseUrl = rawBaseUrl?.trim() + + return normalizedBaseUrl || '/api' +} diff --git a/frontend/src/utils/borrowAnomaly.ts b/frontend/src/utils/borrowAnomaly.ts new file mode 100644 index 0000000..9f75af9 --- /dev/null +++ b/frontend/src/utils/borrowAnomaly.ts @@ -0,0 +1,15 @@ +export interface BorrowAnomalyLike { + state?: string | null + can_handle?: boolean | null + canHandle?: boolean | null +} + +export interface EquipmentWithBorrowAnomaly { + borrow_anomaly?: BorrowAnomalyLike | null +} + +export const normalizeBorrowAnomalyCanHandle = (borrowAnomaly?: BorrowAnomalyLike | null) => + Boolean(borrowAnomaly?.canHandle ?? borrowAnomaly?.can_handle) + +export const canHandleBorrowAnomaly = (equipment?: EquipmentWithBorrowAnomaly | null) => + equipment?.borrow_anomaly?.state === 'borrow_record_missing' && normalizeBorrowAnomalyCanHandle(equipment.borrow_anomaly) diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index eb131b4..65800ce 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -85,6 +85,7 @@ import keycloak from '@/auth/keycloak' import api from '@/api' import type { User } from '@/types' import { useSiteStore } from '@/stores/site' +import { resolveAuthMode } from '@/utils/authMode' const router = useRouter() const authStore = useAuthStore() @@ -94,7 +95,7 @@ const password = ref('') const errorText = ref('') const siteStore = useSiteStore() -const authMode = (import.meta.env as any).VITE_AUTH_MODE || 'keycloak' +const authMode = resolveAuthMode((import.meta.env as any).VITE_AUTH_MODE) const isLocalAuth = computed(() => authMode === 'local') const siteName = computed(() => siteStore.siteName || '固定资产管理系统') diff --git a/frontend/src/views/borrow/BorrowScan.vue b/frontend/src/views/borrow/BorrowScan.vue index 0b5d9b1..8fd6354 100644 --- a/frontend/src/views/borrow/BorrowScan.vue +++ b/frontend/src/views/borrow/BorrowScan.vue @@ -45,21 +45,69 @@

手动输入

- +
-
+

支持搜索编码 / 名称 / 型号 / 地址,只显示当前可借资产。

+
+ +
+
+
+

匹配结果

+

点一条即可带入,不用再手抄资产编码。

+
+

+ {{ manualSearchLoading ? '搜索中...' : `共 ${manualSearchResults.length} 条` }} +

+
+ +
+ +
+ +

+ 正在搜索可借资产... +

+

+ 没有匹配到可借资产。若你已经知道准确编码,也可以直接点“按编码读取”。 +

@@ -123,25 +171,10 @@

-
+

- 核对无误后再出库 + 资产信息已带入,确认无误后可直接选择使用者并出库。

-
- - - 已确认 - -
@@ -165,42 +198,45 @@ v-model.trim="personKeyword" type="text" class="input" - placeholder="输入花名 / 姓名 / 证件号进行搜索" + placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索" + @keyup.enter="handlePersonKeywordSubmit" /> - -
-
- -

+ + +

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

+

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

@@ -216,28 +252,6 @@

-
-
-
-
- 花名: - {{ selectedPerson.nickname }} -
-
- 姓名: - {{ selectedPerson.real_name }} -
-
- 部门: - {{ selectedPerson.department }} -
-
- 电话: - {{ selectedPerson.phone }} -
-
-
-
@@ -304,7 +318,7 @@ + + diff --git a/print-agent/src/main.js b/print-agent/src/main.js new file mode 100644 index 0000000..7332854 --- /dev/null +++ b/print-agent/src/main.js @@ -0,0 +1,275 @@ +import path from 'node:path' +import os from 'node:os' +import { fileURLToPath } from 'node:url' +import { + app, + BrowserWindow, + ipcMain, + Menu, + Tray, + nativeImage, + shell +} from 'electron' + +import { createConfigStore } from './config/storage.js' +import { + loadDriverResources, + resolveDriverDownloadUrl +} from './services/driverManifest.js' +import { createPrintAgentClient } from './services/apiClient.js' +import { createJobProcessor } from './services/jobProcessor.js' +import { createAgentRuntime } from './services/agentRuntime.js' +import { + buildAssetLabelPrintHtml, + buildTestLabelPrintHtml +} from './services/labelDocument.js' +import { printHtmlSilently } from './services/electronPrinter.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const agentRoot = path.resolve(__dirname, '..') + +let mainWindow = null +let tray = null +let runtime = null +let store = null +let isQuitting = false +let shouldShowMainWindow = false + +function createTrayIcon() { + return nativeImage.createFromDataURL( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAK0lEQVR4AWP4//8/AyUYTFhYWPjPwMBgkJGR4T8GBgYmhjEwMDAwQAEA8A4Qbw6X8UIAAAAASUVORK5CYII=' + ) +} + +function sendStateToRenderer(state) { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('agent:state', state) + } +} + +async function getPrinters() { + if (!mainWindow || mainWindow.isDestroyed()) { + return [] + } + + try { + const printers = await mainWindow.webContents.getPrintersAsync() + return printers.map((printer) => ({ + name: printer.name, + description: printer.description || '', + status: printer.status ?? 0, + isDefault: Boolean(printer.isDefault) + })) + } catch (error) { + return [ + { + name: '', + description: `打印机列表读取失败:${error.message}`, + status: -1, + isDefault: false + } + ] + } +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1080, + height: 760, + minWidth: 960, + minHeight: 680, + show: false, + autoHideMenuBar: true, + backgroundColor: '#0b0d12', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + sandbox: false + } + }) + + mainWindow.loadFile(path.join(__dirname, 'index.html')) + + mainWindow.on('ready-to-show', () => { + if (shouldShowMainWindow) { + mainWindow.show() + } + }) + + mainWindow.on('close', (event) => { + if (!isQuitting) { + event.preventDefault() + mainWindow.hide() + } + }) +} + +function createTray() { + tray = new Tray(createTrayIcon()) + tray.setToolTip('固定资产打印助手') + tray.setContextMenu( + Menu.buildFromTemplate([ + { + label: '打开打印助手', + click: () => mainWindow?.show() + }, + { + label: '退出', + click: () => { + isQuitting = true + runtime?.stop() + app.quit() + } + } + ]) + ) + tray.on('double-click', () => mainWindow?.show()) +} + +async function getDriverResourcesForUi(config) { + const resources = await loadDriverResources(agentRoot) + return { + ...resources, + companyArchive: { + ...resources.companyArchive, + downloadUrl: resolveDriverDownloadUrl( + config?.serverUrl || 'https://www.jsgdzc.cn', + resources.companyArchive + ) + } + } +} + +function registerIpcHandlers() { + ipcMain.handle('agent:bootstrap', async () => { + const config = await runtime.loadConfig() + const printers = await runtime.refreshPrinters() + const driverResources = await getDriverResourcesForUi(config) + return { + config, + printers, + driverResources, + runtimeState: runtime.getState() + } + }) + + ipcMain.handle('agent:save-config', async (_event, patch) => { + const config = await runtime.updateConfig(patch) + const driverResources = await getDriverResourcesForUi(config) + return { config, driverResources } + }) + + ipcMain.handle('agent:list-printers', async () => runtime.refreshPrinters()) + ipcMain.handle('agent:heartbeat-now', async () => runtime.heartbeatOnce()) + ipcMain.handle('agent:claim-now', async () => runtime.pollOnce()) + ipcMain.handle('agent:test-print', async () => runtime.runTestPrint()) + ipcMain.handle('agent:open-external', async (_event, url) => shell.openExternal(url)) + + ipcMain.handle('agent:pair', async (_event, payload) => { + const currentConfig = await runtime.loadConfig() + const client = createPrintAgentClient({ + serverUrl: payload.serverUrl || currentConfig.serverUrl + }) + + const result = await client.pair({ + pairCode: payload.pairCode, + terminalName: payload.terminalName, + machineName: payload.machineName || os.hostname(), + platform: payload.platform || process.platform + }) + + const config = await runtime.updateConfig({ + serverUrl: payload.serverUrl || currentConfig.serverUrl, + terminalName: payload.terminalName || currentConfig.terminalName, + pairingCode: '', + pairResult: result + }) + + return { + config, + pairResult: result + } + }) +} + +async function createRuntime() { + store = createConfigStore(path.join(app.getPath('userData'), 'data')) + const jobProcessor = createJobProcessor({ + async printAssetLabels({ items, printerName }) { + const html = await buildAssetLabelPrintHtml({ + items + }) + + await printHtmlSilently({ + html, + printerName, + jobName: '资产标签打印' + }) + + return { + printed_count: items.length + } + }, + async printTestLabel({ printerName }) { + const html = await buildTestLabelPrintHtml({ + printerName + }) + + await printHtmlSilently({ + html, + printerName, + jobName: '测试标签打印' + }) + } + }) + + runtime = createAgentRuntime({ + store, + clientFactory: (options) => createPrintAgentClient(options), + getPrinters, + jobProcessor, + appVersion: app.getVersion(), + onStateChange: sendStateToRenderer + }) + + await runtime.start() + + const config = runtime.getState().config + shouldShowMainWindow = !config?.deviceToken + if (shouldShowMainWindow) { + mainWindow?.show() + } else { + mainWindow?.hide() + } +} + +app.setAppUserModelId('com.jsgdzc.printagent') +if (process.platform === 'win32') { + app.setLoginItemSettings({ + openAtLogin: true, + openAsHidden: true + }) +} +app.on('before-quit', () => { + isQuitting = true +}) + +app.whenReady().then(async () => { + createWindow() + createTray() + registerIpcHandlers() + await createRuntime() +}) + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } else { + mainWindow?.show() + } +}) + +app.on('window-all-closed', (event) => { + event.preventDefault() +}) diff --git a/print-agent/src/preload.js b/print-agent/src/preload.js new file mode 100644 index 0000000..29f86d7 --- /dev/null +++ b/print-agent/src/preload.js @@ -0,0 +1,17 @@ +import { contextBridge, ipcRenderer } from 'electron' + +contextBridge.exposeInMainWorld('printAgent', { + bootstrap: () => ipcRenderer.invoke('agent:bootstrap'), + saveConfig: (patch) => ipcRenderer.invoke('agent:save-config', patch), + pair: (payload) => ipcRenderer.invoke('agent:pair', payload), + listPrinters: () => ipcRenderer.invoke('agent:list-printers'), + heartbeatNow: () => ipcRenderer.invoke('agent:heartbeat-now'), + claimNow: () => ipcRenderer.invoke('agent:claim-now'), + testPrint: () => ipcRenderer.invoke('agent:test-print'), + openExternal: (url) => ipcRenderer.invoke('agent:open-external', url), + onStateChange: (callback) => { + const listener = (_event, state) => callback(state) + ipcRenderer.on('agent:state', listener) + return () => ipcRenderer.removeListener('agent:state', listener) + } +}) diff --git a/print-agent/src/renderer.js b/print-agent/src/renderer.js new file mode 100644 index 0000000..e51ee6c --- /dev/null +++ b/print-agent/src/renderer.js @@ -0,0 +1,209 @@ +const ui = { + config: null, + driverResources: null, + runtimeState: null, + printers: [] +} + +function $(id) { + return document.getElementById(id) +} + +function setFlashMessage(message, isError = false) { + const el = $('flash-message') + el.textContent = message + el.style.color = isError ? 'var(--danger)' : 'var(--muted)' +} + +function renderPrinterOptions() { + const select = $('printer-select') + select.innerHTML = '' + + const printers = ui.printers || [] + if (!printers.length) { + const option = document.createElement('option') + option.value = '' + option.textContent = '暂未识别到打印机' + select.append(option) + return + } + + printers.forEach((printer) => { + const option = document.createElement('option') + option.value = printer.name + option.textContent = printer.name || '未命名打印机' + if (printer.name === ui.config?.selectedPrinterName) { + option.selected = true + } + select.append(option) + }) +} + +function renderDriverResources() { + const resources = ui.driverResources + if (!resources) { + return + } + + $('official-driver-title').textContent = + resources.officialLatest.title || '芯烨官方最新版驱动' + $('official-driver-url').textContent = resources.officialLatest.sourceUrl || '-' + $('archive-driver-version').textContent = `版本 ${resources.companyArchive.version || '-'}` + $('archive-driver-path').textContent = + resources.companyArchive.downloadUrl || resources.companyArchive.relativePath || '-' +} + +function renderRuntimeState() { + const runtimeState = ui.runtimeState || {} + const config = ui.config || {} + + $('runtime-chip').textContent = config.deviceToken ? '已配对' : '未配对' + $('terminal-id').textContent = config.terminalId ?? '-' + $('token-status').textContent = config.deviceToken ? '已保存' : '未配对' + $('current-version').textContent = runtimeState.appVersion || '-' + $('latest-version').textContent = runtimeState.latestReleaseVersion || '-' + $('update-status').textContent = runtimeState.updateStatusMessage || '-' + $('last-heartbeat').textContent = runtimeState.lastHeartbeatAt || '-' + $('last-claim').textContent = runtimeState.lastClaimAt || '-' + $('last-job-result').textContent = runtimeState.lastJobResult || '-' + $('last-error').textContent = runtimeState.lastError || '-' + $('log-output').textContent = (runtimeState.logLines || []).join('\n') +} + +function renderConfig() { + const config = ui.config || {} + $('server-url').value = config.serverUrl || '' + $('terminal-name').value = config.terminalName || '' + $('pair-code').value = config.pairingCode || '' + renderPrinterOptions() + renderDriverResources() + renderRuntimeState() +} + +async function saveConfigPatch(patch) { + const result = await window.printAgent.saveConfig(patch) + ui.config = result.config + ui.driverResources = result.driverResources + renderConfig() +} + +function currentFormValues() { + return { + serverUrl: $('server-url').value.trim(), + terminalName: $('terminal-name').value.trim(), + pairCode: $('pair-code').value.trim(), + selectedPrinterName: $('printer-select').value + } +} + +async function bootstrap() { + const result = await window.printAgent.bootstrap() + ui.config = result.config + ui.printers = result.printers + ui.driverResources = result.driverResources + ui.runtimeState = result.runtimeState + renderConfig() +} + +function bindEvents() { + $('pair-form').addEventListener('submit', async (event) => { + event.preventDefault() + const values = currentFormValues() + + try { + const result = await window.printAgent.pair(values) + ui.config = result.config + $('pair-code').value = '' + renderConfig() + setFlashMessage( + `配对成功,终端 ID:${result.pairResult.terminalId || result.pairResult.terminal_id}` + ) + } catch (error) { + setFlashMessage(`配对失败:${error.message}`, true) + } + }) + + $('save-config-button').addEventListener('click', async () => { + try { + await saveConfigPatch(currentFormValues()) + setFlashMessage('本地配置已保存。') + } catch (error) { + setFlashMessage(`保存失败:${error.message}`, true) + } + }) + + $('save-printer-button').addEventListener('click', async () => { + try { + await saveConfigPatch({ + selectedPrinterName: $('printer-select').value + }) + setFlashMessage('打印机选择已保存。') + } catch (error) { + setFlashMessage(`保存打印机失败:${error.message}`, true) + } + }) + + $('refresh-printers-button').addEventListener('click', async () => { + try { + ui.printers = await window.printAgent.listPrinters() + renderPrinterOptions() + setFlashMessage('打印机列表已刷新。') + } catch (error) { + setFlashMessage(`刷新失败:${error.message}`, true) + } + }) + + $('test-print-button').addEventListener('click', async () => { + try { + await saveConfigPatch({ + selectedPrinterName: $('printer-select').value + }) + const result = await window.printAgent.testPrint() + setFlashMessage(result.message) + } catch (error) { + setFlashMessage(`测试打印失败:${error.message}`, true) + } + }) + + $('heartbeat-button').addEventListener('click', async () => { + try { + await window.printAgent.heartbeatNow() + setFlashMessage('心跳已发送。') + } catch (error) { + setFlashMessage(`心跳失败:${error.message}`, true) + } + }) + + $('claim-button').addEventListener('click', async () => { + try { + const result = await window.printAgent.claimNow() + setFlashMessage(result ? '已完成一次远程标签处理。' : '当前没有可领取任务。') + } catch (error) { + setFlashMessage(`领取任务失败:${error.message}`, true) + } + }) + + $('open-official-driver-button').addEventListener('click', async () => { + const url = ui.driverResources?.officialLatest?.sourceUrl + if (url) { + await window.printAgent.openExternal(url) + } + }) + + $('open-archive-driver-button').addEventListener('click', async () => { + const url = ui.driverResources?.companyArchive?.downloadUrl + if (url) { + await window.printAgent.openExternal(url) + } + }) + + window.printAgent.onStateChange((runtimeState) => { + ui.runtimeState = runtimeState + renderRuntimeState() + }) +} + +bindEvents() +bootstrap().catch((error) => { + setFlashMessage(`初始化失败:${error.message}`, true) +}) diff --git a/print-agent/src/services/agentRuntime.js b/print-agent/src/services/agentRuntime.js new file mode 100644 index 0000000..e6dedcf --- /dev/null +++ b/print-agent/src/services/agentRuntime.js @@ -0,0 +1,274 @@ +export function createAgentRuntime({ + store, + clientFactory, + getPrinters, + jobProcessor, + appVersion = '', + setIntervalImpl = globalThis.setInterval, + clearIntervalImpl = globalThis.clearInterval, + onStateChange = () => {} +}) { + const state = { + running: false, + config: null, + printers: [], + appVersion, + latestReleaseVersion: '', + updateStatusMessage: '', + lastHeartbeatAt: '', + lastClaimAt: '', + lastJobResult: '', + lastError: '', + logLines: [] + } + + let heartbeatTimer = null + let pollTimer = null + + function pushLog(message) { + const line = `${new Date().toLocaleString('zh-CN', { hour12: false })} ${message}` + state.logLines = [line, ...state.logLines].slice(0, 40) + onStateChange({ ...state }) + } + + async function loadConfig() { + state.config = await store.load() + onStateChange({ ...state }) + return state.config + } + + async function refreshPrinters() { + state.printers = await getPrinters() + onStateChange({ ...state }) + return state.printers + } + + function buildLabelProfile() { + return { + paper_size: '60x40mm', + width_mm: 60, + height_mm: 40, + orientation: 'landscape', + scale_mode: 'fit' + } + } + + function getClient() { + return clientFactory({ + serverUrl: state.config?.serverUrl, + getDeviceToken: () => state.config?.deviceToken || '' + }) + } + + async function checkForUpdates() { + if (!state.config?.serverUrl) { + state.updateStatusMessage = state.appVersion + ? `当前版本 ${state.appVersion}` + : '未配置版本信息' + onStateChange({ ...state }) + return null + } + + try { + const client = getClient() + const releaseInfo = await client.getPrintAgentReleases() + const latestVersion = + releaseInfo?.latest_version || + (Array.isArray(releaseInfo?.releases) ? releaseInfo.releases[0]?.version : '') || + '' + + state.latestReleaseVersion = latestVersion + state.updateStatusMessage = + latestVersion && latestVersion !== state.appVersion + ? `发现新版本 ${latestVersion},可从后台下载更新` + : state.appVersion + ? `当前已是最新版本 ${state.appVersion}` + : latestVersion + ? `服务器当前版本 ${latestVersion}` + : '暂未读取到版本信息' + onStateChange({ ...state }) + return releaseInfo + } catch (error) { + state.updateStatusMessage = '版本检查失败,请稍后重试' + onStateChange({ ...state }) + return null + } + } + + async function heartbeatOnce() { + if (!state.config) { + await loadConfig() + } + + if (!state.config?.deviceToken || !state.config?.terminalId) { + return { + skipped: true, + message: '尚未完成配对' + } + } + + const printers = await refreshPrinters() + const client = getClient() + const payload = { + terminalId: state.config?.terminalId, + terminalName: state.config?.terminalName, + selectedPrinterName: state.config?.selectedPrinterName, + printerSnapshot: printers, + labelProfile: buildLabelProfile(), + ready: + Boolean(state.config?.deviceToken) && Boolean(state.config?.selectedPrinterName) + } + + try { + const response = await client.sendHeartbeat(payload) + state.lastHeartbeatAt = new Date().toISOString() + state.lastError = '' + pushLog(`心跳已发送:${response.message || '服务器已接收'}`) + return response + } catch (error) { + state.lastError = error.message + pushLog(`心跳失败:${error.message}`) + throw error + } + } + + async function pollOnce() { + if (!state.config?.deviceToken || !state.config?.terminalId) { + return null + } + + const client = getClient() + + try { + const claimResult = await client.claimNextJob({ + terminalId: state.config.terminalId + }) + state.lastClaimAt = new Date().toISOString() + + if (!claimResult?.job) { + return null + } + + const job = claimResult.job + await client.reportJobStatus(job.id, { + status: 'printing' + }) + + const processingResult = await jobProcessor.process(job, { + selectedPrinterName: state.config?.selectedPrinterName + }) + state.lastJobResult = processingResult.message || processingResult.errorMessage || '' + + await client.reportJobStatus(job.id, { + status: processingResult.nextStatus, + errorMessage: processingResult.errorMessage || null + }) + + pushLog( + processingResult.ok + ? `任务 ${job.id} 已发送到 ${state.config?.selectedPrinterName || '当前打印机'}。` + : `任务 ${job.id} 打印失败:${processingResult.errorMessage}` + ) + + return processingResult + } catch (error) { + state.lastError = error.message + pushLog(`领取任务失败:${error.message}`) + throw error + } + } + + async function runTestPrint() { + if (!state.config) { + await loadConfig() + } + + const printers = await refreshPrinters() + const result = await jobProcessor.runTestPrint({ + selectedPrinterName: state.config?.selectedPrinterName + }) + + if (state.config?.deviceToken && state.config?.terminalId) { + try { + const client = getClient() + await client.requestTestPrint({ + selectedPrinterName: state.config?.selectedPrinterName, + printerSnapshot: printers, + labelProfile: buildLabelProfile(), + success: result.ok, + errorMessage: result.ok ? null : result.message + }) + } catch (error) { + state.lastError = error.message + pushLog(`测试打印结果回写失败:${error.message}`) + } + } + + state.lastJobResult = result.message + pushLog(result.message) + return result + } + + async function updateConfig(patch) { + state.config = await store.save(patch) + onStateChange({ ...state }) + return state.config + } + + async function start() { + if (heartbeatTimer) { + clearIntervalImpl(heartbeatTimer) + heartbeatTimer = null + } + + if (pollTimer) { + clearIntervalImpl(pollTimer) + pollTimer = null + } + + const config = await loadConfig() + await refreshPrinters() + await checkForUpdates() + state.running = true + onStateChange({ ...state }) + + await heartbeatOnce().catch(() => {}) + await pollOnce().catch(() => {}) + + heartbeatTimer = setIntervalImpl(() => { + heartbeatOnce().catch(() => {}) + }, config.heartbeatIntervalMs) + + pollTimer = setIntervalImpl(() => { + pollOnce().catch(() => {}) + }, config.pollIntervalMs) + } + + function stop() { + state.running = false + if (heartbeatTimer) { + clearIntervalImpl(heartbeatTimer) + heartbeatTimer = null + } + if (pollTimer) { + clearIntervalImpl(pollTimer) + pollTimer = null + } + onStateChange({ ...state }) + } + + return { + getState() { + return { ...state } + }, + loadConfig, + refreshPrinters, + checkForUpdates, + heartbeatOnce, + pollOnce, + runTestPrint, + updateConfig, + start, + stop + } +} diff --git a/print-agent/src/services/apiClient.js b/print-agent/src/services/apiClient.js new file mode 100644 index 0000000..aaca41d --- /dev/null +++ b/print-agent/src/services/apiClient.js @@ -0,0 +1,166 @@ +function trimBaseUrl(serverUrl) { + return String(serverUrl || '') + .replace(/\/+$/, '') + .replace(/\/api$/i, '') +} + +function unwrapEnvelope(payload) { + if (payload && typeof payload === 'object' && 'data' in payload && 'code' in payload) { + const data = payload.data + if (data && typeof data === 'object' && !Array.isArray(data)) { + return { + ...data, + message: data.message || payload.message || '' + } + } + + return data + } + + return payload +} + +async function parseResponse(response) { + let data = {} + + if (typeof response.text === 'function') { + const text = await response.text() + data = text ? JSON.parse(text) : {} + } else if (typeof response.json === 'function') { + data = (await response.json()) || {} + } + + if (!response.ok) { + const error = new Error(data.message || '打印助手请求失败') + error.status = response.status + error.payload = data + throw error + } + + return unwrapEnvelope(data) +} + +export function createPrintAgentClient({ + serverUrl, + getDeviceToken = () => '', + fetchImpl = globalThis.fetch +}) { + if (typeof fetchImpl !== 'function') { + throw new Error('当前运行环境不支持 fetch,请补充 fetch 实现') + } + + async function request(endpoint, { method = 'GET', body, auth = true } = {}) { + const headers = { + 'Content-Type': 'application/json' + } + + if (auth) { + const deviceToken = getDeviceToken() + if (deviceToken) { + headers.Authorization = `Bearer ${deviceToken}` + } + } + + const response = await fetchImpl(`${trimBaseUrl(serverUrl)}${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined + }) + + return parseResponse(response) + } + + const toSnakeCaseBody = (body = {}) => { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return body + } + + const mapped = { ...body } + + if ('pairCode' in mapped) { + mapped.code = mapped.pairCode + delete mapped.pairCode + } + + if ('terminalName' in mapped) { + mapped.terminal_name = mapped.terminalName + delete mapped.terminalName + } + + if ('machineName' in mapped) { + mapped.machine_name = mapped.machineName + delete mapped.machineName + } + + if ('selectedPrinterName' in mapped) { + mapped.selected_printer_name = mapped.selectedPrinterName + delete mapped.selectedPrinterName + } + + if ('printerSnapshot' in mapped) { + mapped.printer_snapshot = mapped.printerSnapshot + delete mapped.printerSnapshot + } + + if ('labelProfile' in mapped) { + mapped.label_profile = mapped.labelProfile + delete mapped.labelProfile + } + + if ('errorMessage' in mapped) { + mapped.error_message = mapped.errorMessage + delete mapped.errorMessage + } + + if ('retryable' in mapped) { + mapped.retryable = Boolean(mapped.retryable) + } + + if ('terminalId' in mapped) { + mapped.terminal_id = mapped.terminalId + delete mapped.terminalId + } + + return mapped + } + + return { + pair(payload) { + return request('/api/print-agent/pair', { + method: 'POST', + auth: false, + body: toSnakeCaseBody(payload) + }) + }, + sendHeartbeat(payload) { + return request('/api/print-agent/heartbeat', { + method: 'POST', + body: toSnakeCaseBody(payload) + }) + }, + claimNextJob(payload) { + return request('/api/print-agent/jobs/claim', { + method: 'POST', + body: toSnakeCaseBody(payload) + }) + }, + reportJobStatus(jobId, payload) { + return request(`/api/print-agent/jobs/${jobId}/status`, { + method: 'POST', + body: toSnakeCaseBody(payload) + }) + }, + requestTestPrint(payload) { + return request('/api/print-agent/test-print', { + method: 'POST', + body: toSnakeCaseBody(payload) + }) + }, + getPrintAgentReleases() { + return request('/downloads/print-agent/releases', { + method: 'GET', + auth: false + }) + } + } +} diff --git a/print-agent/src/services/driverManifest.js b/print-agent/src/services/driverManifest.js new file mode 100644 index 0000000..5ce0783 --- /dev/null +++ b/print-agent/src/services/driverManifest.js @@ -0,0 +1,61 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +export const DEFAULT_DRIVER_RESOURCES = { + officialLatest: { + version: 'latest', + title: '芯烨官方最新版驱动', + sourceUrl: + 'https://www.x-printer.cn/1/windows%E5%B9%B3%E5%8F%B0%E4%B8%80%E9%94%AE%E5%AE%89%E8%A3%85%E7%89%88/', + checkedAt: '2026-03-25T00:00:00.000+08:00' + }, + companyArchive: { + version: '2026.01.28.01', + fileName: 'xprinter-driver-2026.01.28.01.exe', + relativePath: + 'downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe', + sourcePath: + 'D:\\Users\\54972\\Downloads\\芯烨windows一键安装版驱动2026.01.28.01.exe' + } +} + +export async function loadDriverResources(repoRoot) { + const manifestPaths = [ + path.join(repoRoot, 'resources', 'driver-resources.json'), + path.join(repoRoot, 'print-agent', 'resources', 'driver-resources.json') + ] + + for (const manifestPath of manifestPaths) { + try { + const content = await fs.readFile(manifestPath, 'utf8') + const manifest = JSON.parse(content) + return { + ...DEFAULT_DRIVER_RESOURCES, + ...manifest, + officialLatest: { + ...DEFAULT_DRIVER_RESOURCES.officialLatest, + ...manifest.officialLatest + }, + companyArchive: { + ...DEFAULT_DRIVER_RESOURCES.companyArchive, + ...manifest.companyArchive + } + } + } catch (error) { + if (error && error.code === 'ENOENT') { + continue + } + throw error + } + } + + return DEFAULT_DRIVER_RESOURCES +} + +export function resolveDriverDownloadUrl(serverUrl, manifest) { + const baseUrl = String(serverUrl || '') + .replace(/\/+$/, '') + .replace(/\/api$/i, '') + const relativePath = String(manifest.relativePath || '').replace(/^\/+/, '') + return `${baseUrl}/${relativePath}` +} diff --git a/print-agent/src/services/electronPrinter.js b/print-agent/src/services/electronPrinter.js new file mode 100644 index 0000000..7216cae --- /dev/null +++ b/print-agent/src/services/electronPrinter.js @@ -0,0 +1,101 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { BrowserWindow } from 'electron' + +const LABEL_PAGE_SIZE_MICRONS = { + width: 60000, + height: 40000 +} + +const waitForPrintableContent = async (win) => { + await win.webContents.executeJavaScript(` + new Promise((resolve) => { + const waitForImages = () => { + const images = Array.from(document.images || []) + Promise.all( + images.map((img) => + img.complete + ? Promise.resolve() + : new Promise((done) => { + img.addEventListener('load', done, { once: true }) + img.addEventListener('error', done, { once: true }) + }) + ) + ).then(() => setTimeout(resolve, 120)) + } + + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(waitForImages).catch(waitForImages) + } else { + waitForImages() + } + }) + `) +} + +export const printHtmlSilently = async ({ + html, + printerName, + jobName = '固定资产标签打印' +}) => { + const resolvedPrinterName = String(printerName ?? '').trim() + if (!resolvedPrinterName) { + throw new Error('尚未选择标签打印机,请先完成打印机配置。') + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gdzc-print-')) + const htmlPath = path.join(tempDir, 'print.html') + await fs.writeFile(htmlPath, html, 'utf8') + + const printWindow = new BrowserWindow({ + show: false, + width: 980, + height: 720, + backgroundColor: '#ffffff', + webPreferences: { + sandbox: false, + backgroundThrottling: false + } + }) + + try { + await printWindow.loadFile(htmlPath) + await waitForPrintableContent(printWindow) + + await new Promise((resolve, reject) => { + printWindow.webContents.print( + { + silent: true, + printBackground: true, + deviceName: resolvedPrinterName, + margins: { + marginType: 'none' + }, + pageSize: LABEL_PAGE_SIZE_MICRONS + }, + (success, failureReason) => { + if (success) { + resolve(true) + return + } + + reject( + new Error( + failureReason || `${jobName}失败,请检查打印机是否在线、驱动是否正常。` + ) + ) + } + ) + }) + } finally { + if (!printWindow.isDestroyed()) { + printWindow.destroy() + } + + await fs.rm(tempDir, { + recursive: true, + force: true + }) + } +} diff --git a/print-agent/src/services/jobProcessor.js b/print-agent/src/services/jobProcessor.js new file mode 100644 index 0000000..dec6027 --- /dev/null +++ b/print-agent/src/services/jobProcessor.js @@ -0,0 +1,121 @@ +const normalizePrinterName = (printerName) => String(printerName ?? '').trim() + +const parsePayload = (job) => { + if (job?.payload && typeof job.payload === 'object') { + return job.payload + } + + if (typeof job?.payload_json === 'string') { + try { + return JSON.parse(job.payload_json) + } catch { + return null + } + } + + if (job?.payload_json && typeof job.payload_json === 'object') { + return job.payload_json + } + + return null +} + +const getAssetLabelItems = (job) => { + const payload = parsePayload(job) + if (!payload || !Array.isArray(payload.items)) { + return null + } + return payload.items +} + +export function createTestPrintSuccessMessage(printerName) { + const resolvedPrinterName = normalizePrinterName(printerName) || '已选打印机' + return `测试标签已发送到 ${resolvedPrinterName},请确认是否按 60x40mm 正常出纸。` +} + +export function createJobProcessor({ + printAssetLabels = async () => { + throw new Error('尚未注入真实资产标签打印执行器') + }, + printTestLabel = async () => { + throw new Error('尚未注入真实测试标签打印执行器') + } +} = {}) { + return { + async process(job, { selectedPrinterName } = {}) { + if (job?.job_type === 'asset_label') { + const printerName = normalizePrinterName(selectedPrinterName) + if (!printerName) { + return { + ok: false, + nextStatus: 'failed', + errorMessage: '尚未选择标签打印机,请先在打印助手里完成打印机配置。' + } + } + + const items = getAssetLabelItems(job) + if (!items?.length) { + return { + ok: false, + nextStatus: 'failed', + errorMessage: '当前打印任务缺少可用的资产标签数据,已阻止实际出纸。' + } + } + + try { + const printResult = await printAssetLabels({ + job, + items, + printerName + }) + + const printedCount = + typeof printResult?.printed_count === 'number' ? printResult.printed_count : items.length + + return { + ok: true, + nextStatus: 'completed', + message: `已发送到 ${printerName} 打印,准备输出 ${printedCount} 张资产标签。` + } + } catch (error) { + return { + ok: false, + nextStatus: 'failed', + errorMessage: error instanceof Error ? error.message : '资产标签打印失败' + } + } + } + + return { + ok: true, + nextStatus: 'completed', + message: '当前任务类型未实现,已跳过。' + } + }, + async runTestPrint({ selectedPrinterName }) { + const printerName = normalizePrinterName(selectedPrinterName) + if (!printerName) { + return { + ok: false, + message: '尚未选择标签打印机,请先保存打印机后再做测试打印。' + } + } + + try { + await printTestLabel({ + printerName + }) + + return { + ok: true, + message: createTestPrintSuccessMessage(printerName) + } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : '测试打印失败' + } + } + } + } +} diff --git a/print-agent/src/services/labelDocument.js b/print-agent/src/services/labelDocument.js new file mode 100644 index 0000000..a4a0320 --- /dev/null +++ b/print-agent/src/services/labelDocument.js @@ -0,0 +1,428 @@ +import QRCode from 'qrcode' + +const DEFAULT_COMPANY_NAME = '常州简尚家具有限公司' +const DEFAULT_EQUIPMENT_TYPE = '未分类资产' +const DEFAULT_ORGANIZATION = '未分配组织' + +const escapeHtml = (value) => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const normalizeText = (value, fallback = '-') => { + const normalized = String(value ?? '') + .replace(/\s+/g, ' ') + .trim() + return normalized || fallback +} + +export const formatLabelDate = (value) => { + const raw = String(value ?? '').trim() + if (!raw) { + return '' + } + + const normalized = raw.replace(/\s+/g, ' ') + const matchedDate = + normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/) || + normalized.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})/) + + if (matchedDate) { + const [, year, month, day] = matchedDate + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}` + } + + return normalized +} + +export const splitCodeForDisplay = (code) => { + const normalized = normalizeText(code) + + if (normalized.length <= 14) { + return [normalized] + } + + const separatorIndexes = Array.from(normalized.matchAll(/[-/]/g)).map((match) => match.index ?? -1) + const middleIndex = normalized.length / 2 + + if (separatorIndexes.length > 0) { + let bestIndex = separatorIndexes[0] + let bestDistance = Math.abs(bestIndex - middleIndex) + + for (const index of separatorIndexes) { + const distance = Math.abs(index - middleIndex) + if (distance < bestDistance) { + bestIndex = index + bestDistance = distance + } + } + + const first = normalized.slice(0, bestIndex + 1).trim() + const second = normalized.slice(bestIndex + 1).trim() + if (first && second) { + return [first, second] + } + } + + const midpoint = Math.ceil(normalized.length / 2) + return [normalized.slice(0, midpoint), normalized.slice(midpoint)] +} + +const buildCardMarkup = async (item, companyName) => { + const qrDataUrl = await QRCode.toDataURL(normalizeText(item.equipment_code), { + width: 512, + margin: 1, + errorCorrectionLevel: 'H', + color: { + dark: '#000000', + light: '#FFFFFF' + } + }) + + const codeLines = splitCodeForDisplay(item.equipment_code).slice(0, 2) + const equipmentType = normalizeText(item.equipment_type, DEFAULT_EQUIPMENT_TYPE) + const purchaseDate = formatLabelDate(item.purchase_date) + const organizationName = normalizeText(item.department?.name, DEFAULT_ORGANIZATION) + + return ` +
+
+
${escapeHtml(companyName)}
+ +
+ +
+
+
+ ${escapeHtml(normalizeText(item.equipment_code))} +
+
+ +
+
${escapeHtml(equipmentType)}
+ +
+ ${codeLines + .map((line) => `
${escapeHtml(line)}
`) + .join('')} +
+ +
+ +
+
采购日期
+
${escapeHtml(purchaseDate || '-')}
+
+ +
+
所属组织
+
${escapeHtml(organizationName)}
+
+ +
+ + +
+
+
+
+ ` +} + +const buildDocumentShell = (pages) => ` + + + + + 固定资产标签打印 + + + + ${pages + .map( + (page) => ` + + ` + ) + .join('')} + + +` + +export const buildAssetLabelPrintHtml = async ({ + companyName = DEFAULT_COMPANY_NAME, + items +}) => { + if (!Array.isArray(items) || items.length === 0) { + throw new Error('没有可打印的资产标签数据') + } + + const pages = await Promise.all( + items.map((item) => buildCardMarkup(item, normalizeText(companyName, DEFAULT_COMPANY_NAME))) + ) + + return buildDocumentShell(pages) +} + +export const buildTestLabelPrintHtml = async ({ + companyName = DEFAULT_COMPANY_NAME, + printerName = '' +} = {}) => { + return buildAssetLabelPrintHtml({ + companyName, + items: [ + { + id: 0, + equipment_code: 'TEST-60X40', + equipment_name: '测试标签', + equipment_type: '测试标签', + purchase_date: new Date().toISOString(), + department: { + name: printerName ? `测试打印 · ${printerName}` : '测试打印' + } + } + ] + }) +} diff --git a/print-agent/src/styles.css b/print-agent/src/styles.css new file mode 100644 index 0000000..265b065 --- /dev/null +++ b/print-agent/src/styles.css @@ -0,0 +1,250 @@ +:root { + color-scheme: dark; + --bg: #0b0d12; + --panel: #121722; + --panel-2: #171d2a; + --border: rgba(255, 255, 255, 0.12); + --text: #f4f7fb; + --muted: #9ea6b6; + --accent: #87f5d1; + --warning: #f6ca68; + --danger: #ff8f8f; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.35); + --radius: 18px; + --radius-sm: 12px; + --font: "Segoe UI", "Microsoft YaHei UI", sans-serif; + --mono: "Cascadia Mono", "Consolas", monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top right, rgba(135, 245, 209, 0.16), transparent 28%), + linear-gradient(180deg, #0b0d12 0%, #101521 100%); + color: var(--text); + font-family: var(--font); +} + +button, +input, +select { + font: inherit; +} + +button { + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: var(--text); + padding: 0.8rem 1rem; + cursor: pointer; + transition: transform 150ms ease, border-color 150ms ease, background 150ms ease; +} + +button:hover { + transform: translateY(-1px); + border-color: rgba(135, 245, 209, 0.5); + background: rgba(135, 245, 209, 0.12); +} + +button.primary { + background: linear-gradient(135deg, #87f5d1 0%, #5ad5f6 100%); + color: #091016; + border-color: transparent; + font-weight: 700; +} + +input, +select { + width: 100%; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + color: var(--text); + padding: 0.9rem 1rem; +} + +input:focus, +select:focus, +button:focus { + outline: 2px solid rgba(135, 245, 209, 0.45); + outline-offset: 2px; +} + +.app-shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 32px 0 40px; +} + +.hero { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-start; + margin-bottom: 20px; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent); + font-size: 0.82rem; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.hero h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3rem); +} + +.hero-copy { + margin: 10px 0 0; + max-width: 680px; + color: var(--muted); + line-height: 1.6; +} + +.status-chip { + padding: 0.65rem 0.95rem; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + white-space: nowrap; +} + +.panel { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 22px; +} + +.warning-panel { + margin-bottom: 20px; + border-color: rgba(246, 202, 104, 0.35); + color: #fff4cf; +} + +.grid-two { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +.panel-head { + margin-bottom: 18px; +} + +.panel-head h2, +.driver-card h3 { + margin: 0; +} + +.panel-head p, +.footnote { + margin: 8px 0 0; + color: var(--muted); + line-height: 1.5; +} + +.field-stack { + display: grid; + gap: 14px; +} + +.field { + display: grid; + gap: 8px; +} + +.field span, +.detail-list span, +.driver-label { + color: var(--muted); + font-size: 0.92rem; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.detail-list { + display: grid; + gap: 12px; + margin-top: 18px; +} + +.detail-list div { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.driver-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--panel-2); +} + +.driver-card + .driver-card { + margin-top: 14px; +} + +.mono, +.log-output { + font-family: var(--mono); + word-break: break-all; +} + +.log-output { + margin: 0; + min-height: 180px; + max-height: 320px; + overflow: auto; + padding: 14px; + border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.34); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #dce4f5; + white-space: pre-wrap; +} + +@media (max-width: 920px) { + .app-shell { + width: min(100vw - 20px, 1180px); + padding-top: 18px; + } + + .hero, + .grid-two, + .driver-card { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + + .hero { + align-items: stretch; + } + + .button-row { + flex-direction: column; + } +} diff --git a/print-agent/tests/agent-runtime.test.mjs b/print-agent/tests/agent-runtime.test.mjs new file mode 100644 index 0000000..594d136 --- /dev/null +++ b/print-agent/tests/agent-runtime.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { createAgentRuntime } from '../src/services/agentRuntime.js' + +test('start 会立即发送一次心跳并尝试领取任务', async () => { + const calls = [] + const intervals = [] + + const runtime = createAgentRuntime({ + store: { + async load() { + return { + serverUrl: 'https://www.jsgdzc.cn/api', + deviceToken: 'device-token', + terminalId: 18, + terminalName: '行政标签机', + selectedPrinterName: 'Xprinter XP-420B', + heartbeatIntervalMs: 30000, + pollIntervalMs: 3000 + } + }, + async save(patch) { + return patch + } + }, + clientFactory: () => ({ + async getPrintAgentReleases() { + calls.push(['release-check']) + return { + latest_version: '0.1.0' + } + }, + async sendHeartbeat(payload) { + calls.push(['heartbeat', payload]) + return { + message: '心跳已接收' + } + }, + async claimNextJob() { + calls.push(['claim']) + return { + job: { + id: 31, + job_type: 'asset_label', + payload: { + items: [ + { + id: 1, + equipment_code: 'JS-PC-2101' + } + ] + } + } + } + }, + async reportJobStatus(jobId, payload) { + calls.push(['status', jobId, payload.status]) + return {} + } + }), + getPrinters: async () => [{ name: 'Xprinter XP-420B' }], + jobProcessor: { + async process(job, context) { + calls.push(['process', job.id, context.selectedPrinterName]) + return { + ok: true, + nextStatus: 'completed', + message: '已发送打印' + } + }, + async runTestPrint() { + return { + ok: true, + message: '测试标签已发送' + } + } + }, + setIntervalImpl(handler, timeoutMs) { + intervals.push(timeoutMs) + return { handler, timeoutMs } + }, + clearIntervalImpl() {}, + onStateChange() {} + }) + + await runtime.start() + + assert.deepEqual(intervals, [30000, 3000]) + assert.equal(calls[0][0], 'release-check') + assert.equal(calls[1][0], 'heartbeat') + assert.equal(calls[2][0], 'claim') + assert.deepEqual( + calls.filter((entry) => entry[0] === 'status').map((entry) => entry[2]), + ['printing', 'completed'] + ) + assert.deepEqual(calls.find((entry) => entry[0] === 'process'), [ + 'process', + 31, + 'Xprinter XP-420B' + ]) + + runtime.stop() +}) diff --git a/print-agent/tests/api-client.test.mjs b/print-agent/tests/api-client.test.mjs new file mode 100644 index 0000000..8adc003 --- /dev/null +++ b/print-agent/tests/api-client.test.mjs @@ -0,0 +1,115 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { createPrintAgentClient } from '../src/services/apiClient.js' + +test('createPrintAgentClient 会用配对码请求长期令牌', async () => { + const calls = [] + const client = createPrintAgentClient({ + serverUrl: 'https://asset.example.com/', + fetchImpl: async (url, options) => { + calls.push({ url, options }) + return { + ok: true, + async json() { + return { + code: 200, + message: '打印终端配对成功', + data: { + terminal_id: 12, + device_token: 'token-123' + } + } + } + } + } + }) + + const result = await client.pair({ + pairCode: 'ABCD-EFGH', + terminalName: '前台标签机', + machineName: 'DESKTOP-001', + platform: 'win32' + }) + + assert.equal(calls[0].url, 'https://asset.example.com/api/print-agent/pair') + assert.equal(calls[0].options.method, 'POST') + assert.match(calls[0].options.body, /"code":"ABCD-EFGH"/) + assert.match(calls[0].options.body, /"terminal_name":"前台标签机"/) + assert.match(calls[0].options.body, /"machine_name":"DESKTOP-001"/) + assert.equal(result.terminal_id, 12) + assert.equal(result.device_token, 'token-123') +}) + +test('已配对请求会自动带上终端令牌', async () => { + const calls = [] + const client = createPrintAgentClient({ + serverUrl: 'https://asset.example.com', + getDeviceToken: () => 'token-xyz', + fetchImpl: async (url, options) => { + calls.push({ url, options }) + return { + ok: true, + async json() { + return { + code: 200, + message: '领取成功', + data: { + job: { + id: 18, + status: 'claimed' + } + } + } + } + } + } + }) + + await client.claimNextJob({ + terminalId: 7 + }) + + assert.equal(calls[0].url, 'https://asset.example.com/api/print-agent/jobs/claim') + assert.equal(calls[0].options.headers.Authorization, 'Bearer token-xyz') + assert.match(calls[0].options.body, /"terminal_id":7/) +}) + +test('心跳会把字段转成后端要求的 snake_case', async () => { + const calls = [] + const client = createPrintAgentClient({ + serverUrl: 'https://asset.example.com', + getDeviceToken: () => 'token-xyz', + fetchImpl: async (url, options) => { + calls.push({ url, options }) + return { + ok: true, + async json() { + return { + code: 200, + message: '心跳已接收', + data: { + id: 9 + } + } + } + } + } + }) + + await client.sendHeartbeat({ + terminalName: '行政标签机', + machineName: 'DESKTOP-001', + selectedPrinterName: 'Xprinter XP-420B', + printerSnapshot: [{ name: 'Xprinter XP-420B' }], + labelProfile: { + paper_size: '60x40mm' + } + }) + + assert.equal(calls[0].url, 'https://asset.example.com/api/print-agent/heartbeat') + assert.match(calls[0].options.body, /"terminal_name":"行政标签机"/) + assert.match(calls[0].options.body, /"machine_name":"DESKTOP-001"/) + assert.match(calls[0].options.body, /"selected_printer_name":"Xprinter XP-420B"/) + assert.match(calls[0].options.body, /"printer_snapshot":\[\{"name":"Xprinter XP-420B"\}\]/) +}) diff --git a/print-agent/tests/config-store.test.mjs b/print-agent/tests/config-store.test.mjs new file mode 100644 index 0000000..769a349 --- /dev/null +++ b/print-agent/tests/config-store.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { + createConfigStore, + defaultAgentConfig +} from '../src/config/storage.js' + +test('defaultAgentConfig 返回完整的初始配置', () => { + const config = defaultAgentConfig() + + assert.equal(config.serverUrl, 'https://www.jsgdzc.cn') + assert.equal(config.pairingCode, '') + assert.equal(config.deviceToken, '') + assert.equal(config.terminalName, '') + assert.equal(config.selectedPrinterName, '') + assert.equal(config.pollIntervalMs, 3000) + assert.equal(config.heartbeatIntervalMs, 30000) + assert.deepEqual(config.driverHelp.companyArchive, { + version: '2026.01.28.01', + fileName: 'xprinter-driver-2026.01.28.01.exe', + relativePath: + 'downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe' + }) +}) + +test('createConfigStore 可以保存并重新读取配置', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'print-agent-config-')) + const store = createConfigStore(tempDir) + + const saved = await store.save({ + terminalName: '行政标签机', + serverUrl: 'https://asset.example.com', + selectedPrinterName: 'Xprinter XP-420B', + pairResult: { + terminalId: 8, + deviceToken: 'terminal-token', + pairedAt: '2026-03-25T09:15:00.000Z' + } + }) + + assert.equal(saved.terminalName, '行政标签机') + assert.equal(saved.serverUrl, 'https://asset.example.com') + assert.equal(saved.selectedPrinterName, 'Xprinter XP-420B') + assert.equal(saved.terminalId, 8) + assert.equal(saved.deviceToken, 'terminal-token') + assert.equal(saved.pairedAt, '2026-03-25T09:15:00.000Z') + + const loaded = await store.load() + assert.equal(loaded.terminalName, '行政标签机') + assert.equal(loaded.serverUrl, 'https://asset.example.com') + assert.equal(loaded.selectedPrinterName, 'Xprinter XP-420B') + assert.equal(loaded.terminalId, 8) + assert.equal(loaded.deviceToken, 'terminal-token') +}) diff --git a/print-agent/tests/driver-manifest.test.mjs b/print-agent/tests/driver-manifest.test.mjs new file mode 100644 index 0000000..a0734c7 --- /dev/null +++ b/print-agent/tests/driver-manifest.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' + +import { + loadDriverResources, + resolveDriverDownloadUrl +} from '../src/services/driverManifest.js' + +const repoRoot = path.resolve(import.meta.dirname, '..', '..') + +test('loadDriverResources 能读取官方与归档版驱动清单', async () => { + const resources = await loadDriverResources(repoRoot) + + assert.equal(resources.officialLatest.version, 'latest') + assert.match(resources.officialLatest.sourceUrl, /^https:\/\/www\.x-printer\.cn\//) + assert.equal(resources.companyArchive.version, '2026.01.28.01') + assert.equal( + resources.companyArchive.relativePath, + 'downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe' + ) +}) + +test('resolveDriverDownloadUrl 会把归档版相对路径转为服务器下载地址', async () => { + const resources = await loadDriverResources(repoRoot) + const url = resolveDriverDownloadUrl('https://www.jsgdzc.cn', resources.companyArchive) + + assert.equal( + url, + 'https://www.jsgdzc.cn/downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe' + ) +}) + +test('resolveDriverDownloadUrl 会自动去掉服务器地址末尾的 /api', async () => { + const resources = await loadDriverResources(repoRoot) + const url = resolveDriverDownloadUrl( + 'https://www.jsgdzc.cn/api', + resources.companyArchive + ) + + assert.equal( + url, + 'https://www.jsgdzc.cn/downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe' + ) +}) diff --git a/print-agent/tests/job-processor.test.mjs b/print-agent/tests/job-processor.test.mjs new file mode 100644 index 0000000..70a30c2 --- /dev/null +++ b/print-agent/tests/job-processor.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { createJobProcessor } from '../src/services/jobProcessor.js' + +test('正式资产标签任务会调用真实打印执行器并返回 completed', async () => { + const printedJobs = [] + const processor = createJobProcessor({ + async printAssetLabels(payload) { + printedJobs.push(payload) + return { + printed_count: payload.items.length + } + } + }) + + const result = await processor.process({ + id: 21, + job_type: 'asset_label', + payload: { + job_type: 'asset_label', + equipment_ids: [1], + items: [ + { + id: 1, + equipment_code: 'JS-PC-2101', + equipment_name: '办公主机', + equipment_type: '主机', + purchase_date: '2026-03-01T00:00:00.000Z', + department: { + name: '行政部' + } + } + ] + } + }, { + selectedPrinterName: 'Xprinter XP-420B' + }) + + assert.equal(result.ok, true) + assert.equal(result.nextStatus, 'completed') + assert.match(result.message, /Xprinter XP-420B/) + assert.equal(printedJobs.length, 1) + assert.equal(printedJobs[0].printerName, 'Xprinter XP-420B') + assert.equal(printedJobs[0].items[0].equipment_code, 'JS-PC-2101') +}) + +test('未选择打印机时会返回人话错误,不会继续打印', async () => { + const processor = createJobProcessor({ + async printAssetLabels() { + throw new Error('不应该进入真实打印') + } + }) + + const result = await processor.process({ + id: 22, + job_type: 'asset_label', + payload: { + job_type: 'asset_label', + equipment_ids: [1], + items: [ + { + id: 1, + equipment_code: 'JS-PC-2201', + equipment_name: '办公主机', + equipment_type: '主机' + } + ] + } + }) + + assert.equal(result.ok, false) + assert.equal(result.nextStatus, 'failed') + assert.match(result.errorMessage, /尚未选择标签打印机/) +}) + +test('测试打印会调用真实测试标签打印执行器', async () => { + const calls = [] + const processor = createJobProcessor({ + async printTestLabel(payload) { + calls.push(payload) + return { + printer_name: payload.printerName + } + } + }) + + const result = await processor.runTestPrint({ + selectedPrinterName: 'Xprinter XP-420B' + }) + + assert.equal(result.ok, true) + assert.match(result.message, /Xprinter XP-420B/) + assert.equal(calls.length, 1) + assert.equal(calls[0].printerName, 'Xprinter XP-420B') +}) diff --git a/print-agent/tests/label-document.test.mjs b/print-agent/tests/label-document.test.mjs new file mode 100644 index 0000000..cb265f3 --- /dev/null +++ b/print-agent/tests/label-document.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildAssetLabelPrintHtml, + formatLabelDate, + splitCodeForDisplay +} from '../src/services/labelDocument.js' + +test('splitCodeForDisplay 会优先按连接符拆分较长编码', () => { + const lines = splitCodeForDisplay('JS-PC-210188888') + + assert.deepEqual(lines, ['JS-PC-', '210188888']) +}) + +test('formatLabelDate 会统一输出 YYYY-MM-DD', () => { + assert.equal(formatLabelDate('2026/3/5 09:00:00'), '2026-03-05') + assert.equal(formatLabelDate('2026-03-25T00:00:00.000Z'), '2026-03-25') + assert.equal(formatLabelDate(''), '') +}) + +test('buildAssetLabelPrintHtml 会生成可直接静默打印的 60x40 标签文档', async () => { + const html = await buildAssetLabelPrintHtml({ + companyName: '常州简尚家具有限公司', + items: [ + { + id: 1, + equipment_code: 'JS-PC-2101', + equipment_name: '办公主机', + equipment_type: '主机', + purchase_date: '2026-03-01T00:00:00.000Z', + department: { + name: '行政部' + } + } + ] + }) + + assert.match(html, /@page\s*\{\s*size:\s*60mm 40mm;/) + assert.match(html, /常州简尚家具有限公司/) + assert.match(html, /JS-PC-2101/) + assert.match(html, /主机/) + assert.match(html, /2026-03-01/) + assert.match(html, /行政部/) + assert.match(html, /data:image\/png;base64,/) +}) diff --git a/scripts/backup-equipment.sh b/scripts/backup-equipment.sh index e983d6d..14f06a5 100644 --- a/scripts/backup-equipment.sh +++ b/scripts/backup-equipment.sh @@ -30,11 +30,18 @@ prune_old_backups() { exit 1 fi - mapfile -t existing_backups < <(find "${BACKUP_ROOT}" -mindepth 1 -maxdepth 1 -type d | sort) + mapfile -t existing_backups < <( + find "${BACKUP_ROOT}" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; \ + | awk '/^[0-9]{8}_[0-9]{6}$/' \ + | sort \ + | while IFS= read -r dir_name; do + printf '%s/%s\n' "${BACKUP_ROOT}" "${dir_name}" + done + ) local total="${#existing_backups[@]}" if [ "${total}" -le "${KEEP_BACKUPS}" ]; then - echo ">>> 当前备份数量 ${total},无需清理" + echo ">>> 当前标准备份数量 ${total},无需清理" return fi diff --git a/scripts/tests/backup-equipment-retention-test.sh b/scripts/tests/backup-equipment-retention-test.sh new file mode 100644 index 0000000..38bdbdb --- /dev/null +++ b/scripts/tests/backup-equipment-retention-test.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +TARGET_SCRIPT="${REPO_ROOT}/scripts/backup-equipment.sh" +TEMP_DIR="" + +assert_exists() { + local path="$1" + if [ ! -e "${path}" ]; then + echo "Expected path to exist: ${path}" >&2 + return 1 + fi +} + +main() { + TEMP_DIR="$(mktemp -d)" + trap 'rm -rf "${TEMP_DIR}"' EXIT + + local project_dir="${TEMP_DIR}/project" + local backup_root="${TEMP_DIR}/backups" + local fake_bin="${TEMP_DIR}/bin" + + mkdir -p "${project_dir}/frontend/public/branding" "${backup_root}" "${fake_bin}" + + printf 'version: "3"\n' > "${project_dir}/docker-compose.yml" + printf '#!/bin/bash\nexit 0\n' > "${project_dir}/deploy.sh" + printf 'FROM scratch\n' > "${project_dir}/Dockerfile" + printf 'brand\n' > "${project_dir}/frontend/public/branding/logo.txt" + + mkdir -p \ + "${backup_root}/source" \ + "${backup_root}/manual_hotfix_20260326_195455" \ + "${backup_root}/manual_hotfix_20260326_2035_authfix" \ + "${backup_root}/20260326_141543" \ + "${backup_root}/20260326_194741" + + cat > "${fake_bin}/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +command="${1:-}" +shift || true + +case "${command}" in + inspect) + exit 0 + ;; + exec) + if printf '%s\n' "$*" | grep -q "mysqldump"; then + printf 'fake-sql-dump' + else + printf '' + fi + ;; + *) + echo "Unexpected docker command: ${command} $*" >&2 + exit 1 + ;; +esac +EOF + chmod +x "${fake_bin}/docker" + + PATH="${fake_bin}:${PATH}" \ + PROJECT_DIR="${project_dir}" \ + BACKUP_ROOT="${backup_root}" \ + KEEP_BACKUPS=3 \ + DB_CONTAINER="equipment-mysql" \ + BACKEND_CONTAINER="equipment-backend" \ + DB_NAME="equipment_management" \ + DB_USER="root" \ + DB_PASSWORD="secret" \ + UPLOADS_PATH="/app/uploads" \ + bash "${TARGET_SCRIPT}" >/dev/null + + mapfile -t timestamp_backups < <(find "${backup_root}" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | grep -E '^[0-9]{8}_[0-9]{6}$' | sort) + if [ "${#timestamp_backups[@]}" -eq 0 ]; then + echo "Expected at least one timestamp backup to remain after pruning" >&2 + return 1 + fi + local latest_index=$(( ${#timestamp_backups[@]} - 1 )) + local latest_backup="${backup_root}/${timestamp_backups[${latest_index}]}" + + assert_exists "${latest_backup}" + assert_exists "${backup_root}/20260326_141543" + assert_exists "${backup_root}/20260326_194741" + assert_exists "${backup_root}/source" + assert_exists "${backup_root}/manual_hotfix_20260326_195455" + assert_exists "${backup_root}/manual_hotfix_20260326_2035_authfix" +} + +main "$@"