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 @@
+
+
+
+
+
+
Borrow Exception
+
异常借出处理
+
+ 这台资产当前标记为已借出,但系统里没有借用单。请按真实情况处理,不要猜。
+
+
+
+
+
+
+
+
+
+
+
{{ asset.equipmentName }}
+ 借用单缺失
+
+
+ {{ asset.equipmentCode }}
+ {{ asset.equipmentType || '未分类' }}
+ {{ asset.equipmentModel || '未填写型号' }}
+ {{ asset.storageLocationName || '未设置地址' }}
+
+
+
+ {{ asset.message || '请根据真实业务情况补处理' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ feedback.message }}
+
+
+
+
+
+ 适用于这台资产其实已经回来了,只是系统缺借用单,导致行政没法正常入库。
+
+
+
+
+
同步规则
+
库存与价值按现有系统规则重算
+
+
+
+
+
+
+
+
+
+
+
+
+ 补登记借出时,只能选择系统里当前启用中的人员。
+
+
+
+
+
支持花名、姓名、工号、部门和手机号搜索。
+
+ {{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }}
+
+
+
+
+
+
+
+
+ 当前没有匹配到使用者,请换个关键词再试。
+
+
+
+
+
+
+
+
+
+
+ 这里必须按真实借出日期手动选择,系统不会自动帮你猜。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
+
-
+
支持搜索编码 / 名称 / 型号 / 地址,只显示当前可借资产。
+
+
+
+
+
+
+
+
+
+
+ {{ item.equipment_name }}
+ · {{ item.equipment_code }}
+
+
+ {{ item.equipment_type || '未分类' }}
+ · {{ item.equipment_model }}
+ · {{ item.storageLocation.name }}
+
+
+
可借 {{ item.remaining_quantity }}
+
+
+
+
+
+ 正在搜索可借资产...
+
+
+ 没有匹配到可借资产。若你已经知道准确编码,也可以直接点“按编码读取”。
+
@@ -123,25 +171,10 @@
-
+
- 核对无误后再出库
+ 资产信息已带入,确认无误后可直接选择使用者并出库。
-
-
- 确认
-
-
- 已确认
-
-
@@ -165,42 +198,45 @@
v-model.trim="personKeyword"
type="text"
class="input"
- placeholder="输入花名 / 姓名 / 证件号进行搜索"
+ placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
+ @keyup.enter="handlePersonKeywordSubmit"
/>
-
-
-
-
-
-
{{ person.nickname }} · {{ person.real_name }}
-
- {{ person.department || '未填写部门' }}
- · {{ person.phone }}
-
+
+ 输入后会直接搜索人员,点选即可;如果只匹配到 1 人,按回车也能直接选中。
+
+
+
+
支持花名、姓名、工号、部门和手机号搜索。
+
+ {{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }}
+
+
+
+
+
+
+
{{ person.nickname }} · {{ person.real_name }}
+
+ {{ person.department || '未填写部门' }}
+ · {{ person.phone }}
+ · {{ person.id_number }}
+
+
+
已选中
- 已选中
-
-
-
+
+
+
+ 正在搜索使用者...
+
+
当前没有匹配到使用者,请换个关键词再试。
@@ -216,28 +252,6 @@
-
-
-
-
- 花名:
- {{ selectedPerson.nickname }}
-
-
- 姓名:
- {{ selectedPerson.real_name }}
-
-
- 部门:
- {{ selectedPerson.department }}
-
-
- 电话:
- {{ selectedPerson.phone }}
-
-
-
-
@@ -304,7 +318,7 @@
+