feat(资产与打印): 提交近期借还盘点与打印助手源码
- 提交借用单缺失异常处理、扫码借还搜索与站内消息快捷处理源码 - 提交盘点范围、多终端打印、组织同步与认证模式相关源码 - 提交 .NET/Node 打印助手及版本清单源码
This commit is contained in:
parent
c7a9324635
commit
0dc3c09315
550
backend/scripts/prepareSheet2SupplementDryRun.js
Normal file
550
backend/scripts/prepareSheet2SupplementDryRun.js
Normal file
@ -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)
|
||||||
|
})
|
||||||
@ -6,6 +6,13 @@ import {
|
|||||||
getDisplayEquipmentValue,
|
getDisplayEquipmentValue,
|
||||||
syncMountedChildrenState
|
syncMountedChildrenState
|
||||||
} from '../utils/assetValue'
|
} 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 parseNumber = (value: any): number | undefined => {
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
@ -18,6 +25,18 @@ const parseDate = (value?: any): Date | null => {
|
|||||||
return Number.isNaN(date.getTime()) ? null : date
|
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 = {
|
const equipmentInclude = {
|
||||||
storageLocation: {
|
storageLocation: {
|
||||||
select: {
|
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) => {
|
const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: string) => {
|
||||||
if (equipmentId) {
|
if (equipmentId) {
|
||||||
return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude })
|
return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude })
|
||||||
@ -48,6 +81,62 @@ const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: st
|
|||||||
return null
|
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) => {
|
const ensurePersonAvailable = async (personId?: number) => {
|
||||||
if (!personId) {
|
if (!personId) {
|
||||||
throw new Error('使用者ID不能为空')
|
throw new Error('使用者ID不能为空')
|
||||||
@ -59,6 +148,46 @@ const ensurePersonAvailable = async (personId?: number) => {
|
|||||||
return person
|
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<string, any>) => {
|
||||||
|
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) => {
|
const ensureEquipmentBorrowable = (equipment: any) => {
|
||||||
if (equipment.parent_id) {
|
if (equipment.parent_id) {
|
||||||
throw new Error('该子资产当前已挂载,请先卸载后再借用')
|
throw new Error('该子资产当前已挂载,请先卸载后再借用')
|
||||||
@ -134,6 +263,7 @@ const borrowEquipmentInTransaction = async (
|
|||||||
|
|
||||||
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
|
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
|
||||||
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
|
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
|
||||||
|
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
|
||||||
const values = calculateEquipmentValues({
|
const values = calculateEquipmentValues({
|
||||||
unitPrice: Number(equipment.unit_price),
|
unitPrice: Number(equipment.unit_price),
|
||||||
totalQuantity: Number(equipment.total_quantity),
|
totalQuantity: Number(equipment.total_quantity),
|
||||||
@ -151,7 +281,8 @@ const borrowEquipmentInTransaction = async (
|
|||||||
total_value: values.total_value,
|
total_value: values.total_value,
|
||||||
borrowed_value: values.borrowed_value,
|
borrowed_value: values.borrowed_value,
|
||||||
available_value: values.available_value,
|
available_value: values.available_value,
|
||||||
status: nextStatus
|
status: nextStatus,
|
||||||
|
...(departmentUpdateData || {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -246,7 +377,10 @@ const returnEquipmentInTransaction = async (
|
|||||||
equipment: any,
|
equipment: any,
|
||||||
borrowRecord: any,
|
borrowRecord: any,
|
||||||
returnCondition: string,
|
returnCondition: string,
|
||||||
notes?: string
|
notes?: string,
|
||||||
|
equipmentUpdateData?: {
|
||||||
|
department_id: number | null
|
||||||
|
} | null
|
||||||
) => {
|
) => {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const returnTime = new Date()
|
const returnTime = new Date()
|
||||||
@ -287,7 +421,8 @@ const returnEquipmentInTransaction = async (
|
|||||||
total_value: values.total_value,
|
total_value: values.total_value,
|
||||||
borrowed_value: values.borrowed_value,
|
borrowed_value: values.borrowed_value,
|
||||||
available_value: values.available_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) => ({
|
const mapBorrowRecord = (record: any) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
equipment_id: record.equipment_id,
|
equipment_id: record.equipment_id,
|
||||||
@ -320,7 +538,8 @@ const mapBorrowRecord = (record: any) => ({
|
|||||||
real_name: record.person.real_name,
|
real_name: record.person.real_name,
|
||||||
department: record.person.mainDepartment?.name || record.person.department,
|
department: record.person.mainDepartment?.name || record.person.department,
|
||||||
main_department: record.person.mainDepartment || null,
|
main_department: record.person.mainDepartment || null,
|
||||||
phone: record.person.phone
|
phone: record.person.phone,
|
||||||
|
status: record.person.status
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
id: record.person_id,
|
id: record.person_id,
|
||||||
@ -568,6 +787,13 @@ export const returnEquipment = async (req: Request, res: Response) => {
|
|||||||
confirm_children_diff
|
confirm_children_diff
|
||||||
} = req.body as Record<string, any>
|
} = req.body as Record<string, any>
|
||||||
|
|
||||||
|
let departmentUpdate
|
||||||
|
try {
|
||||||
|
departmentUpdate = await validateReturnDepartmentUpdate(req.body || {})
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message || '部门归属处理无效', 422)
|
||||||
|
}
|
||||||
|
|
||||||
const equipmentIdNum = parseNumber(equipment_id)
|
const equipmentIdNum = parseNumber(equipment_id)
|
||||||
const equipment = await getEquipmentByIdentifier(equipmentIdNum, equipment_code)
|
const equipment = await getEquipmentByIdentifier(equipmentIdNum, equipment_code)
|
||||||
|
|
||||||
@ -605,7 +831,13 @@ export const returnEquipment = async (req: Request, res: Response) => {
|
|||||||
.join(';')
|
.join(';')
|
||||||
: notes
|
: notes
|
||||||
|
|
||||||
await returnEquipmentInTransaction(equipment, borrowRecord, return_condition, finalNotes)
|
await returnEquipmentInTransaction(
|
||||||
|
equipment,
|
||||||
|
borrowRecord,
|
||||||
|
return_condition,
|
||||||
|
finalNotes,
|
||||||
|
departmentUpdate.equipmentUpdateData
|
||||||
|
)
|
||||||
|
|
||||||
successResponse(
|
successResponse(
|
||||||
res,
|
res,
|
||||||
@ -639,6 +871,13 @@ export const scanReturn = async (req: Request, res: Response) => {
|
|||||||
confirm_children_diff
|
confirm_children_diff
|
||||||
} = req.body as Record<string, any>
|
} = req.body as Record<string, any>
|
||||||
|
|
||||||
|
let departmentUpdate
|
||||||
|
try {
|
||||||
|
departmentUpdate = await validateReturnDepartmentUpdate(req.body || {})
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message || '部门归属处理无效', 422)
|
||||||
|
}
|
||||||
|
|
||||||
const equipment = await getEquipmentByIdentifier(undefined, equipment_code)
|
const equipment = await getEquipmentByIdentifier(undefined, equipment_code)
|
||||||
|
|
||||||
if (!equipment) {
|
if (!equipment) {
|
||||||
@ -679,7 +918,8 @@ export const scanReturn = async (req: Request, res: Response) => {
|
|||||||
equipment,
|
equipment,
|
||||||
borrowRecord,
|
borrowRecord,
|
||||||
return_condition,
|
return_condition,
|
||||||
finalNotes
|
finalNotes,
|
||||||
|
departmentUpdate.equipmentUpdateData
|
||||||
)
|
)
|
||||||
|
|
||||||
successResponse(
|
successResponse(
|
||||||
@ -834,6 +1074,15 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (borrowRecords.length === 0) {
|
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)
|
return errorResponse(res, '未找到对应的借用记录', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -944,3 +1193,241 @@ export const getBorrowRecordList = async (req: Request, res: Response) => {
|
|||||||
errorResponse(res, error.message || '获取借用记录列表失败')
|
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<string, any>
|
||||||
|
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 || '补登记借出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
sumBundleChildrenValue,
|
sumBundleChildrenValue,
|
||||||
syncMountedChildrenState
|
syncMountedChildrenState
|
||||||
} from '../utils/assetValue'
|
} from '../utils/assetValue'
|
||||||
|
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
|
||||||
|
import { getEquipmentDeleteBlockedReason } from '../utils/equipmentDeletion'
|
||||||
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
|
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
|
||||||
|
|
||||||
type EquipmentEntryMode = 'single' | 'parent' | 'child'
|
type EquipmentEntryMode = 'single' | 'parent' | 'child'
|
||||||
@ -163,7 +165,13 @@ const serializeEquipmentListItem = (equipment: any) => {
|
|||||||
return {
|
return {
|
||||||
...restEquipment,
|
...restEquipment,
|
||||||
children,
|
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,
|
children: true,
|
||||||
mountHistories: true,
|
mountHistories: true,
|
||||||
disposalRecords: 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)
|
return errorResponse(res, '资产不存在', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有未归还的借用记录
|
const blockedReason = getEquipmentDeleteBlockedReason({
|
||||||
const activeBorrowCount = await prisma.borrowRecord.count({
|
borrowRecordCount: equipment._count.borrow_records,
|
||||||
where: {
|
isMountedChild: Boolean(equipment.parent_id),
|
||||||
equipment_id: equipmentId,
|
childCount: equipment._count.children,
|
||||||
status: '已借出'
|
mountHistoryCount: equipment._count.mountHistories,
|
||||||
}
|
disposalRecordCount: equipment._count.disposalRecords,
|
||||||
|
maintenanceRecordCount: equipment._count.maintenanceRecords,
|
||||||
|
inventoryTaskItemCount: equipment._count.inventoryTaskItems
|
||||||
})
|
})
|
||||||
|
|
||||||
if (activeBorrowCount > 0) {
|
if (blockedReason) {
|
||||||
return errorResponse(res, '该资产存在未归还的借用记录,无法删除')
|
return errorResponse(res, blockedReason)
|
||||||
}
|
|
||||||
|
|
||||||
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, '该资产已存在维修、报废或挂载历史,无法删除')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.borrowRecord.deleteMany({
|
|
||||||
where: {
|
|
||||||
equipment_id: equipmentId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await tx.equipment.delete({
|
await tx.equipment.delete({
|
||||||
where: { id: equipmentId }
|
where: { id: equipmentId }
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import {
|
|||||||
assignUnassignedTaskItem,
|
assignUnassignedTaskItem,
|
||||||
closeInventoryTask,
|
closeInventoryTask,
|
||||||
createInventoryTask,
|
createInventoryTask,
|
||||||
|
deleteInventoryTask,
|
||||||
getInventoryTaskDetail,
|
getInventoryTaskDetail,
|
||||||
getTaskUnassignedItems,
|
getTaskUnassignedItems,
|
||||||
listInventoryTasks,
|
listInventoryTasks,
|
||||||
previewInventoryTask
|
previewInventoryTask
|
||||||
} from '../services/inventoryService'
|
} from '../services/inventoryService'
|
||||||
|
import { normalizeInventoryScopeInput } from '../utils/inventoryScope'
|
||||||
|
|
||||||
const toOptionalNumber = (value: unknown) => {
|
const toOptionalNumber = (value: unknown) => {
|
||||||
if (value === undefined || value === null || value === '') return null
|
if (value === undefined || value === null || value === '') return null
|
||||||
@ -18,10 +20,15 @@ const toOptionalNumber = (value: unknown) => {
|
|||||||
|
|
||||||
export const previewInventoryTaskController = async (req: Request, res: Response) => {
|
export const previewInventoryTaskController = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const result = await previewInventoryTask({
|
const normalizedScope = normalizeInventoryScopeInput({
|
||||||
departmentId: toOptionalNumber(req.body?.department_id),
|
department_id: toOptionalNumber(req.body?.department_id),
|
||||||
assetTypeId: toOptionalNumber(req.body?.asset_type_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, '预览成功')
|
successResponse(res, result, '预览成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorResponse(res, error.message || '预览盘点任务失败', 422)
|
errorResponse(res, error.message || '预览盘点任务失败', 422)
|
||||||
@ -35,12 +42,22 @@ export const createInventoryTaskController = async (req: Request, res: Response)
|
|||||||
return errorResponse(res, '未获取到当前登录用户', 401)
|
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(
|
const result = await createInventoryTask(
|
||||||
{
|
{
|
||||||
title: req.body?.title,
|
title: req.body?.title,
|
||||||
description: req.body?.description,
|
description: req.body?.description,
|
||||||
department_id: toOptionalNumber(req.body?.department_id),
|
department_ids: normalizedScope.departmentIds,
|
||||||
asset_type_id: toOptionalNumber(req.body?.asset_type_id)
|
asset_type_ids: normalizedScope.assetTypeIds,
|
||||||
|
storage_location_ids: normalizedScope.storageLocationIds
|
||||||
},
|
},
|
||||||
currentUserId
|
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) => {
|
export const getInventoryTaskUnassignedController = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const taskId = Number(req.params.id)
|
const taskId = Number(req.params.id)
|
||||||
|
|||||||
@ -16,6 +16,23 @@ const getCurrentUserId = (res: Response) => {
|
|||||||
return Number.isNaN(userId) ? null : userId
|
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) => ({
|
const sanitizeNotification = (item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
is_read: item.is_read,
|
is_read: item.is_read,
|
||||||
@ -38,6 +55,7 @@ export const getNotificationList = async (req: Request, res: Response) => {
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return errorResponse(res, '未获取到当前用户信息', 401)
|
return errorResponse(res, '未获取到当前用户信息', 401)
|
||||||
}
|
}
|
||||||
|
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
|
||||||
|
|
||||||
const { page = 1, pageSize = 10, status = 'all' } = req.query
|
const { page = 1, pageSize = 10, status = 'all' } = req.query
|
||||||
const where: any = { user_id: userId }
|
const where: any = { user_id: userId }
|
||||||
@ -89,6 +107,7 @@ export const getUnreadNotificationCount = async (_req: Request, res: Response) =
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return errorResponse(res, '未获取到当前用户信息', 401)
|
return errorResponse(res, '未获取到当前用户信息', 401)
|
||||||
}
|
}
|
||||||
|
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
|
||||||
|
|
||||||
const count = await prisma.userNotification.count({
|
const count = await prisma.userNotification.count({
|
||||||
where: {
|
where: {
|
||||||
@ -109,6 +128,7 @@ export const markNotificationAsRead = async (req: Request, res: Response) => {
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return errorResponse(res, '未获取到当前用户信息', 401)
|
return errorResponse(res, '未获取到当前用户信息', 401)
|
||||||
}
|
}
|
||||||
|
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
|
||||||
|
|
||||||
const id = Number(req.params.id)
|
const id = Number(req.params.id)
|
||||||
const existing = await prisma.userNotification.findFirst({
|
const existing = await prisma.userNotification.findFirst({
|
||||||
@ -148,6 +168,7 @@ export const markAllNotificationsAsRead = async (_req: Request, res: Response) =
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return errorResponse(res, '未获取到当前用户信息', 401)
|
return errorResponse(res, '未获取到当前用户信息', 401)
|
||||||
}
|
}
|
||||||
|
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
|
||||||
|
|
||||||
const result = await prisma.userNotification.updateMany({
|
const result = await prisma.userNotification.updateMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -108,7 +108,7 @@ export const createAssetLabelPrintJob = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createAssetLabelPrintJobService(req.body?.equipment_ids, userId)
|
const result = await createAssetLabelPrintJobService(req.body, userId)
|
||||||
successResponse(res, result.job, result.queue_notice)
|
successResponse(res, result.job, result.queue_notice)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handlePrintServiceError(res, error, '创建打印任务失败')
|
handlePrintServiceError(res, error, '创建打印任务失败')
|
||||||
|
|||||||
@ -43,7 +43,7 @@ const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secre
|
|||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors({ origin: true, credentials: true }))
|
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(express.urlencoded({ extended: true }))
|
||||||
app.use(
|
app.use(
|
||||||
session({
|
session({
|
||||||
|
|||||||
@ -36,10 +36,12 @@ router.get('/user', (req, res) => {
|
|||||||
|
|
||||||
// Keycloak 配置信息(前端使用)
|
// Keycloak 配置信息(前端使用)
|
||||||
router.get('/config', (req, res) => {
|
router.get('/config', (req, res) => {
|
||||||
|
const authMode = process.env.AUTH_MODE === 'keycloak' ? 'keycloak' : 'local'
|
||||||
successResponse(res, {
|
successResponse(res, {
|
||||||
url: process.env.KEYCLOAK_URL || 'http://localhost:8080',
|
authMode,
|
||||||
realm: process.env.KEYCLOAK_REALM || 'equipment-management',
|
url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '',
|
||||||
clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client'
|
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
|
export default router
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import {
|
|||||||
borrowEquipment,
|
borrowEquipment,
|
||||||
scanBorrow,
|
scanBorrow,
|
||||||
scanBorrowBatch,
|
scanBorrowBatch,
|
||||||
|
resolveBorrowAnomalyAsReturned,
|
||||||
|
registerBorrowAnomaly,
|
||||||
getBorrowRecordByCode,
|
getBorrowRecordByCode,
|
||||||
getBorrowRecordList
|
getBorrowRecordList
|
||||||
} from '../controllers/borrowController'
|
} from '../controllers/borrowController'
|
||||||
@ -13,6 +15,8 @@ const router = Router()
|
|||||||
router.post('/', borrowEquipment)
|
router.post('/', borrowEquipment)
|
||||||
router.post('/scan', scanBorrow)
|
router.post('/scan', scanBorrow)
|
||||||
router.post('/scan-batch', scanBorrowBatch)
|
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('/scan/:equipment_code', getBorrowRecordByCode)
|
||||||
router.get('/records', getBorrowRecordList)
|
router.get('/records', getBorrowRecordList)
|
||||||
|
|
||||||
@ -24,9 +28,11 @@ export const returnRouter = Router()
|
|||||||
import {
|
import {
|
||||||
returnEquipment,
|
returnEquipment,
|
||||||
scanReturn,
|
scanReturn,
|
||||||
scanReturnBatch
|
scanReturnBatch,
|
||||||
|
searchReturnAssets
|
||||||
} from '../controllers/borrowController'
|
} from '../controllers/borrowController'
|
||||||
|
|
||||||
returnRouter.post('/', returnEquipment)
|
returnRouter.post('/', returnEquipment)
|
||||||
returnRouter.post('/scan', scanReturn)
|
returnRouter.post('/scan', scanReturn)
|
||||||
returnRouter.post('/scan-batch', scanReturnBatch)
|
returnRouter.post('/scan-batch', scanReturnBatch)
|
||||||
|
returnRouter.get('/search-assets', searchReturnAssets)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
assignInventoryTaskUnassignedController,
|
assignInventoryTaskUnassignedController,
|
||||||
closeInventoryTaskController,
|
closeInventoryTaskController,
|
||||||
createInventoryTaskController,
|
createInventoryTaskController,
|
||||||
|
deleteInventoryTaskController,
|
||||||
getInventoryTaskDetailController,
|
getInventoryTaskDetailController,
|
||||||
getInventoryTaskListController,
|
getInventoryTaskListController,
|
||||||
getInventoryTaskUnassignedController,
|
getInventoryTaskUnassignedController,
|
||||||
@ -19,6 +20,7 @@ router.post('/tasks', createInventoryTaskController)
|
|||||||
router.get('/tasks', getInventoryTaskListController)
|
router.get('/tasks', getInventoryTaskListController)
|
||||||
router.get('/tasks/:id', getInventoryTaskDetailController)
|
router.get('/tasks/:id', getInventoryTaskDetailController)
|
||||||
router.post('/tasks/:id/close', closeInventoryTaskController)
|
router.post('/tasks/:id/close', closeInventoryTaskController)
|
||||||
|
router.delete('/tasks/:id', deleteInventoryTaskController)
|
||||||
router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController)
|
router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController)
|
||||||
router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController)
|
router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController)
|
||||||
|
|
||||||
|
|||||||
91
backend/src/services/__tests__/hrSyncService.test.ts
Normal file
91
backend/src/services/__tests__/hrSyncService.test.ts
Normal file
@ -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 / 主机)')
|
||||||
|
})
|
||||||
@ -1,10 +1,13 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import {
|
import {
|
||||||
|
getPrintTerminalAdminStatusChangeError,
|
||||||
resolveEffectiveTerminalStatus,
|
resolveEffectiveTerminalStatus,
|
||||||
normalizeLabelProfile,
|
normalizeLabelProfile,
|
||||||
pickNextClaimablePrintJob,
|
pickNextClaimablePrintJob,
|
||||||
resolveTerminalReadiness
|
resolveTerminalReadiness,
|
||||||
|
sanitizeRenderedLabelImages,
|
||||||
|
stripRenderedLabelData
|
||||||
} from '../printService'
|
} from '../printService'
|
||||||
|
|
||||||
test('normalizeLabelProfile 应回填 60x40 与默认方向', () => {
|
test('normalizeLabelProfile 应回填 60x40 与默认方向', () => {
|
||||||
@ -142,3 +145,86 @@ test('resolveEffectiveTerminalStatus 对手动设为 offline 的终端保持 off
|
|||||||
|
|
||||||
assert.equal(status, 'offline')
|
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: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
113
backend/src/services/hrSyncHelpers.ts
Normal file
113
backend/src/services/hrSyncHelpers.ts
Normal file
@ -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<string, any> | null) => {
|
||||||
|
if (!afterSnapshot || !Array.isArray(afterSnapshot.assets)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return afterSnapshot.assets
|
||||||
|
.map((asset: Record<string, any>) => ({
|
||||||
|
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<string, any> | 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
|
||||||
|
}
|
||||||
@ -7,6 +7,12 @@ import {
|
|||||||
buildBusinessDepartmentWhere,
|
buildBusinessDepartmentWhere,
|
||||||
buildSyncedBusinessDepartmentWhere
|
buildSyncedBusinessDepartmentWhere
|
||||||
} from '../utils/organization'
|
} from '../utils/organization'
|
||||||
|
import {
|
||||||
|
buildHrSyncPreviewLine,
|
||||||
|
resolveHrSyncPersonDisplayName,
|
||||||
|
resolveSourcePersonStatus,
|
||||||
|
type OpenBorrowAssetPreview
|
||||||
|
} from './hrSyncHelpers'
|
||||||
|
|
||||||
type SourceDepartment = {
|
type SourceDepartment = {
|
||||||
sourceGroupId: number | null
|
sourceGroupId: number | null
|
||||||
@ -147,6 +153,7 @@ const REQUIRED_SQLSERVER_FIELDS = [
|
|||||||
'SQLSERVER_USER',
|
'SQLSERVER_USER',
|
||||||
'SQLSERVER_PASSWORD'
|
'SQLSERVER_PASSWORD'
|
||||||
] as const
|
] as const
|
||||||
|
const DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE = 'Asia/Shanghai'
|
||||||
|
|
||||||
const departmentCollator = new Intl.Collator('zh-CN', {
|
const departmentCollator = new Intl.Collator('zh-CN', {
|
||||||
numeric: true,
|
numeric: true,
|
||||||
@ -181,9 +188,6 @@ const getConfiguredSet = (value: string, defaultValue: string) =>
|
|||||||
const getFormalStatusValues = () =>
|
const getFormalStatusValues = () =>
|
||||||
getConfiguredSet(process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', '3')
|
getConfiguredSet(process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', '3')
|
||||||
|
|
||||||
const getInactiveStatusValues = () =>
|
|
||||||
getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '5')
|
|
||||||
|
|
||||||
const getExcludedSourceDepartmentNames = () =>
|
const getExcludedSourceDepartmentNames = () =>
|
||||||
getConfiguredSet(
|
getConfiguredSet(
|
||||||
process.env.HR_SYNC_EXCLUDED_DEPARTMENT_NAMES || process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '',
|
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 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<string, string>()
|
||||||
|
|
||||||
|
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 = () => {
|
export const getHrSyncConfigStatus = () => {
|
||||||
const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => {
|
const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => {
|
||||||
const value = process.env[field]
|
const value = process.env[field]
|
||||||
@ -202,7 +261,8 @@ export const getHrSyncConfigStatus = () => {
|
|||||||
enabled: process.env.HR_SYNC_ENABLED === 'true',
|
enabled: process.env.HR_SYNC_ENABLED === 'true',
|
||||||
complete: missingFields.length === 0,
|
complete: missingFields.length === 0,
|
||||||
missingFields,
|
missingFields,
|
||||||
scheduleTimes: getConfiguredScheduleTimes()
|
scheduleTimes: getConfiguredScheduleTimes(),
|
||||||
|
scheduleTimezone: getConfiguredScheduleTimezone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,8 +318,8 @@ const formatDepartmentName = (value: string | null | undefined) => value || '未
|
|||||||
const inferTriggerMode = (operator: string): SyncTriggerMode =>
|
const inferTriggerMode = (operator: string): SyncTriggerMode =>
|
||||||
operator.includes('scheduler') ? 'scheduler' : 'manual'
|
operator.includes('scheduler') ? 'scheduler' : 'manual'
|
||||||
|
|
||||||
const getConfiguredScheduleTimes = () =>
|
const getConfiguredScheduleTimes = () => {
|
||||||
Array.from(
|
const scheduleTimes = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
(process.env.HR_SYNC_SCHEDULES || process.env.HR_SYNC_SCHEDULE || '03:00')
|
(process.env.HR_SYNC_SCHEDULES || process.env.HR_SYNC_SCHEDULE || '03:00')
|
||||||
.split(',')
|
.split(',')
|
||||||
@ -268,6 +328,9 @@ const getConfiguredScheduleTimes = () =>
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return scheduleTimes.length > 0 ? scheduleTimes : ['03:00']
|
||||||
|
}
|
||||||
|
|
||||||
const normalizePhone = (value: unknown) => {
|
const normalizePhone = (value: unknown) => {
|
||||||
const text = safeTrim(value)
|
const text = safeTrim(value)
|
||||||
if (!text) return null
|
if (!text) return null
|
||||||
@ -293,16 +356,8 @@ const parseExtensionFlowerName = (extension: unknown) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' => {
|
const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' =>
|
||||||
const inactiveStatusValues = getInactiveStatusValues()
|
resolveSourcePersonStatus(safeTrim(employeeStatus))
|
||||||
const sourceStatus = safeTrim(employeeStatus)
|
|
||||||
|
|
||||||
if (sourceStatus && inactiveStatusValues.has(sourceStatus)) {
|
|
||||||
return 'inactive'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'active'
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveDepartmentBinding = (
|
const resolveDepartmentBinding = (
|
||||||
sourceDepartmentName: string | null,
|
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<number, OpenBorrowAssetPreview[]>()
|
||||||
|
|
||||||
|
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 buildSyncChange = (change: HrSyncChangeDraft) => change
|
||||||
|
|
||||||
const diffDepartmentSnapshots = (
|
const diffDepartmentSnapshots = (
|
||||||
@ -779,7 +873,8 @@ const diffDepartmentSnapshots = (
|
|||||||
|
|
||||||
const diffPersonSnapshots = (
|
const diffPersonSnapshots = (
|
||||||
beforeMap: Map<string, PersonSnapshot>,
|
beforeMap: Map<string, PersonSnapshot>,
|
||||||
afterMap: Map<string, PersonSnapshot>
|
afterMap: Map<string, PersonSnapshot>,
|
||||||
|
openBorrowAssetMap: Map<number, OpenBorrowAssetPreview[]>
|
||||||
) => {
|
) => {
|
||||||
const changes: HrSyncChangeDraft[] = []
|
const changes: HrSyncChangeDraft[] = []
|
||||||
const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) =>
|
const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) =>
|
||||||
@ -789,6 +884,8 @@ const diffPersonSnapshots = (
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const before = beforeMap.get(key)
|
const before = beforeMap.get(key)
|
||||||
const after = afterMap.get(key)
|
const after = afterMap.get(key)
|
||||||
|
const currentPerson = after || before
|
||||||
|
const personDisplayName = resolveHrSyncPersonDisplayName(currentPerson || {})
|
||||||
|
|
||||||
if (!before && after && after.status === 'active') {
|
if (!before && after && after.status === 'active') {
|
||||||
changes.push(
|
changes.push(
|
||||||
@ -797,10 +894,13 @@ const diffPersonSnapshots = (
|
|||||||
entityKey: key,
|
entityKey: key,
|
||||||
entityName: after.realName,
|
entityName: after.realName,
|
||||||
changeType: 'person_created',
|
changeType: 'person_created',
|
||||||
title: `新人入职:${after.realName}`,
|
title: `新人入职:${personDisplayName}`,
|
||||||
detail: `系统归属部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`,
|
detail: `系统归属部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`,
|
||||||
beforeSnapshot: null,
|
beforeSnapshot: null,
|
||||||
afterSnapshot: after
|
afterSnapshot: {
|
||||||
|
...after,
|
||||||
|
displayName: personDisplayName
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@ -810,6 +910,8 @@ const diffPersonSnapshots = (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openBorrowAssets = openBorrowAssetMap.get(after.id || before.id) || []
|
||||||
|
|
||||||
if (before.status !== after.status) {
|
if (before.status !== after.status) {
|
||||||
changes.push(
|
changes.push(
|
||||||
buildSyncChange({
|
buildSyncChange({
|
||||||
@ -817,13 +919,21 @@ const diffPersonSnapshots = (
|
|||||||
entityKey: key,
|
entityKey: key,
|
||||||
entityName: after.realName,
|
entityName: after.realName,
|
||||||
changeType: after.status === 'inactive' ? 'person_deactivated' : 'person_reactivated',
|
changeType: after.status === 'inactive' ? 'person_deactivated' : 'person_reactivated',
|
||||||
title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${after.realName}`,
|
title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${personDisplayName}`,
|
||||||
detail:
|
detail:
|
||||||
after.status === 'inactive'
|
after.status === 'inactive'
|
||||||
? '该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。'
|
? `该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。${
|
||||||
|
openBorrowAssets.length > 0
|
||||||
|
? ` 当前名下还有 ${openBorrowAssets.length} 件待处理资产,可在下方直接快捷入库。`
|
||||||
|
: ' 当前名下没有待处理资产。'
|
||||||
|
}`
|
||||||
: '该人员重新进入同步名单,可继续参与借还与盘点流程。',
|
: '该人员重新进入同步名单,可继续参与借还与盘点流程。',
|
||||||
beforeSnapshot: before,
|
beforeSnapshot: before,
|
||||||
afterSnapshot: after
|
afterSnapshot: {
|
||||||
|
...after,
|
||||||
|
displayName: personDisplayName,
|
||||||
|
assets: openBorrowAssets
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -839,10 +949,13 @@ const diffPersonSnapshots = (
|
|||||||
entityKey: key,
|
entityKey: key,
|
||||||
entityName: after.realName,
|
entityName: after.realName,
|
||||||
changeType: 'person_main_department_changed',
|
changeType: 'person_main_department_changed',
|
||||||
title: `主部门变动:${after.realName}`,
|
title: `主部门变动:${personDisplayName}`,
|
||||||
detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName(after.mainDepartmentName)}”。`,
|
detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName(after.mainDepartmentName)}”。`,
|
||||||
beforeSnapshot: before,
|
beforeSnapshot: before,
|
||||||
afterSnapshot: after
|
afterSnapshot: {
|
||||||
|
...after,
|
||||||
|
displayName: personDisplayName
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -854,10 +967,13 @@ const diffPersonSnapshots = (
|
|||||||
entityKey: key,
|
entityKey: key,
|
||||||
entityName: after.realName,
|
entityName: after.realName,
|
||||||
changeType: 'person_departments_changed',
|
changeType: 'person_departments_changed',
|
||||||
title: `关联部门变动:${after.realName}`,
|
title: `关联部门变动:${personDisplayName}`,
|
||||||
detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList(after.departmentNames)}”。`,
|
detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList(after.departmentNames)}”。`,
|
||||||
beforeSnapshot: before,
|
beforeSnapshot: before,
|
||||||
afterSnapshot: after
|
afterSnapshot: {
|
||||||
|
...after,
|
||||||
|
displayName: personDisplayName
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -869,10 +985,18 @@ const diffPersonSnapshots = (
|
|||||||
entityKey: key,
|
entityKey: key,
|
||||||
entityName: after.realName,
|
entityName: after.realName,
|
||||||
changeType: 'person_position_changed',
|
changeType: 'person_position_changed',
|
||||||
title: `岗位变动:${after.realName}`,
|
title: `岗位变动:${personDisplayName}`,
|
||||||
detail: `岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。`,
|
detail:
|
||||||
|
`岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。` +
|
||||||
|
(openBorrowAssets.length > 0
|
||||||
|
? ` 当前名下还有 ${openBorrowAssets.length} 件资产,可在下方直接快捷入库。`
|
||||||
|
: ''),
|
||||||
beforeSnapshot: before,
|
beforeSnapshot: before,
|
||||||
afterSnapshot: after
|
afterSnapshot: {
|
||||||
|
...after,
|
||||||
|
displayName: personDisplayName,
|
||||||
|
assets: openBorrowAssets
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -910,7 +1034,13 @@ const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary =>
|
|||||||
change_type: change.changeType
|
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 {
|
return {
|
||||||
title: `组织同步发现 ${changes.length} 项变化`,
|
title: `组织同步发现 ${changes.length} 项变化`,
|
||||||
@ -981,44 +1111,41 @@ const persistHrSyncArtifacts = async ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let notificationId: number | null = null
|
const notification = await tx.notification.create({
|
||||||
if (status === 'failed' || changes.length > 0) {
|
data: {
|
||||||
const notification = await tx.notification.create({
|
title: summary.title,
|
||||||
data: {
|
content: summary.content,
|
||||||
title: summary.title,
|
type: 'hr_sync',
|
||||||
content: summary.content,
|
level: status === 'failed' ? 'error' : changes.length > 0 ? 'warning' : 'info',
|
||||||
type: 'hr_sync',
|
action_url: '/notifications',
|
||||||
level: status === 'failed' ? 'error' : 'warning',
|
related_sync_job_id: syncJob.id,
|
||||||
action_url: '/settings?tab=sync',
|
metadata: toJsonText({
|
||||||
related_sync_job_id: syncJob.id,
|
status,
|
||||||
metadata: toJsonText({
|
changes_count: changes.length,
|
||||||
status,
|
counts: summary.counts,
|
||||||
changes_count: changes.length,
|
preview_changes: summary.previewChanges
|
||||||
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 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 {
|
return {
|
||||||
@ -1459,6 +1586,9 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo
|
|||||||
const deactivatedResult = await prisma.person.updateMany({
|
const deactivatedResult = await prisma.person.updateMany({
|
||||||
where: {
|
where: {
|
||||||
source_system: HR_SOURCE_SYSTEM,
|
source_system: HR_SOURCE_SYSTEM,
|
||||||
|
status: {
|
||||||
|
not: 'inactive'
|
||||||
|
},
|
||||||
source_userid: {
|
source_userid: {
|
||||||
notIn: sourceUserIds
|
notIn: sourceUserIds
|
||||||
}
|
}
|
||||||
@ -1651,14 +1781,15 @@ export const runHrSync = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [afterDepartmentMap, afterPersonMap] = await Promise.all([
|
const [afterDepartmentMap, afterPersonMap, openBorrowAssetMap] = await Promise.all([
|
||||||
getDepartmentSnapshotMap(),
|
getDepartmentSnapshotMap(),
|
||||||
getPersonSnapshotMap()
|
getPersonSnapshotMap(),
|
||||||
|
getOpenBorrowAssetMap()
|
||||||
])
|
])
|
||||||
|
|
||||||
const changes: HrSyncChangeDraft[] = [
|
const changes: HrSyncChangeDraft[] = [
|
||||||
...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap),
|
...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap),
|
||||||
...diffPersonSnapshots(beforePersonMap, afterPersonMap)
|
...diffPersonSnapshots(beforePersonMap, afterPersonMap, openBorrowAssetMap)
|
||||||
]
|
]
|
||||||
|
|
||||||
if (equipmentDepartmentMigration.unresolved > 0) {
|
if (equipmentDepartmentMigration.unresolved > 0) {
|
||||||
@ -1753,12 +1884,12 @@ export const startHrSyncScheduler = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scheduleTimes = getConfiguredScheduleTimes()
|
const scheduleTimes = getConfiguredScheduleTimes()
|
||||||
|
const scheduleTimezone = getConfiguredScheduleTimezone()
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const hh = String(now.getHours()).padStart(2, '0')
|
const currentClock = getCurrentClockInTimeZone(now, scheduleTimezone)
|
||||||
const mm = String(now.getMinutes()).padStart(2, '0')
|
const currentTime = currentClock.timeKey
|
||||||
const currentTime = `${hh}:${mm}`
|
const runKey = `${currentClock.dateKey}@${currentTime}`
|
||||||
const runKey = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}@${currentTime}`
|
|
||||||
|
|
||||||
if (!scheduleTimes.includes(currentTime) || schedulerRunning || lastSchedulerRunKeys.has(runKey)) {
|
if (!scheduleTimes.includes(currentTime) || schedulerRunning || lastSchedulerRunKeys.has(runKey)) {
|
||||||
return
|
return
|
||||||
@ -1768,7 +1899,9 @@ export const startHrSyncScheduler = () => {
|
|||||||
try {
|
try {
|
||||||
await runHrSync('system-scheduler', 'scheduler')
|
await runHrSync('system-scheduler', 'scheduler')
|
||||||
lastSchedulerRunKeys.add(runKey)
|
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) {
|
} catch (error) {
|
||||||
console.error('[hr-sync] failed:', error)
|
console.error('[hr-sync] failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -1776,5 +1909,7 @@ export const startHrSyncScheduler = () => {
|
|||||||
}
|
}
|
||||||
}, 60 * 1000)
|
}, 60 * 1000)
|
||||||
|
|
||||||
console.log(`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}`)
|
console.log(
|
||||||
|
`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}, timezone=${scheduleTimezone}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
import prisma from '../utils/prisma'
|
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 = {
|
type ScopeEntity = {
|
||||||
departmentId?: number | null
|
id: number
|
||||||
assetTypeId?: number | null
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InventoryScopeOptions = NormalizeInventoryScopeInputParams | NormalizedInventoryScopeInput
|
||||||
|
|
||||||
type FrozenParticipantDraft = {
|
type FrozenParticipantDraft = {
|
||||||
person_id: number
|
person_id: number
|
||||||
person_name: string
|
person_name: string
|
||||||
@ -37,8 +46,9 @@ type FrozenItemDraft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type InventoryScopeBuildResult = {
|
type InventoryScopeBuildResult = {
|
||||||
scopeDepartment: { id: number; name: string } | null
|
scopeDepartments: ScopeEntity[]
|
||||||
scopeAssetType: { id: number; name: string } | null
|
scopeAssetTypes: ScopeEntity[]
|
||||||
|
scopeStorageLocations: ScopeEntity[]
|
||||||
participants: FrozenParticipantDraft[]
|
participants: FrozenParticipantDraft[]
|
||||||
items: FrozenItemDraft[]
|
items: FrozenItemDraft[]
|
||||||
}
|
}
|
||||||
@ -62,54 +72,101 @@ const INVENTORY_PARTICIPANT_STATUS = {
|
|||||||
|
|
||||||
const EXCLUDED_EQUIPMENT_STATUSES = ['已报废']
|
const EXCLUDED_EQUIPMENT_STATUSES = ['已报废']
|
||||||
|
|
||||||
const buildScopeWhere = ({ departmentId, assetTypeId }: InventoryScopeOptions) => {
|
const buildScopeWhere = ({
|
||||||
|
departmentIds,
|
||||||
|
assetTypeIds,
|
||||||
|
storageLocationIds
|
||||||
|
}: NormalizedInventoryScopeInput) => {
|
||||||
const where: Record<string, any> = {
|
const where: Record<string, any> = {
|
||||||
status: {
|
status: {
|
||||||
notIn: EXCLUDED_EQUIPMENT_STATUSES
|
notIn: EXCLUDED_EQUIPMENT_STATUSES
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (departmentId) {
|
if (departmentIds.length > 0) {
|
||||||
where.department_id = departmentId
|
where.department_id = {
|
||||||
|
in: departmentIds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetTypeId) {
|
if (assetTypeIds.length > 0) {
|
||||||
where.asset_type_id = assetTypeId
|
where.asset_type_id = {
|
||||||
|
in: assetTypeIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageLocationIds.length > 0) {
|
||||||
|
where.storage_location_id = {
|
||||||
|
in: storageLocationIds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return where
|
return where
|
||||||
}
|
}
|
||||||
|
|
||||||
const getScopeMeta = async ({ departmentId, assetTypeId }: InventoryScopeOptions) => {
|
const getScopeMeta = async ({
|
||||||
const [department, assetType] = await Promise.all([
|
departmentIds,
|
||||||
departmentId
|
assetTypeIds,
|
||||||
? prisma.department.findFirst({
|
storageLocationIds
|
||||||
|
}: NormalizedInventoryScopeInput) => {
|
||||||
|
const [departments, assetTypes, storageLocations] = await Promise.all([
|
||||||
|
departmentIds.length > 0
|
||||||
|
? prisma.department.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: departmentId,
|
id: {
|
||||||
...buildSyncedBusinessDepartmentWhere()
|
in: departmentIds
|
||||||
|
},
|
||||||
|
AND: [
|
||||||
|
buildBusinessDepartmentWhere(),
|
||||||
|
{ status: 'active' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
select: { id: true, name: true, status: true }
|
orderBy: { name: 'asc' },
|
||||||
|
select: { id: true, name: true }
|
||||||
})
|
})
|
||||||
: Promise.resolve(null),
|
: Promise.resolve([]),
|
||||||
assetTypeId
|
assetTypeIds.length > 0
|
||||||
? prisma.assetType.findUnique({
|
? prisma.assetType.findMany({
|
||||||
where: { id: assetTypeId },
|
where: {
|
||||||
select: { id: true, name: true, status: true }
|
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('所选业务部门不存在或已停用')
|
throw new Error('所选业务部门不存在或已停用')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assetTypeId && (!assetType || assetType.status !== 'active')) {
|
if (assetTypeIds.length > 0 && assetTypes.length !== assetTypeIds.length) {
|
||||||
throw new Error('所选资产类型不存在或已停用')
|
throw new Error('所选资产类型不存在或已停用')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (storageLocationIds.length > 0 && storageLocations.length !== storageLocationIds.length) {
|
||||||
|
throw new Error('所选地区不存在或已停用')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scopeDepartment: department ? { id: department.id, name: department.name } : null,
|
scopeDepartments: departments,
|
||||||
scopeAssetType: assetType ? { id: assetType.id, name: assetType.name } : null
|
scopeAssetTypes: assetTypes,
|
||||||
|
scopeStorageLocations: storageLocations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,10 +300,16 @@ const buildAssetTypeBreakdown = (items: FrozenItemDraft[]) =>
|
|||||||
export const buildInventoryScope = async (
|
export const buildInventoryScope = async (
|
||||||
options: InventoryScopeOptions
|
options: InventoryScopeOptions
|
||||||
): Promise<InventoryScopeBuildResult> => {
|
): Promise<InventoryScopeBuildResult> => {
|
||||||
const meta = await getScopeMeta(options)
|
const normalizedScope = normalizeInventoryScopeInput(options)
|
||||||
|
const meta = await getScopeMeta(normalizedScope)
|
||||||
const equipments = await prisma.equipment.findMany({
|
const equipments = await prisma.equipment.findMany({
|
||||||
where: buildScopeWhere(options),
|
where: buildScopeWhere(normalizedScope),
|
||||||
orderBy: [{ department_id: 'asc' }, { asset_type_id: 'asc' }, { created_at: 'desc' }],
|
orderBy: [
|
||||||
|
{ storage_location_id: 'asc' },
|
||||||
|
{ department_id: 'asc' },
|
||||||
|
{ asset_type_id: 'asc' },
|
||||||
|
{ created_at: 'desc' }
|
||||||
|
],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
equipment_code: true,
|
equipment_code: true,
|
||||||
@ -267,6 +330,12 @@ export const buildInventoryScope = async (
|
|||||||
id: true,
|
id: true,
|
||||||
name: true
|
name: true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
storageLocation: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -281,12 +350,13 @@ export const buildInventoryScope = async (
|
|||||||
for (const equipment of equipments) {
|
for (const equipment of equipments) {
|
||||||
const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap)
|
const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap)
|
||||||
const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || []
|
const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || []
|
||||||
const activeBorrowRecord =
|
const activeBorrowRecord = resolveUniqueActiveBorrowRecord(activeBorrowRecords)
|
||||||
activeBorrowRecords.length === 1 && activeBorrowRecords[0].person?.status === 'active'
|
|
||||||
? activeBorrowRecords[0]
|
|
||||||
: null
|
|
||||||
|
|
||||||
const participantPersonId = activeBorrowRecord?.person?.id || null
|
if (!activeBorrowRecord?.person) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantPersonId = activeBorrowRecord.person.id
|
||||||
const snapshotDepartmentId =
|
const snapshotDepartmentId =
|
||||||
activeBorrowRecord?.person?.mainDepartment?.id ||
|
activeBorrowRecord?.person?.mainDepartment?.id ||
|
||||||
equipment.department?.id ||
|
equipment.department?.id ||
|
||||||
@ -297,19 +367,17 @@ export const buildInventoryScope = async (
|
|||||||
equipment.department?.name ||
|
equipment.department?.name ||
|
||||||
null
|
null
|
||||||
|
|
||||||
if (participantPersonId && activeBorrowRecord?.person) {
|
participantMap.set(participantPersonId, {
|
||||||
participantMap.set(participantPersonId, {
|
person_id: activeBorrowRecord.person.id,
|
||||||
person_id: activeBorrowRecord.person.id,
|
person_name: activeBorrowRecord.person.real_name,
|
||||||
person_name: activeBorrowRecord.person.real_name,
|
person_nickname: activeBorrowRecord.person.nickname,
|
||||||
person_nickname: activeBorrowRecord.person.nickname,
|
person_source_userid: activeBorrowRecord.person.source_userid || null,
|
||||||
person_source_userid: activeBorrowRecord.person.source_userid || null,
|
department_id: activeBorrowRecord.person.mainDepartment?.id || null,
|
||||||
department_id: activeBorrowRecord.person.mainDepartment?.id || null,
|
department_name:
|
||||||
department_name:
|
activeBorrowRecord.person.mainDepartment?.name ||
|
||||||
activeBorrowRecord.person.mainDepartment?.name ||
|
activeBorrowRecord.person.department ||
|
||||||
activeBorrowRecord.person.department ||
|
null
|
||||||
null
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
participant_person_id: participantPersonId,
|
participant_person_id: participantPersonId,
|
||||||
@ -327,13 +395,14 @@ export const buildInventoryScope = async (
|
|||||||
snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null,
|
snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null,
|
||||||
snapshot_status: equipment.status,
|
snapshot_status: equipment.status,
|
||||||
source_borrow_record_id: activeBorrowRecord?.id || null,
|
source_borrow_record_id: activeBorrowRecord?.id || null,
|
||||||
is_unassigned: !activeBorrowRecord
|
is_unassigned: false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scopeDepartment: meta.scopeDepartment,
|
scopeDepartments: meta.scopeDepartments,
|
||||||
scopeAssetType: meta.scopeAssetType,
|
scopeAssetTypes: meta.scopeAssetTypes,
|
||||||
|
scopeStorageLocations: meta.scopeStorageLocations,
|
||||||
participants: Array.from(participantMap.values()).sort((left, right) =>
|
participants: Array.from(participantMap.values()).sort((left, right) =>
|
||||||
left.person_nickname.localeCompare(right.person_nickname, 'zh-CN')
|
left.person_nickname.localeCompare(right.person_nickname, 'zh-CN')
|
||||||
),
|
),
|
||||||
@ -343,13 +412,17 @@ export const buildInventoryScope = async (
|
|||||||
|
|
||||||
export const previewInventoryTask = async (options: InventoryScopeOptions) => {
|
export const previewInventoryTask = async (options: InventoryScopeOptions) => {
|
||||||
const scope = await buildInventoryScope(options)
|
const scope = await buildInventoryScope(options)
|
||||||
|
const unassignedCount = scope.items.filter((item) => item.is_unassigned).length
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scope_department: scope.scopeDepartment,
|
scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null,
|
||||||
scope_asset_type: scope.scopeAssetType,
|
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,
|
participants_count: scope.participants.length,
|
||||||
items_count: scope.items.length,
|
items_count: scope.items.length,
|
||||||
unassigned_count: scope.items.filter((item) => item.is_unassigned).length,
|
unassigned_count: unassignedCount,
|
||||||
department_breakdown: buildDepartmentBreakdown(scope.items),
|
department_breakdown: buildDepartmentBreakdown(scope.items),
|
||||||
asset_type_breakdown: buildAssetTypeBreakdown(scope.items),
|
asset_type_breakdown: buildAssetTypeBreakdown(scope.items),
|
||||||
participants_sample: scope.participants.slice(0, 10),
|
participants_sample: scope.participants.slice(0, 10),
|
||||||
@ -358,8 +431,11 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({
|
const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({
|
||||||
scope_department: scope.scopeDepartment,
|
scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null,
|
||||||
scope_asset_type: scope.scopeAssetType,
|
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,
|
participants_count: scope.participants.length,
|
||||||
items_count: scope.items.length,
|
items_count: scope.items.length,
|
||||||
unassigned_count: scope.items.filter((item) => item.is_unassigned).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) => ({
|
const parseScopeSnapshot = (task: any) => {
|
||||||
id: task.id,
|
let snapshot = null
|
||||||
title: task.title,
|
|
||||||
description: task.description,
|
if (task.scope_snapshot_json) {
|
||||||
status: task.status,
|
try {
|
||||||
public_token: task.public_token,
|
snapshot = JSON.parse(task.scope_snapshot_json)
|
||||||
scope_department_id: task.scope_department_id,
|
} catch {
|
||||||
scope_department_name: task.scope_department_name,
|
snapshot = null
|
||||||
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,
|
const scopeDepartments = Array.isArray(snapshot?.scope_departments)
|
||||||
completed_count: task.completed_count,
|
? snapshot.scope_departments
|
||||||
exception_count: task.exception_count,
|
: task.scope_department_id && task.scope_department_name
|
||||||
unassigned_count: task.unassigned_count,
|
? [{ id: task.scope_department_id, name: task.scope_department_name }]
|
||||||
created_by: task.created_by,
|
: []
|
||||||
created_by_name: task.creator?.name || task.creator?.username || null,
|
|
||||||
closed_at: task.closed_at,
|
const scopeAssetTypes = Array.isArray(snapshot?.scope_asset_types)
|
||||||
created_at: task.created_at,
|
? snapshot.scope_asset_types
|
||||||
updated_at: task.updated_at
|
: 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 (
|
export const createInventoryTask = async (
|
||||||
input: {
|
input: {
|
||||||
title: string
|
title: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
department_id?: number | null
|
department_id?: number | null
|
||||||
asset_type_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
|
operatorUserId: number
|
||||||
) => {
|
) => {
|
||||||
@ -484,8 +604,12 @@ export const createInventoryTask = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scope = await buildInventoryScope({
|
const scope = await buildInventoryScope({
|
||||||
departmentId: input.department_id || null,
|
department_id: input.department_id || null,
|
||||||
assetTypeId: input.asset_type_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) => {
|
const task = await prisma.$transaction(async (tx) => {
|
||||||
@ -495,10 +619,10 @@ export const createInventoryTask = async (
|
|||||||
description: input.description?.trim() || null,
|
description: input.description?.trim() || null,
|
||||||
status: INVENTORY_TASK_STATUSES.active,
|
status: INVENTORY_TASK_STATUSES.active,
|
||||||
public_token: createPublicToken(),
|
public_token: createPublicToken(),
|
||||||
scope_department_id: scope.scopeDepartment?.id || null,
|
scope_department_id: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].id : null,
|
||||||
scope_department_name: scope.scopeDepartment?.name || null,
|
scope_department_name: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].name : null,
|
||||||
scope_asset_type_id: scope.scopeAssetType?.id || null,
|
scope_asset_type_id: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].id : null,
|
||||||
scope_asset_type_name: scope.scopeAssetType?.name || null,
|
scope_asset_type_name: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].name : null,
|
||||||
scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)),
|
scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)),
|
||||||
created_by: operatorUserId
|
created_by: operatorUserId
|
||||||
}
|
}
|
||||||
@ -642,6 +766,8 @@ export const getInventoryTaskDetail = async (taskId: number) => {
|
|||||||
throw new Error('盘点任务不存在')
|
throw new Error('盘点任务不存在')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serializedScope = parseScopeSnapshot(task)
|
||||||
|
|
||||||
const departmentBreakdown = Array.from(
|
const departmentBreakdown = Array.from(
|
||||||
task.items.reduce((map, item) => {
|
task.items.reduce((map, item) => {
|
||||||
const key = item.snapshot_department_name || '未归属部门'
|
const key = item.snapshot_department_name || '未归属部门'
|
||||||
@ -684,7 +810,7 @@ export const getInventoryTaskDetail = async (taskId: number) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...serializeTaskListItem(task),
|
...serializeTaskListItem(task),
|
||||||
scope_snapshot: task.scope_snapshot_json ? JSON.parse(task.scope_snapshot_json) : null,
|
...serializedScope,
|
||||||
participants: task.participants,
|
participants: task.participants,
|
||||||
items: task.items,
|
items: task.items,
|
||||||
recent_actions: task.actions,
|
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) => {
|
export const getInventoryTaskPublicInfo = async (publicToken: string) => {
|
||||||
const task = await prisma.inventoryTask.findUnique({
|
const task = await prisma.inventoryTask.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@ -746,6 +895,7 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
|
|||||||
completed_count: true,
|
completed_count: true,
|
||||||
exception_count: true,
|
exception_count: true,
|
||||||
unassigned_count: true,
|
unassigned_count: true,
|
||||||
|
scope_snapshot_json: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
closed_at: true
|
closed_at: true
|
||||||
}
|
}
|
||||||
@ -755,7 +905,10 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
|
|||||||
throw new Error('盘点任务不存在')
|
throw new Error('盘点任务不存在')
|
||||||
}
|
}
|
||||||
|
|
||||||
return task
|
return {
|
||||||
|
...task,
|
||||||
|
...parseScopeSnapshot(task)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchTaskParticipants = async (publicToken: string, keyword: string) => {
|
export const searchTaskParticipants = async (publicToken: string, keyword: string) => {
|
||||||
@ -875,7 +1028,13 @@ export const getParticipantTaskContext = async (taskId: number, participantId: n
|
|||||||
throw new Error('盘点任务会话已失效')
|
throw new Error('盘点任务会话已失效')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { task, participant }
|
return {
|
||||||
|
task: {
|
||||||
|
...task,
|
||||||
|
...parseScopeSnapshot(task)
|
||||||
|
},
|
||||||
|
participant
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getParticipantItems = async (taskId: number, participantId: number) => {
|
export const getParticipantItems = async (taskId: number, participantId: number) => {
|
||||||
|
|||||||
@ -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 type { Prisma, PrintJob, PrintTerminal } from '@prisma/client'
|
||||||
import prisma from '../utils/prisma'
|
import prisma from '../utils/prisma'
|
||||||
|
|
||||||
@ -60,11 +62,24 @@ export interface AssetLabelPayloadItem {
|
|||||||
} | null
|
} | 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 {
|
export interface AssetLabelJobPayload {
|
||||||
job_type: 'asset_label'
|
job_type: 'asset_label'
|
||||||
generated_at: string
|
generated_at: string
|
||||||
equipment_ids: number[]
|
equipment_ids: number[]
|
||||||
items: AssetLabelPayloadItem[]
|
items: AssetLabelPayloadItem[]
|
||||||
|
rendered_labels?: AssetLabelRenderedImagePayload[]
|
||||||
|
rendered_label_files?: AssetLabelStoredRenderFilePayload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairPrintTerminalInput {
|
export interface PairPrintTerminalInput {
|
||||||
@ -129,6 +144,9 @@ const PRINT_PAIR_CODE_TTL_MINUTES = 10
|
|||||||
const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50
|
const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50
|
||||||
const MAX_RETRY_ATTEMPTS = 3
|
const MAX_RETRY_ATTEMPTS = 3
|
||||||
const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
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 = {
|
type ParsedTerminalData = {
|
||||||
labelProfile: PrintLabelProfileInput | null
|
labelProfile: PrintLabelProfileInput | null
|
||||||
@ -351,6 +369,213 @@ const sanitizeEquipmentIds = (value: unknown) => {
|
|||||||
return uniqueIds
|
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<number, AssetLabelRenderedImagePayload>()
|
||||||
|
|
||||||
|
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<AssetLabelRenderedImagePayload[]> => {
|
||||||
|
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 = (
|
export const resolveEffectiveTerminalStatus = (
|
||||||
terminal: Pick<PrintTerminalReadinessInput, 'status' | 'last_seen_at'>,
|
terminal: Pick<PrintTerminalReadinessInput, 'status' | 'last_seen_at'>,
|
||||||
now: Date = new Date()
|
now: Date = new Date()
|
||||||
@ -382,6 +607,13 @@ export const resolveEffectiveTerminalStatus = (
|
|||||||
return PRINT_TERMINAL_STATUS.online
|
return PRINT_TERMINAL_STATUS.online
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getPrintTerminalAdminStatusChangeError = (
|
||||||
|
_terminal: Pick<PrintTerminal, 'is_default'>,
|
||||||
|
_nextStatus: 'offline' | 'disabled'
|
||||||
|
) => {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const getEffectiveTerminalStatus = (status: string, lastSeenAt: Date | null) => {
|
const getEffectiveTerminalStatus = (status: string, lastSeenAt: Date | null) => {
|
||||||
if (status === PRINT_TERMINAL_STATUS.disabled) {
|
if (status === PRINT_TERMINAL_STATUS.disabled) {
|
||||||
return 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 payload = parseAssetLabelJobPayload(job.payload_json)
|
||||||
|
const responsePayload = await resolvePrintJobPayloadForResponse(payload, options)
|
||||||
const targetParsed = parseTerminalData(job.targetTerminal)
|
const targetParsed = parseTerminalData(job.targetTerminal)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -511,7 +749,7 @@ const mapPrintJobRecord = (job: PrintJobListRecord) => {
|
|||||||
completed_at: job.completed_at,
|
completed_at: job.completed_at,
|
||||||
created_at: job.created_at,
|
created_at: job.created_at,
|
||||||
updated_at: job.updated_at,
|
updated_at: job.updated_at,
|
||||||
payload,
|
payload: responsePayload,
|
||||||
summary: buildPrintJobSummary(payload),
|
summary: buildPrintJobSummary(payload),
|
||||||
target_terminal: {
|
target_terminal: {
|
||||||
id: job.targetTerminal.id,
|
id: job.targetTerminal.id,
|
||||||
@ -778,8 +1016,9 @@ export const updatePrintTerminalAdminStatus = async (
|
|||||||
throw new PrintServiceError('打印终端不存在', 404)
|
throw new PrintServiceError('打印终端不存在', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextStatus === PRINT_TERMINAL_STATUS.disabled && existing.is_default) {
|
const statusChangeError = getPrintTerminalAdminStatusChangeError(existing, nextStatus)
|
||||||
throw new PrintServiceError('默认打印电脑不能直接停用,请先切换默认终端', 422)
|
if (statusChangeError) {
|
||||||
|
throw new PrintServiceError(statusChangeError, 422)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.printTerminal.update({
|
const updated = await prisma.printTerminal.update({
|
||||||
@ -796,53 +1035,75 @@ export const updatePrintTerminalAdminStatus = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createAssetLabelPrintJob = async (equipmentIdsInput: unknown, requestedBy: number) => {
|
export const createAssetLabelPrintJob = async (input: unknown, requestedBy: number) => {
|
||||||
const equipmentIds = sanitizeEquipmentIds(equipmentIdsInput)
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
const [targetTerminal, payload] = await Promise.all([
|
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(),
|
getDefaultTargetTerminal(),
|
||||||
buildAssetLabelPayload(equipmentIds)
|
buildAssetLabelPayload(equipmentIds)
|
||||||
])
|
])
|
||||||
|
|
||||||
const job = await prisma.printJob.create({
|
const storedRenderBatch = await storeRenderedLabelFiles(renderedLabels)
|
||||||
data: {
|
|
||||||
job_type: PRINT_JOB_TYPE.assetLabel,
|
let job: PrintJobListRecord
|
||||||
status: PRINT_JOB_STATUS.queued,
|
|
||||||
target_terminal_id: targetTerminal.id,
|
try {
|
||||||
requested_by: requestedBy,
|
const payload: AssetLabelJobPayload = {
|
||||||
payload_json: stringifyJson(payload)
|
...basePayload,
|
||||||
},
|
rendered_label_files: storedRenderBatch.storedFiles
|
||||||
include: {
|
}
|
||||||
targetTerminal: {
|
|
||||||
select: {
|
job = await prisma.printJob.create({
|
||||||
id: true,
|
data: {
|
||||||
name: true,
|
job_type: PRINT_JOB_TYPE.assetLabel,
|
||||||
status: true,
|
status: PRINT_JOB_STATUS.queued,
|
||||||
selected_printer_name: true,
|
target_terminal_id: targetTerminal.id,
|
||||||
last_seen_at: true,
|
requested_by: requestedBy,
|
||||||
label_profile_json: true,
|
payload_json: stringifyJson(payload)
|
||||||
printer_snapshot_json: true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
claimedByTerminal: {
|
include: {
|
||||||
select: {
|
targetTerminal: {
|
||||||
id: true,
|
select: {
|
||||||
name: true
|
id: true,
|
||||||
}
|
name: true,
|
||||||
},
|
status: true,
|
||||||
requester: {
|
selected_printer_name: true,
|
||||||
select: {
|
last_seen_at: true,
|
||||||
id: true,
|
label_profile_json: true,
|
||||||
username: true,
|
printer_snapshot_json: true
|
||||||
name: true
|
}
|
||||||
|
},
|
||||||
|
claimedByTerminal: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requester: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
} catch (error) {
|
||||||
|
await removeStoredRenderBatch(storedRenderBatch.batchDirectory)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
const parsedTarget = parseTerminalData(targetTerminal)
|
const parsedTarget = parseTerminalData(targetTerminal)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
job: mapPrintJobRecord(job),
|
job: await mapPrintJobRecord(job),
|
||||||
queue_notice:
|
queue_notice:
|
||||||
parsedTarget.readiness.reason === 'ready'
|
parsedTarget.readiness.reason === 'ready'
|
||||||
? '打印任务已入队,等待默认打印电脑领取'
|
? '打印任务已入队,等待默认打印电脑领取'
|
||||||
@ -909,7 +1170,7 @@ export const listPrintJobs = async (
|
|||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: jobs.map((job) => mapPrintJobRecord(job)),
|
list: await Promise.all(jobs.map((job) => mapPrintJobRecord(job))),
|
||||||
pagination: {
|
pagination: {
|
||||||
page: safePage,
|
page: safePage,
|
||||||
pageSize: safePageSize,
|
pageSize: safePageSize,
|
||||||
@ -938,6 +1199,8 @@ export const cancelQueuedPrintJob = async (jobId: number) => {
|
|||||||
status: PRINT_JOB_STATUS.cancelled
|
status: PRINT_JOB_STATUS.cancelled
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => {
|
export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => {
|
||||||
@ -1191,10 +1454,35 @@ export const claimNextPrintJob = async (terminalId: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
try {
|
||||||
terminal: terminalView,
|
return {
|
||||||
job: mapPrintJobRecord(claimedJob),
|
terminal: terminalView,
|
||||||
message: '领取成功'
|
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 {
|
return {
|
||||||
job: mapPrintJobRecord(updated),
|
job: await mapPrintJobRecord(updated),
|
||||||
message
|
message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
backend/src/utils/__tests__/borrowAnomaly.test.ts
Normal file
80
backend/src/utils/__tests__/borrowAnomaly.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
71
backend/src/utils/__tests__/borrowDepartment.test.ts
Normal file
71
backend/src/utils/__tests__/borrowDepartment.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
93
backend/src/utils/__tests__/equipmentDeletion.test.ts
Normal file
93
backend/src/utils/__tests__/equipmentDeletion.test.ts
Normal file
@ -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
|
||||||
|
}),
|
||||||
|
'该资产已存在盘点记录,无法删除'
|
||||||
|
)
|
||||||
|
})
|
||||||
88
backend/src/utils/__tests__/inventoryScope.test.ts
Normal file
88
backend/src/utils/__tests__/inventoryScope.test.ts
Normal file
@ -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
|
||||||
|
)
|
||||||
|
})
|
||||||
11
backend/src/utils/__tests__/inventoryTaskLifecycle.test.ts
Normal file
11
backend/src/utils/__tests__/inventoryTaskLifecycle.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
57
backend/src/utils/borrowAnomaly.ts
Normal file
57
backend/src/utils/borrowAnomaly.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
82
backend/src/utils/borrowDepartment.ts
Normal file
82
backend/src/utils/borrowDepartment.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/utils/equipmentDeletion.ts
Normal file
41
backend/src/utils/equipmentDeletion.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
70
backend/src/utils/inventoryScope.ts
Normal file
70
backend/src/utils/inventoryScope.ts
Normal file
@ -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 = <T extends { person?: { status?: string | null } | null }>(
|
||||||
|
records: T[]
|
||||||
|
) => {
|
||||||
|
if (records.length !== 1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [record] = records
|
||||||
|
if (!record?.person || record.person.status !== 'active') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return record
|
||||||
|
}
|
||||||
3
backend/src/utils/inventoryTaskLifecycle.ts
Normal file
3
backend/src/utils/inventoryTaskLifecycle.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const getInventoryTaskDeleteBlockedReason = (status?: string | null) => {
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -47,7 +47,7 @@ services:
|
|||||||
HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00}
|
HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00}
|
||||||
HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-}
|
HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-}
|
||||||
HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-3}
|
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_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流}
|
||||||
HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-}
|
HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-}
|
||||||
DOWNLOADS_DIR: /app/downloads
|
DOWNLOADS_DIR: /app/downloads
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"version": "0.2.0",
|
"version": "0.2.5",
|
||||||
"published_at": "2026-03-25T17:45:51+08:00",
|
"published_at": "2026-03-26T21:07:29+08:00",
|
||||||
"filename": "print-agent-setup.exe",
|
"filename": "print-agent-setup.exe",
|
||||||
"notes": "打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。",
|
"notes": "打印助手最新稳定版,当前为安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。",
|
||||||
"source_type": "server_release",
|
"source_type": "server_release",
|
||||||
"relative_path": "downloads/print-agent/latest/print-agent-setup.exe",
|
"relative_path": "downloads/print-agent/latest/print-agent-setup.exe",
|
||||||
"relativePath": "downloads/print-agent/latest/print-agent-setup.exe"
|
"relativePath": "downloads/print-agent/latest/print-agent-setup.exe"
|
||||||
}
|
}
|
||||||
9
downloads/print-agent/releases/0.2.1/manifest.json
Normal file
9
downloads/print-agent/releases/0.2.1/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
9
downloads/print-agent/releases/0.2.3/manifest.json
Normal file
9
downloads/print-agent/releases/0.2.3/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
9
downloads/print-agent/releases/0.2.4/manifest.json
Normal file
9
downloads/print-agent/releases/0.2.4/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
9
downloads/print-agent/releases/0.2.5/manifest.json
Normal file
9
downloads/print-agent/releases/0.2.5/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
14
frontend/scripts/test-auth-mode.ts
Normal file
14
frontend/scripts/test-auth-mode.ts
Normal file
@ -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')
|
||||||
38
frontend/scripts/test-borrow-anomaly.ts
Normal file
38
frontend/scripts/test-borrow-anomaly.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
78
frontend/scripts/test-borrow-manual-search.ts
Normal file
78
frontend/scripts/test-borrow-manual-search.ts
Normal file
@ -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')
|
||||||
103
frontend/scripts/test-borrow-person-search.ts
Normal file
103
frontend/scripts/test-borrow-person-search.ts
Normal file
@ -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')
|
||||||
29
frontend/scripts/test-borrow-scan-state.ts
Normal file
29
frontend/scripts/test-borrow-scan-state.ts
Normal file
@ -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')
|
||||||
76
frontend/scripts/test-notification-quick-return.ts
Normal file
76
frontend/scripts/test-notification-quick-return.ts
Normal file
@ -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')
|
||||||
80
frontend/scripts/test-return-manual-search.ts
Normal file
80
frontend/scripts/test-return-manual-search.ts
Normal file
@ -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')
|
||||||
86
frontend/scripts/test-return-record-search.ts
Normal file
86
frontend/scripts/test-return-record-search.ts
Normal file
@ -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')
|
||||||
29
frontend/scripts/test-return-scan-state.ts
Normal file
29
frontend/scripts/test-return-scan-state.ts
Normal file
@ -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')
|
||||||
@ -7,6 +7,13 @@ import type {
|
|||||||
StorageLocation
|
StorageLocation
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
|
export type BorrowDepartmentAction = 'keep' | 'clear' | 'set'
|
||||||
|
|
||||||
|
export interface ReturnDepartmentPayload {
|
||||||
|
department_action?: BorrowDepartmentAction
|
||||||
|
department_id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScanBorrowResult {
|
export interface ScanBorrowResult {
|
||||||
id: number
|
id: number
|
||||||
equipment_code: string
|
equipment_code: string
|
||||||
@ -19,6 +26,25 @@ export interface ScanBorrowResult {
|
|||||||
storageLocation?: StorageLocation | null
|
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: {
|
export const borrowEquipment = async (data: {
|
||||||
equipment_id?: number
|
equipment_id?: number
|
||||||
@ -73,7 +99,7 @@ export const returnEquipment = async (data: {
|
|||||||
return_condition?: string
|
return_condition?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
confirm_children_diff?: boolean
|
confirm_children_diff?: boolean
|
||||||
}) => {
|
} & ReturnDepartmentPayload) => {
|
||||||
const response = await api.post<ApiResponse<BorrowRecord>>('/return', data)
|
const response = await api.post<ApiResponse<BorrowRecord>>('/return', data)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
@ -86,7 +112,7 @@ export const scanReturn = async (data: {
|
|||||||
person_id?: number
|
person_id?: number
|
||||||
borrow_record_id?: number
|
borrow_record_id?: number
|
||||||
confirm_children_diff?: boolean
|
confirm_children_diff?: boolean
|
||||||
}) => {
|
} & ReturnDepartmentPayload) => {
|
||||||
const response = await api.post<
|
const response = await api.post<
|
||||||
ApiResponse<{
|
ApiResponse<{
|
||||||
id: number
|
id: number
|
||||||
@ -139,6 +165,44 @@ export const getBorrowRecordByCode = async (equipmentCode: string) => {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const searchReturnAssets = async (params: { keyword?: string }) => {
|
||||||
|
const response = await api.get<ApiResponse<ReturnSearchAssetCandidate[]>>('/return/search-assets', {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveBorrowAnomalyAsReturned = async (equipmentId: number) => {
|
||||||
|
const response = await api.post<ApiResponse<{
|
||||||
|
equipment_id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
status: string
|
||||||
|
remaining_quantity: number
|
||||||
|
}>>(`/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<ApiResponse<{
|
||||||
|
id: number
|
||||||
|
equipment_id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
borrow_time: string
|
||||||
|
status: string
|
||||||
|
children_count: number
|
||||||
|
}>>(`/borrow/anomalies/${equipmentId}/register-borrow`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// 获取借用记录列表
|
// 获取借用记录列表
|
||||||
export const getBorrowRecordList = async (params: {
|
export const getBorrowRecordList = async (params: {
|
||||||
page?: number
|
page?: number
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||||
import type { ApiResponse } from '@/types'
|
import type { ApiResponse } from '@/types'
|
||||||
|
import { resolveApiBaseUrl, resolveAuthMode } from '@/utils/authMode'
|
||||||
|
|
||||||
// 创建axios实例
|
// 创建axios实例
|
||||||
const api: AxiosInstance = axios.create({
|
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,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'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(
|
api.interceptors.response.use(
|
||||||
@ -76,7 +116,7 @@ api.interceptors.response.use(
|
|||||||
console.error('请求失败')
|
console.error('请求失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(createApiRequestError(error))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,11 @@ import type {
|
|||||||
|
|
||||||
export interface InventoryTaskPreviewPayload {
|
export interface InventoryTaskPreviewPayload {
|
||||||
department_id?: number | null
|
department_id?: number | null
|
||||||
|
department_ids?: number[]
|
||||||
asset_type_id?: number | null
|
asset_type_id?: number | null
|
||||||
|
asset_type_ids?: number[]
|
||||||
|
storage_location_id?: number | null
|
||||||
|
storage_location_ids?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload {
|
export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload {
|
||||||
@ -48,6 +52,11 @@ export const closeInventoryTask = async (id: number) => {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const deleteInventoryTask = async (id: number) => {
|
||||||
|
const response = await api.delete<ApiResponse<null>>(`/inventory/tasks/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export const getInventoryTaskUnassignedItems = async (id: number) => {
|
export const getInventoryTaskUnassignedItems = async (id: number) => {
|
||||||
const response = await api.get<ApiResponse<InventoryTaskItem[]>>(`/inventory/tasks/${id}/unassigned`)
|
const response = await api.get<ApiResponse<InventoryTaskItem[]>>(`/inventory/tasks/${id}/unassigned`)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import api from './index'
|
import api from './index'
|
||||||
|
import { resolveApiBaseUrl } from '@/utils/authMode'
|
||||||
import type {
|
import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
CreateAssetLabelPrintJobPayload,
|
CreateAssetLabelPrintJobPayload,
|
||||||
@ -20,7 +21,7 @@ import type {
|
|||||||
UpdatePrintTerminalStatusPayload
|
UpdatePrintTerminalStatusPayload
|
||||||
} from '@/types'
|
} 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 downloadBaseUrl = apiBaseUrl.replace(/\/api\/?$/, '')
|
||||||
|
|
||||||
const getAuthHeaders = () => {
|
const getAuthHeaders = () => {
|
||||||
@ -325,8 +326,9 @@ export const getPrintTerminals = async () => {
|
|||||||
return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal)
|
return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createAssetLabelPrintJob = async (equipmentIds: number[]): Promise<CreateAssetLabelPrintJobResult> => {
|
export const createAssetLabelPrintJob = async (
|
||||||
const payload: CreateAssetLabelPrintJobPayload = { equipment_ids: equipmentIds }
|
payload: CreateAssetLabelPrintJobPayload
|
||||||
|
): Promise<CreateAssetLabelPrintJobResult> => {
|
||||||
const response = await api.post<ApiResponse<unknown>>('/print-jobs/asset-labels', payload)
|
const response = await api.post<ApiResponse<unknown>>('/print-jobs/asset-labels', payload)
|
||||||
return {
|
return {
|
||||||
job: normalizePrintJob(response.data.data),
|
job: normalizePrintJob(response.data.data),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { AxiosResponse } from 'axios'
|
import type { AxiosResponse } from 'axios'
|
||||||
|
import { resolveApiBaseUrl } from '@/utils/authMode'
|
||||||
import type {
|
import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
InventoryTaskItem,
|
InventoryTaskItem,
|
||||||
@ -10,7 +11,7 @@ import type {
|
|||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const resolvePublicInventoryBaseUrl = () => {
|
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`
|
return `${baseUrl.replace(/\/$/, '')}/public/inventory`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,9 @@ export const previewHrSync = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const runHrSync = async () => {
|
export const runHrSync = async () => {
|
||||||
const response = await api.post<ApiResponse<HrSyncResult>>('/sync/hr/run')
|
const response = await api.post<ApiResponse<HrSyncResult>>('/sync/hr/run', undefined, {
|
||||||
|
timeout: 180000
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Keycloak from 'keycloak-js'
|
import Keycloak from 'keycloak-js'
|
||||||
|
|
||||||
const keycloak = new Keycloak({
|
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',
|
realm: import.meta.env.VITE_KEYCLOAK_REALM || 'equipment-management',
|
||||||
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'equipment-client'
|
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'equipment-client'
|
||||||
})
|
})
|
||||||
|
|||||||
@ -137,7 +137,7 @@ const LABEL_PRESET = {
|
|||||||
headerHeightPx: 82,
|
headerHeightPx: 82,
|
||||||
qrPanelWidthPx: 324,
|
qrPanelWidthPx: 324,
|
||||||
qrCodeSizePx: 286,
|
qrCodeSizePx: 286,
|
||||||
companyName: '常州简尚家具有限公司'
|
companyName: '常州简尚家居有限公司'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const equipmentList = ref<EquipmentBarcode[]>([])
|
const equipmentList = ref<EquipmentBarcode[]>([])
|
||||||
@ -724,7 +724,20 @@ const handleRemotePrint = async () => {
|
|||||||
|
|
||||||
remoteSubmitting.value = true
|
remoteSubmitting.value = true
|
||||||
try {
|
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}`)
|
alert(result.queue_notice || `标签已发送到 ${defaultTerminal.value.name}`)
|
||||||
|
|
||||||
|
|||||||
403
frontend/src/components/BorrowAnomalyHandler.vue
Normal file
403
frontend/src/components/BorrowAnomalyHandler.vue
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="modelValue && asset" class="sheet-backdrop" @click.self="close">
|
||||||
|
<div class="sheet-panel max-w-3xl">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="page-kicker">Borrow Exception</p>
|
||||||
|
<h3 class="text-xl font-semibold text-text-primary">异常借出处理</h3>
|
||||||
|
<p class="mt-2 text-sm text-text-tertiary">
|
||||||
|
这台资产当前标记为已借出,但系统里没有借用单。请按真实情况处理,不要猜。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-icon" @click="close" aria-label="关闭异常借出处理弹窗">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<section class="card">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 class="text-lg font-semibold text-text-primary">{{ asset.equipmentName }}</h4>
|
||||||
|
<span class="badge badge-warning">借用单缺失</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-text-tertiary">
|
||||||
|
<span class="font-mono">{{ asset.equipmentCode }}</span>
|
||||||
|
<span>{{ asset.equipmentType || '未分类' }}</span>
|
||||||
|
<span>{{ asset.equipmentModel || '未填写型号' }}</span>
|
||||||
|
<span>{{ asset.storageLocationName || '未设置地址' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
|
||||||
|
{{ asset.message || '请根据真实业务情况补处理' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel space-y-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:class="{ 'btn-secondary--highlight': mode === 'mark-returned' }"
|
||||||
|
@click="mode = 'mark-returned'"
|
||||||
|
>
|
||||||
|
确认已归还
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:class="{ 'btn-secondary--highlight': mode === 'register-borrow' }"
|
||||||
|
@click="mode = 'register-borrow'"
|
||||||
|
>
|
||||||
|
补登记借出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="feedback"
|
||||||
|
class="rounded-lg border px-4 py-3 text-sm"
|
||||||
|
:class="feedback.type === 'error'
|
||||||
|
? 'border-status-error/30 bg-status-error/10 text-status-error'
|
||||||
|
: 'border-status-warning/30 bg-status-warning/10 text-status-warning'"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="mode === 'mark-returned'">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
适用于这台资产其实已经回来了,只是系统缺借用单,导致行政没法正常入库。
|
||||||
|
</p>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-border bg-bg-secondary px-4 py-3">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-text-tertiary">处理后状态</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-text-primary">恢复为可用</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-bg-secondary px-4 py-3">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-text-tertiary">同步规则</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-text-primary">库存与价值按现有系统规则重算</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="field-stack">
|
||||||
|
<label class="field-label">使用者 *</label>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input
|
||||||
|
v-model.trim="personKeyword"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
|
||||||
|
@keyup.enter="handlePersonKeywordSubmit"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-text-tertiary">
|
||||||
|
补登记借出时,只能选择系统里当前启用中的人员。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="showPersonSearchResults" class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-3 px-1">
|
||||||
|
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p>
|
||||||
|
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
||||||
|
{{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredPersons.length > 0" class="touch-option-list">
|
||||||
|
<button
|
||||||
|
v-for="person in filteredPersons"
|
||||||
|
:key="person.id"
|
||||||
|
type="button"
|
||||||
|
class="touch-option"
|
||||||
|
:class="{ 'is-active': selectedPersonId === person.id }"
|
||||||
|
@click="selectPerson(person)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-text-primary">
|
||||||
|
{{ person.nickname }} · {{ person.real_name }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
{{ person.department || '未填写部门' }}
|
||||||
|
<span v-if="person.phone"> · {{ person.phone }}</span>
|
||||||
|
<span v-if="person.id_number"> · {{ person.id_number }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="py-4 text-center text-sm text-text-tertiary">
|
||||||
|
当前没有匹配到使用者,请换个关键词再试。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field-stack">
|
||||||
|
<label class="field-label">借出日期 *</label>
|
||||||
|
<input
|
||||||
|
v-model="borrowDate"
|
||||||
|
type="date"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-text-tertiary">
|
||||||
|
这里必须按真实借出日期手动选择,系统不会自动帮你猜。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-stack">
|
||||||
|
<label class="field-label">补登记说明</label>
|
||||||
|
<textarea
|
||||||
|
v-model.trim="notes"
|
||||||
|
class="input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="可选填写,例如:历史漏建借用单"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<button type="button" class="btn-secondary" @click="close" :disabled="submitting">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="mode === 'mark-returned'"
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submitMarkReturned"
|
||||||
|
>
|
||||||
|
{{ submitting ? '处理中...' : '确认已归还' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="submitting || !canSubmitRegisterBorrow"
|
||||||
|
@click="submitRegisterBorrow"
|
||||||
|
>
|
||||||
|
{{ submitting ? '处理中...' : '确认补登记借出' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { getPersonOptions } from '@/api/person'
|
||||||
|
import {
|
||||||
|
registerBorrowAnomaly,
|
||||||
|
resolveBorrowAnomalyAsReturned
|
||||||
|
} from '@/api/borrow'
|
||||||
|
import type { Person } from '@/types'
|
||||||
|
import {
|
||||||
|
buildManualBorrowPersonDisplay,
|
||||||
|
filterManualBorrowPersons,
|
||||||
|
findPreferredManualBorrowPerson,
|
||||||
|
isManualBorrowSelectedPersonKeyword,
|
||||||
|
shouldShowManualBorrowPersonResults
|
||||||
|
} from '@/views/borrow/manualBorrowPersonSearch'
|
||||||
|
|
||||||
|
interface BorrowAnomalyAssetSummary {
|
||||||
|
equipmentId: number
|
||||||
|
equipmentCode: string
|
||||||
|
equipmentName: string
|
||||||
|
equipmentModel?: string | null
|
||||||
|
equipmentType?: string | null
|
||||||
|
storageLocationName?: string | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
asset: BorrowAnomalyAssetSummary | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
handled: [
|
||||||
|
payload: {
|
||||||
|
action: 'mark-returned' | 'register-borrow'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mode = ref<'mark-returned' | 'register-borrow'>('mark-returned')
|
||||||
|
const persons = ref<Person[]>([])
|
||||||
|
const personsLoading = ref(false)
|
||||||
|
const selectedPersonId = ref<number | null>(null)
|
||||||
|
const personKeyword = ref('')
|
||||||
|
const borrowDate = ref('')
|
||||||
|
const notes = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const feedback = ref<{ type: 'warning' | 'error'; message: string } | null>(null)
|
||||||
|
|
||||||
|
const selectedPerson = computed(() => {
|
||||||
|
if (selectedPersonId.value === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return persons.value.find((person) => person.id === selectedPersonId.value) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredPersons = computed(() => filterManualBorrowPersons(persons.value, personKeyword.value).slice(0, 8))
|
||||||
|
const showPersonSearchResults = computed(() =>
|
||||||
|
shouldShowManualBorrowPersonResults(selectedPerson.value, personKeyword.value)
|
||||||
|
)
|
||||||
|
const canSubmitRegisterBorrow = computed(() =>
|
||||||
|
selectedPersonId.value !== null && Boolean(borrowDate.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
mode.value = 'mark-returned'
|
||||||
|
selectedPersonId.value = null
|
||||||
|
personKeyword.value = ''
|
||||||
|
borrowDate.value = ''
|
||||||
|
notes.value = ''
|
||||||
|
feedback.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensurePersonsLoaded = async () => {
|
||||||
|
if (persons.value.length > 0 || personsLoading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
personsLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await getPersonOptions()
|
||||||
|
persons.value = response.data || []
|
||||||
|
} finally {
|
||||||
|
personsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPerson = (person: Person) => {
|
||||||
|
selectedPersonId.value = person.id
|
||||||
|
personKeyword.value = buildManualBorrowPersonDisplay(person)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePersonKeywordSubmit = async () => {
|
||||||
|
await ensurePersonsLoaded()
|
||||||
|
|
||||||
|
const preferredPerson = findPreferredManualBorrowPerson(filteredPersons.value, personKeyword.value)
|
||||||
|
if (preferredPerson) {
|
||||||
|
selectPerson(preferredPerson)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredPersons.value.length > 1) {
|
||||||
|
feedback.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '匹配到多位使用者,请从下方结果中点选正确人员。'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personKeyword.value.trim()) {
|
||||||
|
feedback.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '没有匹配到使用者,请换个关键词再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitMarkReturned = async () => {
|
||||||
|
if (!props.asset) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
feedback.value = null
|
||||||
|
try {
|
||||||
|
const response = await resolveBorrowAnomalyAsReturned(props.asset.equipmentId)
|
||||||
|
emit('handled', {
|
||||||
|
action: 'mark-returned',
|
||||||
|
message: response.message || `${props.asset.equipmentCode} 已按异常借出处理为归还完成`
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
resetForm()
|
||||||
|
} catch (error: any) {
|
||||||
|
feedback.value = {
|
||||||
|
type: 'error',
|
||||||
|
message: error?.message || '异常借出处理失败'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRegisterBorrow = async () => {
|
||||||
|
if (!props.asset || !selectedPersonId.value || !borrowDate.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
feedback.value = null
|
||||||
|
try {
|
||||||
|
const response = await registerBorrowAnomaly(props.asset.equipmentId, {
|
||||||
|
person_id: selectedPersonId.value,
|
||||||
|
borrow_time: borrowDate.value,
|
||||||
|
notes: notes.value || undefined
|
||||||
|
})
|
||||||
|
emit('handled', {
|
||||||
|
action: 'register-borrow',
|
||||||
|
message: response.message || `${props.asset.equipmentCode} 已补登记借出`
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
resetForm()
|
||||||
|
} catch (error: any) {
|
||||||
|
feedback.value = {
|
||||||
|
type: 'error',
|
||||||
|
message: error?.message || '补登记借出失败'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (value) => {
|
||||||
|
if (!value) {
|
||||||
|
resetForm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensurePersonsLoaded()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.asset?.equipmentId,
|
||||||
|
() => {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => personKeyword.value,
|
||||||
|
(value) => {
|
||||||
|
const shouldKeepCurrentSelection =
|
||||||
|
selectedPerson.value !== null &&
|
||||||
|
isManualBorrowSelectedPersonKeyword(selectedPerson.value, value)
|
||||||
|
|
||||||
|
if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) {
|
||||||
|
selectedPersonId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@ -7,6 +7,7 @@ import keycloak from './auth/keycloak'
|
|||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import api from './api'
|
import api from './api'
|
||||||
import type { User } from './types'
|
import type { User } from './types'
|
||||||
|
import { resolveAuthMode } from './utils/authMode'
|
||||||
|
|
||||||
const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/'
|
const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/'
|
||||||
|
|
||||||
@ -18,7 +19,7 @@ const bootstrap = async () => {
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
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)
|
const isPublicInventoryRoute = window.location.pathname.startsWith(PUBLIC_INVENTORY_ROUTE_PREFIX)
|
||||||
|
|
||||||
if (authMode === 'local') {
|
if (authMode === 'local') {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import keycloak from '@/auth/keycloak'
|
import keycloak from '@/auth/keycloak'
|
||||||
|
import { resolveAuthMode } from '@/utils/authMode'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@ -111,7 +112,7 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
|
||||||
const isAuthenticated =
|
const isAuthenticated =
|
||||||
authMode === 'local'
|
authMode === 'local'
|
||||||
? authStore.isAuthenticated
|
? authStore.isAuthenticated
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
import keycloak from '@/auth/keycloak'
|
import keycloak from '@/auth/keycloak'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import { resolveAuthMode } from '@/utils/authMode'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null
|
user: User | null
|
||||||
@ -34,7 +35,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.isAuthenticated = false
|
this.isAuthenticated = false
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
try {
|
try {
|
||||||
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
|
||||||
if (authMode === 'local') {
|
if (authMode === 'local') {
|
||||||
await api.post('/auth/logout')
|
await api.post('/auth/logout')
|
||||||
} else if (keycloak.authenticated) {
|
} else if (keycloak.authenticated) {
|
||||||
@ -46,4 +47,3 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,15 @@ export type EquipmentType = string
|
|||||||
// 资产状态
|
// 资产状态
|
||||||
export type EquipmentStatus = '可用' | '已借出' | '维护中' | '已损坏' | '待报废' | '已报废'
|
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'
|
export type CodeGenerationMode = 'auto' | 'manual'
|
||||||
|
|
||||||
@ -140,6 +149,7 @@ export interface Equipment {
|
|||||||
unit_price?: number
|
unit_price?: number
|
||||||
} | null
|
} | null
|
||||||
current_borrow?: EquipmentCurrentBorrow | null
|
current_borrow?: EquipmentCurrentBorrow | null
|
||||||
|
borrow_anomaly?: BorrowAnomalyInfo | null
|
||||||
children?: Array<{
|
children?: Array<{
|
||||||
id: number
|
id: number
|
||||||
equipment_code: string
|
equipment_code: string
|
||||||
@ -239,6 +249,9 @@ export interface InventoryScopeEntity {
|
|||||||
export interface InventoryTaskScopeSnapshot {
|
export interface InventoryTaskScopeSnapshot {
|
||||||
scope_department: InventoryScopeEntity | null
|
scope_department: InventoryScopeEntity | null
|
||||||
scope_asset_type: InventoryScopeEntity | null
|
scope_asset_type: InventoryScopeEntity | null
|
||||||
|
scope_departments: InventoryScopeEntity[]
|
||||||
|
scope_asset_types: InventoryScopeEntity[]
|
||||||
|
scope_storage_locations: InventoryScopeEntity[]
|
||||||
participants_count: number
|
participants_count: number
|
||||||
items_count: number
|
items_count: number
|
||||||
unassigned_count: number
|
unassigned_count: number
|
||||||
@ -288,6 +301,9 @@ export interface InventoryPreviewItemSample {
|
|||||||
export interface InventoryTaskPreview {
|
export interface InventoryTaskPreview {
|
||||||
scope_department: InventoryScopeEntity | null
|
scope_department: InventoryScopeEntity | null
|
||||||
scope_asset_type: InventoryScopeEntity | null
|
scope_asset_type: InventoryScopeEntity | null
|
||||||
|
scope_departments: InventoryScopeEntity[]
|
||||||
|
scope_asset_types: InventoryScopeEntity[]
|
||||||
|
scope_storage_locations: InventoryScopeEntity[]
|
||||||
participants_count: number
|
participants_count: number
|
||||||
items_count: number
|
items_count: number
|
||||||
unassigned_count: number
|
unassigned_count: number
|
||||||
@ -317,6 +333,10 @@ export interface InventoryTaskListItem {
|
|||||||
closed_at?: string | null
|
closed_at?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
scope_snapshot?: InventoryTaskScopeSnapshot | null
|
||||||
|
scope_departments?: InventoryScopeEntity[]
|
||||||
|
scope_asset_types?: InventoryScopeEntity[]
|
||||||
|
scope_storage_locations?: InventoryScopeEntity[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryTaskParticipant {
|
export interface InventoryTaskParticipant {
|
||||||
@ -415,6 +435,10 @@ export interface PublicInventoryTaskInfo {
|
|||||||
unassigned_count: number
|
unassigned_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
closed_at?: string | null
|
closed_at?: string | null
|
||||||
|
scope_snapshot?: InventoryTaskScopeSnapshot | null
|
||||||
|
scope_departments?: InventoryScopeEntity[]
|
||||||
|
scope_asset_types?: InventoryScopeEntity[]
|
||||||
|
scope_storage_locations?: InventoryScopeEntity[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicInventoryParticipantOption {
|
export interface PublicInventoryParticipantOption {
|
||||||
@ -433,6 +457,10 @@ export interface PublicInventorySessionTask {
|
|||||||
status: InventoryTaskStatus | string
|
status: InventoryTaskStatus | string
|
||||||
scope_department_name?: string | null
|
scope_department_name?: string | null
|
||||||
scope_asset_type_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
|
completed_count: number
|
||||||
exception_count: number
|
exception_count: number
|
||||||
items_count: number
|
items_count: number
|
||||||
@ -595,6 +623,7 @@ export interface HrSyncStatus {
|
|||||||
complete: boolean
|
complete: boolean
|
||||||
missingFields: string[]
|
missingFields: string[]
|
||||||
scheduleTimes: string[]
|
scheduleTimes: string[]
|
||||||
|
scheduleTimezone?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HrSyncResult {
|
export interface HrSyncResult {
|
||||||
@ -657,6 +686,20 @@ export interface HrSyncChangeItem {
|
|||||||
created_at: string
|
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 {
|
export interface HrSyncJobDetail extends HrSyncJobHistoryItem {
|
||||||
changes: HrSyncChangeItem[]
|
changes: HrSyncChangeItem[]
|
||||||
}
|
}
|
||||||
@ -775,6 +818,11 @@ export interface PrintJobPayload {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssetLabelRenderedImage {
|
||||||
|
equipment_id: number
|
||||||
|
image_data_url: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PrintJob {
|
export interface PrintJob {
|
||||||
id: number
|
id: number
|
||||||
job_type: PrintJobType | string
|
job_type: PrintJobType | string
|
||||||
@ -806,6 +854,7 @@ export interface PrintJobListData extends PaginatedResponse<PrintJob> {}
|
|||||||
|
|
||||||
export interface CreateAssetLabelPrintJobPayload {
|
export interface CreateAssetLabelPrintJobPayload {
|
||||||
equipment_ids: number[]
|
equipment_ids: number[]
|
||||||
|
rendered_labels: AssetLabelRenderedImage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAssetLabelPrintJobResult {
|
export interface CreateAssetLabelPrintJobResult {
|
||||||
|
|||||||
11
frontend/src/utils/authMode.ts
Normal file
11
frontend/src/utils/authMode.ts
Normal file
@ -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'
|
||||||
|
}
|
||||||
15
frontend/src/utils/borrowAnomaly.ts
Normal file
15
frontend/src/utils/borrowAnomaly.ts
Normal file
@ -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)
|
||||||
@ -85,6 +85,7 @@ import keycloak from '@/auth/keycloak'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
import { useSiteStore } from '@/stores/site'
|
import { useSiteStore } from '@/stores/site'
|
||||||
|
import { resolveAuthMode } from '@/utils/authMode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@ -94,7 +95,7 @@ const password = ref('')
|
|||||||
const errorText = ref('')
|
const errorText = ref('')
|
||||||
const siteStore = useSiteStore()
|
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 isLocalAuth = computed(() => authMode === 'local')
|
||||||
const siteName = computed(() => siteStore.siteName || '固定资产管理系统')
|
const siteName = computed(() => siteStore.siteName || '固定资产管理系统')
|
||||||
|
|
||||||
|
|||||||
@ -45,21 +45,69 @@
|
|||||||
<h4 class="form-panel__title">手动输入</h4>
|
<h4 class="form-panel__title">手动输入</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<label class="field-label">资产编码</label>
|
<label class="field-label">资产搜索</label>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
ref="scanInputRef"
|
ref="scanInputRef"
|
||||||
v-model="scanCode"
|
v-model.trim="manualKeyword"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="输入编码后回车"
|
placeholder="输入资产编码 / 名称 / 型号 / 地址搜索"
|
||||||
@keyup.enter="handleScan"
|
@keyup.enter="handleManualSubmit"
|
||||||
@focus="handleFocus"
|
|
||||||
/>
|
/>
|
||||||
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[100px] text-sm" @click="handleScan">
|
<button
|
||||||
读取
|
type="button"
|
||||||
|
class="btn-secondary w-full sm:w-auto sm:min-w-[120px] text-sm"
|
||||||
|
@click="handleManualSubmit"
|
||||||
|
>
|
||||||
|
按编码读取
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-text-tertiary">支持搜索编码 / 名称 / 型号 / 地址,只显示当前可借资产。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="manualKeyword.trim()" class="panel">
|
||||||
|
<div class="list-header">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-text-primary">匹配结果</p>
|
||||||
|
<p class="mt-1 text-xs text-text-tertiary">点一条即可带入,不用再手抄资产编码。</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-text-tertiary">
|
||||||
|
{{ manualSearchLoading ? '搜索中...' : `共 ${manualSearchResults.length} 条` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="manualSearchResults.length > 0" class="mt-3 space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="item in manualSearchResults"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="touch-option w-full text-left"
|
||||||
|
@click="selectManualSearchResult(item)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-text-primary">
|
||||||
|
{{ item.equipment_name }}
|
||||||
|
<span class="text-text-tertiary">· {{ item.equipment_code }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
{{ item.equipment_type || '未分类' }}
|
||||||
|
<span v-if="item.equipment_model"> · {{ item.equipment_model }}</span>
|
||||||
|
<span v-if="item.storageLocation?.name"> · {{ item.storageLocation.name }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-success">可借 {{ item.remaining_quantity }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="manualSearchLoading" class="py-4 text-center text-sm text-text-tertiary">
|
||||||
|
正在搜索可借资产...
|
||||||
|
</p>
|
||||||
|
<p v-else class="py-4 text-center text-sm text-text-tertiary">
|
||||||
|
没有匹配到可借资产。若你已经知道准确编码,也可以直接点“按编码读取”。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -123,25 +171,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between pt-3 border-t border-border mt-3">
|
<div class="pt-3 mt-3 border-t border-border">
|
||||||
<p class="text-xs text-text-secondary">
|
<p class="text-xs text-text-secondary">
|
||||||
核对无误后再出库
|
资产信息已带入,确认无误后可直接选择使用者并出库。
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
v-if="!equipmentConfirmed"
|
|
||||||
class="btn-primary text-sm"
|
|
||||||
@click="equipmentConfirmed = true"
|
|
||||||
>
|
|
||||||
确认
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="badge badge-success"
|
|
||||||
>
|
|
||||||
已确认
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@ -165,42 +198,45 @@
|
|||||||
v-model.trim="personKeyword"
|
v-model.trim="personKeyword"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="输入花名 / 姓名 / 证件号进行搜索"
|
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
|
||||||
|
@keyup.enter="handlePersonKeywordSubmit"
|
||||||
/>
|
/>
|
||||||
<select
|
<p class="text-xs text-text-tertiary">
|
||||||
v-model.number="selectedPersonId"
|
输入后会直接搜索人员,点选即可;如果只匹配到 1 人,按回车也能直接选中。
|
||||||
class="select hidden md:block"
|
</p>
|
||||||
>
|
<div v-if="showPersonSearchResults" class="space-y-2">
|
||||||
<option value="" disabled>请选择使用者</option>
|
<div class="flex items-center justify-between gap-3 px-1">
|
||||||
<option
|
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p>
|
||||||
v-for="person in filteredPersons"
|
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
||||||
:key="person.id"
|
{{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }}
|
||||||
:value="person.id"
|
</p>
|
||||||
>
|
</div>
|
||||||
{{ person.nickname }} · {{ person.real_name }}{{ person.department ? `(${person.department})` : '' }}
|
<div v-if="topFilteredPersons.length > 0" class="touch-option-list">
|
||||||
</option>
|
<button
|
||||||
</select>
|
v-for="person in topFilteredPersons"
|
||||||
<div class="touch-option-list md:hidden">
|
:key="person.id"
|
||||||
<button
|
type="button"
|
||||||
v-for="person in topFilteredPersons"
|
class="touch-option"
|
||||||
:key="person.id"
|
:class="{ 'is-active': selectedPersonId === person.id }"
|
||||||
type="button"
|
@click="selectPerson(person)"
|
||||||
class="touch-option"
|
>
|
||||||
:class="{ 'is-active': selectedPersonId === person.id }"
|
<div class="flex items-start justify-between gap-3">
|
||||||
@click="selectedPersonId = person.id"
|
<div>
|
||||||
>
|
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
<div>
|
{{ person.department || '未填写部门' }}
|
||||||
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
<span v-if="person.phone"> · {{ person.phone }}</span>
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
<span v-if="person.id_number"> · {{ person.id_number }}</span>
|
||||||
{{ person.department || '未填写部门' }}
|
</p>
|
||||||
<span v-if="person.phone"> · {{ person.phone }}</span>
|
</div>
|
||||||
</p>
|
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<p v-else-if="personSearchLoading" class="text-center text-sm text-text-tertiary py-4">
|
||||||
<p v-if="topFilteredPersons.length === 0" class="text-center text-sm text-text-tertiary py-4">
|
正在搜索使用者...
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-center text-sm text-text-tertiary py-4">
|
||||||
当前没有匹配到使用者,请换个关键词再试。
|
当前没有匹配到使用者,请换个关键词再试。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -216,28 +252,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedPerson">
|
|
||||||
<div class="panel">
|
|
||||||
<div class="field-grid text-sm">
|
|
||||||
<div>
|
|
||||||
<span class="text-text-tertiary">花名:</span>
|
|
||||||
<span class="text-text-primary">{{ selectedPerson.nickname }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-text-tertiary">姓名:</span>
|
|
||||||
<span class="text-text-primary">{{ selectedPerson.real_name }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedPerson.department">
|
|
||||||
<span class="text-text-tertiary">部门:</span>
|
|
||||||
<span class="text-text-primary">{{ selectedPerson.department }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedPerson.phone">
|
|
||||||
<span class="text-text-tertiary">电话:</span>
|
|
||||||
<span class="text-text-primary">{{ selectedPerson.phone }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<label class="field-label">预计归还时间</label>
|
<label class="field-label">预计归还时间</label>
|
||||||
@ -304,7 +318,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { scanEquipment } from '@/api/equipment'
|
import { getEquipmentList, scanEquipment } from '@/api/equipment'
|
||||||
import { scanBorrow } from '@/api/borrow'
|
import { scanBorrow } from '@/api/borrow'
|
||||||
import { getPersonList } from '@/api/person'
|
import { getPersonList } from '@/api/person'
|
||||||
import { getChildren } from '@/api/mount'
|
import { getChildren } from '@/api/mount'
|
||||||
@ -312,6 +326,18 @@ import type { Equipment, Person } from '@/types'
|
|||||||
import type { ChildEquipment } from '@/api/mount'
|
import type { ChildEquipment } from '@/api/mount'
|
||||||
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
|
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
|
||||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||||
|
import { canSubmitBorrowScan } from './borrowScanState'
|
||||||
|
import {
|
||||||
|
filterManualBorrowCandidates,
|
||||||
|
findPreferredManualBorrowCandidate
|
||||||
|
} from './manualBorrowSearch'
|
||||||
|
import {
|
||||||
|
buildManualBorrowPersonDisplay,
|
||||||
|
filterManualBorrowPersons,
|
||||||
|
findPreferredManualBorrowPerson,
|
||||||
|
isManualBorrowSelectedPersonKeyword,
|
||||||
|
shouldShowManualBorrowPersonResults
|
||||||
|
} from './manualBorrowPersonSearch'
|
||||||
|
|
||||||
const scanCode = ref('')
|
const scanCode = ref('')
|
||||||
const scanInputRef = ref<HTMLInputElement>()
|
const scanInputRef = ref<HTMLInputElement>()
|
||||||
@ -319,7 +345,6 @@ const route = useRoute()
|
|||||||
const showCameraScanner = ref(false)
|
const showCameraScanner = ref(false)
|
||||||
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
|
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
|
||||||
const equipmentInfo = ref<Equipment | null>(null)
|
const equipmentInfo = ref<Equipment | null>(null)
|
||||||
const equipmentConfirmed = ref(false)
|
|
||||||
const mountedChildren = ref<ChildEquipment[]>([])
|
const mountedChildren = ref<ChildEquipment[]>([])
|
||||||
const mountedChildrenValue = ref(0)
|
const mountedChildrenValue = ref(0)
|
||||||
const successInfo = ref<{
|
const successInfo = ref<{
|
||||||
@ -336,33 +361,28 @@ const borrowForm = ref({
|
|||||||
is_long_term: false
|
is_long_term: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const manualKeyword = ref('')
|
||||||
|
const manualSearchResults = ref<Equipment[]>([])
|
||||||
|
const manualSearchLoading = ref(false)
|
||||||
const personKeyword = ref('')
|
const personKeyword = ref('')
|
||||||
const selectedPersonId = ref<number | null>(null)
|
const selectedPersonId = ref<number | null>(null)
|
||||||
const persons = ref<Person[]>([])
|
const persons = ref<Person[]>([])
|
||||||
let searchTimer: number | null = null
|
let equipmentSearchTimer: number | null = null
|
||||||
|
let personSearchTimer: number | null = null
|
||||||
|
let latestEquipmentRequestId = 0
|
||||||
let latestPersonRequestId = 0
|
let latestPersonRequestId = 0
|
||||||
|
const personSearchLoading = ref(false)
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
const hasEquipment = Boolean(equipmentInfo.value)
|
return canSubmitBorrowScan({
|
||||||
const personId = selectedPersonId.value
|
hasEquipment: Boolean(equipmentInfo.value),
|
||||||
const hasPerson = personId !== null && !Number.isNaN(personId)
|
selectedPersonId: selectedPersonId.value,
|
||||||
return hasEquipment && hasPerson && equipmentConfirmed.value && !loading.value
|
loading: loading.value
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredPersons = computed(() => {
|
const filteredPersons = computed(() => {
|
||||||
if (!personKeyword.value) {
|
return filterManualBorrowPersons(persons.value, personKeyword.value)
|
||||||
return persons.value
|
|
||||||
}
|
|
||||||
const keyword = personKeyword.value.trim().toLowerCase()
|
|
||||||
return persons.value.filter((person) => {
|
|
||||||
return (
|
|
||||||
person.nickname.toLowerCase().includes(keyword) ||
|
|
||||||
person.real_name.toLowerCase().includes(keyword) ||
|
|
||||||
(person.id_number && person.id_number.toLowerCase().includes(keyword)) ||
|
|
||||||
(person.department && person.department.toLowerCase().includes(keyword)) ||
|
|
||||||
Boolean(person.department_names?.some((name) => name.toLowerCase().includes(keyword)))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const topFilteredPersons = computed(() => filteredPersons.value.slice(0, 8))
|
const topFilteredPersons = computed(() => filteredPersons.value.slice(0, 8))
|
||||||
@ -372,6 +392,10 @@ const selectedPerson = computed(() => {
|
|||||||
return persons.value.find((person) => person.id === selectedPersonId.value) || null
|
return persons.value.find((person) => person.id === selectedPersonId.value) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showPersonSearchResults = computed(() =>
|
||||||
|
shouldShowManualBorrowPersonResults(selectedPerson.value, personKeyword.value)
|
||||||
|
)
|
||||||
|
|
||||||
const getStatusBadgeClass = (status: string) => {
|
const getStatusBadgeClass = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case '可用':
|
case '可用':
|
||||||
@ -387,35 +411,46 @@ const getStatusBadgeClass = (status: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFocus = () => {
|
const resetBorrowDraftState = () => {
|
||||||
scanCode.value = ''
|
mountedChildren.value = []
|
||||||
|
mountedChildrenValue.value = 0
|
||||||
|
selectedPersonId.value = null
|
||||||
|
personKeyword.value = ''
|
||||||
|
borrowForm.value = {
|
||||||
|
expected_return_time: '',
|
||||||
|
notes: '',
|
||||||
|
is_long_term: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusManualInput = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
scanInputRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearManualSearch = () => {
|
||||||
|
manualKeyword.value = ''
|
||||||
|
manualSearchResults.value = []
|
||||||
|
manualSearchLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCameraDetected = async (value: string) => {
|
const handleCameraDetected = async (value: string) => {
|
||||||
scanCode.value = value.trim()
|
await handleScan(value)
|
||||||
await handleScan()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScan = async () => {
|
const handleScan = async (rawCode = scanCode.value) => {
|
||||||
if (!scanCode.value) {
|
const normalizedCode = rawCode.trim().toUpperCase()
|
||||||
|
if (!normalizedCode) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await scanEquipment(scanCode.value)
|
const response = await scanEquipment(normalizedCode)
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
equipmentInfo.value = response.data
|
equipmentInfo.value = response.data
|
||||||
equipmentConfirmed.value = false
|
|
||||||
successInfo.value = null
|
successInfo.value = null
|
||||||
mountedChildren.value = []
|
resetBorrowDraftState()
|
||||||
mountedChildrenValue.value = 0
|
|
||||||
selectedPersonId.value = null
|
|
||||||
personKeyword.value = ''
|
|
||||||
borrowForm.value = {
|
|
||||||
expected_return_time: '',
|
|
||||||
notes: '',
|
|
||||||
is_long_term: false
|
|
||||||
}
|
|
||||||
playBeepSound()
|
playBeepSound()
|
||||||
|
|
||||||
// 获取已挂载的子资产
|
// 获取已挂载的子资产
|
||||||
@ -431,9 +466,8 @@ const handleScan = async () => {
|
|||||||
|
|
||||||
// 清空扫码框,准备下次扫码
|
// 清空扫码框,准备下次扫码
|
||||||
scanCode.value = ''
|
scanCode.value = ''
|
||||||
nextTick(() => {
|
clearManualSearch()
|
||||||
scanInputRef.value?.focus()
|
focusManualInput()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
playErrorSound()
|
playErrorSound()
|
||||||
@ -442,6 +476,91 @@ const handleScan = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchManualSearchResults = async (keyword: string) => {
|
||||||
|
const normalizedKeyword = keyword.trim()
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
manualSearchResults.value = []
|
||||||
|
manualSearchLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++latestEquipmentRequestId
|
||||||
|
manualSearchLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getEquipmentList({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
status: '可用',
|
||||||
|
keyword: normalizedKeyword
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== latestEquipmentRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manualSearchResults.value = filterManualBorrowCandidates(response.data.list, normalizedKeyword) as Equipment[]
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === latestEquipmentRequestId) {
|
||||||
|
manualSearchResults.value = []
|
||||||
|
}
|
||||||
|
console.error('搜索可借资产失败', error)
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestEquipmentRequestId) {
|
||||||
|
manualSearchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectManualSearchResult = async (equipment: Equipment) => {
|
||||||
|
manualKeyword.value = equipment.equipment_code
|
||||||
|
await handleScan(equipment.equipment_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPerson = (person: Person) => {
|
||||||
|
selectedPersonId.value = person.id
|
||||||
|
personKeyword.value = buildManualBorrowPersonDisplay(person)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePersonKeywordSubmit = () => {
|
||||||
|
const preferredPerson = findPreferredManualBorrowPerson(topFilteredPersons.value, personKeyword.value)
|
||||||
|
|
||||||
|
if (preferredPerson) {
|
||||||
|
selectPerson(preferredPerson)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topFilteredPersons.value.length > 1) {
|
||||||
|
alert('匹配到多位使用者,请从下方结果中点选具体人员。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personKeyword.value.trim()) {
|
||||||
|
alert('没有匹配到使用者,请换个关键词试试。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualSubmit = async () => {
|
||||||
|
const keyword = manualKeyword.value.trim()
|
||||||
|
if (!keyword) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredCandidate = findPreferredManualBorrowCandidate(manualSearchResults.value, keyword)
|
||||||
|
if (preferredCandidate) {
|
||||||
|
await selectManualSearchResult(preferredCandidate as Equipment)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manualSearchResults.value.length > 1) {
|
||||||
|
alert('匹配到多条资产,请从下方点选具体资产,或直接输入准确资产编码。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scanCode.value = keyword.toUpperCase()
|
||||||
|
await handleScan(scanCode.value)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!equipmentInfo.value || selectedPersonId.value === null || Number.isNaN(selectedPersonId.value)) {
|
if (!equipmentInfo.value || selectedPersonId.value === null || Number.isNaN(selectedPersonId.value)) {
|
||||||
return
|
return
|
||||||
@ -480,19 +599,9 @@ const handleSubmit = async () => {
|
|||||||
equipmentInfo.value.status = '已借出'
|
equipmentInfo.value.status = '已借出'
|
||||||
}
|
}
|
||||||
equipmentInfo.value = null
|
equipmentInfo.value = null
|
||||||
mountedChildren.value = []
|
resetBorrowDraftState()
|
||||||
mountedChildrenValue.value = 0
|
clearManualSearch()
|
||||||
selectedPersonId.value = null
|
focusManualInput()
|
||||||
personKeyword.value = ''
|
|
||||||
equipmentConfirmed.value = false
|
|
||||||
borrowForm.value = {
|
|
||||||
expected_return_time: '',
|
|
||||||
notes: '',
|
|
||||||
is_long_term: false
|
|
||||||
}
|
|
||||||
nextTick(() => {
|
|
||||||
scanInputRef.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.message || '出库失败')
|
alert(error.message || '出库失败')
|
||||||
@ -509,40 +618,73 @@ const loadEquipmentFromRoute = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scanCode.value = routeEquipmentCode
|
await handleScan(routeEquipmentCode)
|
||||||
await handleScan()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchPersons = async (keyword = '') => {
|
const fetchPersons = async (keyword = '') => {
|
||||||
const requestId = ++latestPersonRequestId
|
const requestId = ++latestPersonRequestId
|
||||||
|
personSearchLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getPersonList({
|
const response = await getPersonList({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 100,
|
pageSize: keyword ? 20 : 100,
|
||||||
keyword: keyword || undefined,
|
keyword: keyword || undefined,
|
||||||
status: 'active'
|
status: 'active'
|
||||||
})
|
})
|
||||||
if (response.data && requestId === latestPersonRequestId) {
|
if (response.data && requestId === latestPersonRequestId) {
|
||||||
persons.value = response.data.list
|
persons.value = response.data.list
|
||||||
if (selectedPersonId.value) {
|
|
||||||
const exists = persons.value.some((person) => person.id === selectedPersonId.value)
|
|
||||||
if (!exists) {
|
|
||||||
selectedPersonId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取使用者列表失败', error)
|
console.error('获取使用者列表失败', error)
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestPersonRequestId) {
|
||||||
|
personSearchLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => manualKeyword.value,
|
||||||
|
(value) => {
|
||||||
|
if (equipmentSearchTimer) {
|
||||||
|
window.clearTimeout(equipmentSearchTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = value.trim()
|
||||||
|
if (!keyword) {
|
||||||
|
manualSearchResults.value = []
|
||||||
|
manualSearchLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
equipmentSearchTimer = window.setTimeout(() => {
|
||||||
|
fetchManualSearchResults(keyword)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => personKeyword.value,
|
() => personKeyword.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (searchTimer) {
|
const matchedSelectedPerson = selectedPerson.value
|
||||||
window.clearTimeout(searchTimer)
|
const shouldKeepCurrentSelection =
|
||||||
|
matchedSelectedPerson !== null &&
|
||||||
|
isManualBorrowSelectedPersonKeyword(matchedSelectedPerson, value)
|
||||||
|
|
||||||
|
if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) {
|
||||||
|
selectedPersonId.value = null
|
||||||
}
|
}
|
||||||
searchTimer = window.setTimeout(() => {
|
|
||||||
|
if (personSearchTimer) {
|
||||||
|
window.clearTimeout(personSearchTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldKeepCurrentSelection) {
|
||||||
|
personSearchLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
personSearchTimer = window.setTimeout(() => {
|
||||||
fetchPersons(value)
|
fetchPersons(value)
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
@ -557,24 +699,18 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => equipmentInfo.value?.equipment_code,
|
|
||||||
() => {
|
|
||||||
equipmentConfirmed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchPersons()
|
fetchPersons()
|
||||||
nextTick(() => {
|
focusManualInput()
|
||||||
scanInputRef.value?.focus()
|
|
||||||
})
|
|
||||||
loadEquipmentFromRoute()
|
loadEquipmentFromRoute()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (searchTimer) {
|
if (equipmentSearchTimer) {
|
||||||
window.clearTimeout(searchTimer)
|
window.clearTimeout(equipmentSearchTimer)
|
||||||
|
}
|
||||||
|
if (personSearchTimer) {
|
||||||
|
window.clearTimeout(personSearchTimer)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- 页面头部 -->
|
|
||||||
<section class="page-header">
|
<section class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="page-kicker">Quick Return</p>
|
<p class="page-kicker">Quick Return</p>
|
||||||
@ -8,7 +7,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Scan Input Area -->
|
|
||||||
<div class="scanner-hero">
|
<div class="scanner-hero">
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@ -61,25 +59,78 @@
|
|||||||
<h4 class="form-panel__title">手动输入</h4>
|
<h4 class="form-panel__title">手动输入</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<label class="field-label">资产编码</label>
|
<label class="field-label">资产搜索</label>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
ref="scanInputRef"
|
ref="scanInputRef"
|
||||||
v-model="scanCode"
|
v-model.trim="manualAssetKeyword"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="输入编码后回车"
|
placeholder="输入资产编码 / 名称 / 型号 / 地址搜索"
|
||||||
@keyup.enter="handleScan"
|
@keyup.enter="handleManualAssetSubmit"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[100px] text-sm" @click="handleScan">
|
<button
|
||||||
读取
|
type="button"
|
||||||
|
class="btn-secondary w-full sm:w-auto sm:min-w-[120px] text-sm"
|
||||||
|
@click="handleManualAssetSubmit"
|
||||||
|
>
|
||||||
|
按搜索读取
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-text-tertiary">
|
||||||
|
第一步只按资产信息搜索。搜到资产后,再在下一步选择实际使用者。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="manualAssetKeyword.trim()" class="panel">
|
||||||
|
<div class="list-header">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-text-primary">匹配结果</p>
|
||||||
|
<p class="mt-1 text-xs text-text-tertiary">支持搜索编码 / 名称 / 型号 / 地址。已借出但借用单缺失的资产也会单独提示。</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-text-tertiary">
|
||||||
|
{{ manualAssetSearchLoading ? '搜索中...' : `共 ${manualAssetSearchResults.length} 条` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="manualAssetSearchResults.length > 0" class="mt-3 space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="item in manualAssetSearchResults"
|
||||||
|
:key="item.equipment_id"
|
||||||
|
type="button"
|
||||||
|
class="touch-option w-full text-left"
|
||||||
|
@click="selectManualAsset(item)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-text-primary">
|
||||||
|
{{ item.equipment_name }}
|
||||||
|
<span class="text-text-tertiary">· {{ item.equipment_code }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
{{ item.equipment_type || '未分类' }}
|
||||||
|
<span v-if="item.equipment_model"> · {{ item.equipment_model }}</span>
|
||||||
|
<span v-if="item.storageLocation?.name"> · {{ item.storageLocation.name }}</span>
|
||||||
|
<span v-if="item.search_state === 'borrow_record_missing'"> · {{ item.state_message }}</span>
|
||||||
|
<span v-else-if="item.borrower_names?.length"> · 借用人 {{ item.borrower_names.slice(0, 2).join('、') }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="getManualAssetBadgeClass(item)">
|
||||||
|
{{ getManualAssetBadgeText(item) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="manualAssetSearchLoading" class="py-4 text-center text-sm text-text-tertiary">
|
||||||
|
正在搜索待归还资产...
|
||||||
|
</p>
|
||||||
|
<p v-else class="py-4 text-center text-sm text-text-tertiary">
|
||||||
|
没有匹配到需要处理的借出资产。可尝试资产编码、资产名称、型号或地址。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Equipment and Borrow Record Info -->
|
|
||||||
<section v-if="borrowRecordInfo" class="card mt-6">
|
<section v-if="borrowRecordInfo" class="card mt-6">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@ -122,88 +173,68 @@
|
|||||||
<h3 class="section-title mb-4">借用记录</h3>
|
<h3 class="section-title mb-4">借用记录</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<label class="field-label">选择归还使用者</label>
|
<label class="field-label">归还使用者 *</label>
|
||||||
<select v-model="selectedBorrowRecordId" class="select hidden md:block" @change="handleRecordSelect">
|
<div class="space-y-3">
|
||||||
<option value="" disabled>请选择归还使用者</option>
|
<input
|
||||||
<option
|
v-model.trim="recordKeyword"
|
||||||
v-for="record in borrowRecordInfo.borrow_records"
|
type="text"
|
||||||
:key="record.id"
|
class="input"
|
||||||
:value="String(record.id)"
|
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
|
||||||
>
|
@keyup.enter="handleRecordKeywordSubmit"
|
||||||
{{ record.person_nickname }} · {{ record.person_name }}
|
/>
|
||||||
(借用时间:{{ formatDateTime(record.borrow_time) }})
|
<p class="text-xs text-text-tertiary">
|
||||||
</option>
|
{{ borrowRecordInfo.borrow_records.length > 1
|
||||||
</select>
|
? '这台资产存在多条未归还记录,请搜索并点选正确使用者。'
|
||||||
<div class="mobile-card-list md:hidden">
|
: '这台资产当前只有 1 条未归还记录,系统已自动帮你选中。' }}
|
||||||
<article
|
</p>
|
||||||
v-for="record in borrowRecordInfo.borrow_records"
|
<div v-if="showRecordSearchResults" class="space-y-2">
|
||||||
:key="record.id"
|
<div class="flex items-center justify-between gap-3 px-1">
|
||||||
class="mobile-card"
|
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门搜索。</p>
|
||||||
:class="{ 'border-accent-primary/30': selectedBorrowRecordId === String(record.id) }"
|
<p class="text-xs text-text-tertiary whitespace-nowrap">
|
||||||
@click="selectBorrowRecord(record.id)"
|
共 {{ filteredReturnRecords.length }} 条
|
||||||
>
|
</p>
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-text-primary">
|
|
||||||
{{ record.person_nickname }} · {{ record.person_name }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
|
||||||
{{ record.is_long_term ? '在职长期' : (record.expected_return_time ? formatDateTime(record.expected_return_time) : '未设置归还时间') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span v-if="selectedBorrowRecordId === String(record.id)" class="badge badge-success">已选中</span>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<div v-if="filteredReturnRecords.length > 0" class="touch-option-list">
|
||||||
|
<button
|
||||||
|
v-for="record in filteredReturnRecords"
|
||||||
|
:key="record.id"
|
||||||
|
type="button"
|
||||||
|
class="touch-option"
|
||||||
|
:class="{ 'is-active': selectedBorrowRecordId === record.id }"
|
||||||
|
@click="selectBorrowRecord(record)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-text-primary">
|
||||||
|
{{ record.person_nickname }} · {{ record.person_name }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
{{ record.person?.department || '未填写部门' }}
|
||||||
|
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
|
||||||
|
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
|
||||||
|
<span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedBorrowRecordId === record.id" class="badge badge-success">已选中</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-center text-sm text-text-tertiary py-4">
|
||||||
|
当前没有匹配到使用者,请换个关键词再试。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="borrowRecordInfo.borrow_records.length > 1"
|
v-if="borrowRecordInfo.borrow_records.length > 1"
|
||||||
class="mt-2 text-xs text-text-tertiary"
|
class="mt-2 text-xs text-text-tertiary"
|
||||||
>
|
>
|
||||||
该资产有多位使用者,请选择正确归还人。
|
该资产有多位使用者,请确认选中的是本次实际归还人。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedBorrowRecord" class="panel field-grid">
|
|
||||||
<div class="field-stack">
|
|
||||||
<label class="field-label">使用者</label>
|
|
||||||
<p class="text-sm text-text-primary">
|
|
||||||
{{ selectedBorrowRecord.person_nickname }} · {{ selectedBorrowRecord.person_name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedBorrowRecord.person?.department" class="field-stack">
|
|
||||||
<label class="field-label">部门</label>
|
|
||||||
<p class="text-sm text-text-secondary">{{ selectedBorrowRecord.person?.department }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="field-stack">
|
|
||||||
<label class="field-label">借用时间</label>
|
|
||||||
<p class="text-sm text-text-secondary">{{ formatDateTime(selectedBorrowRecord.borrow_time) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="field-stack">
|
|
||||||
<label class="field-label">预计归还时间</label>
|
|
||||||
<p class="text-sm text-text-secondary">
|
|
||||||
{{
|
|
||||||
selectedBorrowRecord.is_long_term
|
|
||||||
? '在职长期'
|
|
||||||
: selectedBorrowRecord.expected_return_time
|
|
||||||
? formatDateTime(selectedBorrowRecord.expected_return_time)
|
|
||||||
: '未设置'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="field-stack">
|
|
||||||
<label class="field-label">状态</label>
|
|
||||||
<p class="text-sm">
|
|
||||||
<span class="badge" :class="getStatusBadgeClass(selectedBorrowRecord.status)">
|
|
||||||
{{ selectedBorrowRecord.status }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 配件快照对比 -->
|
|
||||||
<div v-if="childrenSnapshot.length > 0 || currentChildren.length > 0" class="mt-3 border-t border-border pt-3">
|
<div v-if="childrenSnapshot.length > 0 || currentChildren.length > 0" class="mt-3 border-t border-border pt-3">
|
||||||
<h4 class="mb-2 text-sm font-medium text-text-primary">配件对比</h4>
|
<h4 class="mb-2 text-sm font-medium text-text-primary">配件对比</h4>
|
||||||
|
|
||||||
<!-- 借出时配件 -->
|
|
||||||
<div v-if="childrenSnapshot.length > 0" class="mb-3">
|
<div v-if="childrenSnapshot.length > 0" class="mb-3">
|
||||||
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">借出时配件:</p>
|
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">借出时配件:</p>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@ -226,7 +257,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 当前配件 -->
|
|
||||||
<div v-if="currentChildren.length > 0">
|
<div v-if="currentChildren.length > 0">
|
||||||
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">当前配件:</p>
|
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">当前配件:</p>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@ -249,7 +279,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 差异提示 -->
|
|
||||||
<div v-if="hasChildrenDiff" class="mt-3 rounded-md border border-status-warning/30 bg-status-warning/10 p-2.5">
|
<div v-if="hasChildrenDiff" class="mt-3 rounded-md border border-status-warning/30 bg-status-warning/10 p-2.5">
|
||||||
<p class="flex items-center gap-2 text-sm text-status-warning">
|
<p class="flex items-center gap-2 text-sm text-status-warning">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -272,7 +301,12 @@
|
|||||||
@detected="handleCameraDetected"
|
@detected="handleCameraDetected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Return Info Form -->
|
<BorrowAnomalyHandler
|
||||||
|
v-model="showBorrowAnomalyHandler"
|
||||||
|
:asset="borrowAnomalyTarget"
|
||||||
|
@handled="handleBorrowAnomalyHandled"
|
||||||
|
/>
|
||||||
|
|
||||||
<section v-if="borrowRecordInfo" class="card">
|
<section v-if="borrowRecordInfo" class="card">
|
||||||
<h3 class="section-title mb-4">归还信息</h3>
|
<h3 class="section-title mb-4">归还信息</h3>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
@ -297,7 +331,7 @@
|
|||||||
<div class="mobile-sticky-actions md:static md:m-0 md:border-0 md:bg-transparent md:p-0 mt-4">
|
<div class="mobile-sticky-actions md:static md:m-0 md:border-0 md:bg-transparent md:p-0 mt-4">
|
||||||
<button
|
<button
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
:disabled="loading || !selectedBorrowRecord"
|
:disabled="!canSubmit"
|
||||||
class="btn-primary w-full text-sm"
|
class="btn-primary w-full text-sm"
|
||||||
>
|
>
|
||||||
{{ loading ? '提交中...' : '确认入库' }}
|
{{ loading ? '提交中...' : '确认入库' }}
|
||||||
@ -308,14 +342,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick, computed } from 'vue'
|
import { ref, onMounted, nextTick, computed, watch } from 'vue'
|
||||||
import { getBorrowRecordByCode } from '@/api/borrow'
|
import { useRoute } from 'vue-router'
|
||||||
import { scanReturn } from '@/api/borrow'
|
import { getBorrowRecordByCode, scanReturn, searchReturnAssets } from '@/api/borrow'
|
||||||
import { getChildren } from '@/api/mount'
|
import { getChildren } from '@/api/mount'
|
||||||
import { formatCurrency, formatDateTime, playBeepSound, playErrorSound } from '@/utils/format'
|
import { formatCurrency, formatDateTime, playBeepSound, playErrorSound } from '@/utils/format'
|
||||||
import type { BorrowRecord, StorageLocation } from '@/types'
|
import type { BorrowRecord, StorageLocation } from '@/types'
|
||||||
import type { ChildEquipment } from '@/api/mount'
|
import type { ChildEquipment } from '@/api/mount'
|
||||||
|
import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
|
||||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||||
|
import {
|
||||||
|
type ManualReturnAssetCandidate,
|
||||||
|
filterManualReturnAssetCandidates,
|
||||||
|
findPreferredManualReturnAssetCandidate
|
||||||
|
} from './manualReturnSearch'
|
||||||
|
import {
|
||||||
|
buildManualReturnRecordDisplay,
|
||||||
|
filterManualReturnRecordCandidates,
|
||||||
|
findPreferredManualReturnRecordCandidate,
|
||||||
|
isManualReturnSelectedRecordKeyword,
|
||||||
|
shouldShowManualReturnRecordResults
|
||||||
|
} from './manualReturnRecordSearch'
|
||||||
|
import { canSubmitReturnScan } from './returnScanState'
|
||||||
|
|
||||||
interface ChildSnapshot {
|
interface ChildSnapshot {
|
||||||
id: number
|
id: number
|
||||||
@ -338,8 +386,8 @@ interface BorrowRecordInfo {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const scanCode = ref('')
|
|
||||||
const scanInputRef = ref<HTMLInputElement>()
|
const scanInputRef = ref<HTMLInputElement>()
|
||||||
|
const route = useRoute()
|
||||||
const showCameraScanner = ref(false)
|
const showCameraScanner = ref(false)
|
||||||
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
|
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
|
||||||
const borrowRecordInfo = ref<BorrowRecordInfo | null>(null)
|
const borrowRecordInfo = ref<BorrowRecordInfo | null>(null)
|
||||||
@ -348,29 +396,58 @@ const childrenSnapshot = ref<ChildSnapshot[]>([])
|
|||||||
const currentChildren = ref<ChildEquipment[]>([])
|
const currentChildren = ref<ChildEquipment[]>([])
|
||||||
const pageNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
|
const pageNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
|
||||||
const returnSuccessCount = ref(0)
|
const returnSuccessCount = ref(0)
|
||||||
|
const showBorrowAnomalyHandler = ref(false)
|
||||||
|
const borrowAnomalyTarget = ref<{
|
||||||
|
equipmentId: number
|
||||||
|
equipmentCode: string
|
||||||
|
equipmentName: string
|
||||||
|
equipmentModel?: string | null
|
||||||
|
equipmentType?: string | null
|
||||||
|
storageLocationName?: string | null
|
||||||
|
message?: string | null
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
const returnInfo = ref({
|
const returnInfo = ref({
|
||||||
return_condition: '正常',
|
return_condition: '正常',
|
||||||
notes: ''
|
notes: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedBorrowRecordId = ref<string>('')
|
const manualAssetKeyword = ref('')
|
||||||
|
const manualAssetSearchResults = ref<ManualReturnAssetCandidate[]>([])
|
||||||
|
const manualAssetSearchLoading = ref(false)
|
||||||
|
const recordKeyword = ref('')
|
||||||
|
const selectedBorrowRecordId = ref<number | null>(null)
|
||||||
|
const appliedRouteQuickReturnKey = ref('')
|
||||||
|
let assetSearchTimer: number | null = null
|
||||||
|
let latestAssetRequestId = 0
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
canSubmitReturnScan({
|
||||||
|
hasBorrowRecordInfo: Boolean(borrowRecordInfo.value),
|
||||||
|
selectedBorrowRecordId: selectedBorrowRecordId.value,
|
||||||
|
loading: loading.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const selectedBorrowRecord = computed(() => {
|
const selectedBorrowRecord = computed(() => {
|
||||||
if (
|
if (!borrowRecordInfo.value || selectedBorrowRecordId.value === null) {
|
||||||
!borrowRecordInfo.value ||
|
|
||||||
!selectedBorrowRecordId.value ||
|
|
||||||
Number.isNaN(Number(selectedBorrowRecordId.value))
|
|
||||||
) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
borrowRecordInfo.value.borrow_records.find(
|
borrowRecordInfo.value.borrow_records.find(
|
||||||
(record) => record.id === Number(selectedBorrowRecordId.value)
|
(record) => record.id === selectedBorrowRecordId.value
|
||||||
) || null
|
) || null
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredReturnRecords = computed(() =>
|
||||||
|
filterManualReturnRecordCandidates(borrowRecordInfo.value?.borrow_records || [], recordKeyword.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const showRecordSearchResults = computed(() =>
|
||||||
|
shouldShowManualReturnRecordResults(selectedBorrowRecord.value, recordKeyword.value)
|
||||||
|
)
|
||||||
|
|
||||||
const hasChildrenDiff = computed(() => {
|
const hasChildrenDiff = computed(() => {
|
||||||
if (childrenSnapshot.value.length === 0 && currentChildren.value.length === 0) return false
|
if (childrenSnapshot.value.length === 0 && currentChildren.value.length === 0) return false
|
||||||
const snapshotIds = new Set(childrenSnapshot.value.map(c => c.id))
|
const snapshotIds = new Set(childrenSnapshot.value.map(c => c.id))
|
||||||
@ -390,35 +467,18 @@ const isChildNew = (childId: number) => {
|
|||||||
return !childrenSnapshot.value.some(c => c.id === childId)
|
return !childrenSnapshot.value.some(c => c.id === childId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusBadgeClass = (status: string) => {
|
const isBorrowRecordMissingAsset = (asset: ManualReturnAssetCandidate) =>
|
||||||
switch (status) {
|
asset.search_state === 'borrow_record_missing' || asset.can_return === false
|
||||||
case '已借出':
|
|
||||||
return 'badge-warning'
|
|
||||||
case '已归还':
|
|
||||||
return 'badge-success'
|
|
||||||
case '逾期':
|
|
||||||
return 'badge-error'
|
|
||||||
default:
|
|
||||||
return 'badge-neutral'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRecordSelect = () => {
|
const getManualAssetBadgeClass = (asset: ManualReturnAssetCandidate) =>
|
||||||
// 解析选中记录的配件快照
|
isBorrowRecordMissingAsset(asset) ? 'badge-error' : 'badge-warning'
|
||||||
if (selectedBorrowRecord.value && (selectedBorrowRecord.value as any).children_snapshot) {
|
|
||||||
try {
|
|
||||||
childrenSnapshot.value = JSON.parse((selectedBorrowRecord.value as any).children_snapshot)
|
|
||||||
} catch {
|
|
||||||
childrenSnapshot.value = []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
childrenSnapshot.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectBorrowRecord = (recordId: number) => {
|
const getManualAssetBadgeText = (asset: ManualReturnAssetCandidate) =>
|
||||||
selectedBorrowRecordId.value = String(recordId)
|
isBorrowRecordMissingAsset(asset) ? '借用单缺失' : `待归还 ${asset.active_borrow_count}`
|
||||||
handleRecordSelect()
|
|
||||||
|
const selectBorrowRecord = (record: BorrowRecord) => {
|
||||||
|
selectedBorrowRecordId.value = record.id
|
||||||
|
recordKeyword.value = buildManualReturnRecordDisplay(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusScanInput = () => {
|
const focusScanInput = () => {
|
||||||
@ -436,37 +496,199 @@ const resetReturnForm = () => {
|
|||||||
|
|
||||||
const resetCurrentFlow = () => {
|
const resetCurrentFlow = () => {
|
||||||
borrowRecordInfo.value = null
|
borrowRecordInfo.value = null
|
||||||
selectedBorrowRecordId.value = ''
|
selectedBorrowRecordId.value = null
|
||||||
childrenSnapshot.value = []
|
childrenSnapshot.value = []
|
||||||
currentChildren.value = []
|
currentChildren.value = []
|
||||||
scanCode.value = ''
|
recordKeyword.value = ''
|
||||||
resetReturnForm()
|
resetReturnForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
scanCode.value = ''
|
pageNotice.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCameraDetected = async (value: string) => {
|
const handleCameraDetected = async (value: string) => {
|
||||||
scanCode.value = value.trim()
|
await handleScan(value.trim())
|
||||||
await handleScan()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScan = async () => {
|
const clearManualAssetSearch = () => {
|
||||||
if (!scanCode.value) {
|
manualAssetKeyword.value = ''
|
||||||
|
manualAssetSearchResults.value = []
|
||||||
|
manualAssetSearchLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBorrowAnomalyHandler = (asset: ManualReturnAssetCandidate) => {
|
||||||
|
borrowAnomalyTarget.value = {
|
||||||
|
equipmentId: asset.equipment_id,
|
||||||
|
equipmentCode: asset.equipment_code,
|
||||||
|
equipmentName: asset.equipment_name,
|
||||||
|
equipmentModel: asset.equipment_model,
|
||||||
|
equipmentType: asset.equipment_type,
|
||||||
|
storageLocationName: asset.storageLocation?.name || null,
|
||||||
|
message: asset.state_message || null
|
||||||
|
}
|
||||||
|
showBorrowAnomalyHandler.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBorrowAnomalyHandled = async (payload: {
|
||||||
|
action: 'mark-returned' | 'register-borrow'
|
||||||
|
message: string
|
||||||
|
}) => {
|
||||||
|
resetCurrentFlow()
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'success',
|
||||||
|
message: payload.message
|
||||||
|
}
|
||||||
|
showBorrowAnomalyHandler.value = false
|
||||||
|
borrowAnomalyTarget.value = null
|
||||||
|
|
||||||
|
const keyword = manualAssetKeyword.value.trim()
|
||||||
|
if (keyword) {
|
||||||
|
await fetchManualAssetSearchResults(keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchManualAssetSearchResults = async (keyword: string) => {
|
||||||
|
const normalizedKeyword = keyword.trim()
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
manualAssetSearchResults.value = []
|
||||||
|
manualAssetSearchLoading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestId = ++latestAssetRequestId
|
||||||
|
manualAssetSearchLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getBorrowRecordByCode(scanCode.value)
|
const response = await searchReturnAssets({ keyword: normalizedKeyword })
|
||||||
|
if (requestId !== latestAssetRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manualAssetSearchResults.value = filterManualReturnAssetCandidates(
|
||||||
|
response.data || [],
|
||||||
|
normalizedKeyword
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === latestAssetRequestId) {
|
||||||
|
manualAssetSearchResults.value = []
|
||||||
|
}
|
||||||
|
console.error('搜索待归还资产失败', error)
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestAssetRequestId) {
|
||||||
|
manualAssetSearchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectManualAsset = async (asset: ManualReturnAssetCandidate) => {
|
||||||
|
manualAssetKeyword.value = asset.equipment_code
|
||||||
|
if (isBorrowRecordMissingAsset(asset)) {
|
||||||
|
resetCurrentFlow()
|
||||||
|
if (asset.can_handle_borrow_anomaly) {
|
||||||
|
openBorrowAnomalyHandler(asset)
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: `${asset.equipment_code} 不能直接归还,已为你打开异常借出处理。`
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message:
|
||||||
|
[asset.state_message, asset.borrow_anomaly_blocked_reason].filter(Boolean).join(';') ||
|
||||||
|
`${asset.equipment_code} 当前标记为已借出,但系统里没有对应借用记录,不能直接归还,请先核对。`
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await handleScan(asset.equipment_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualAssetSubmit = async () => {
|
||||||
|
const keyword = manualAssetKeyword.value.trim()
|
||||||
|
if (!keyword) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredCandidate = findPreferredManualReturnAssetCandidate(
|
||||||
|
manualAssetSearchResults.value,
|
||||||
|
keyword
|
||||||
|
)
|
||||||
|
if (preferredCandidate) {
|
||||||
|
await selectManualAsset(preferredCandidate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manualAssetSearchResults.value.length > 1) {
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '匹配到多条待归还资产,请从下方结果中点选具体资产。'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleScan(keyword.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRecordKeywordSubmit = () => {
|
||||||
|
const preferredRecord = findPreferredManualReturnRecordCandidate(
|
||||||
|
filteredReturnRecords.value,
|
||||||
|
recordKeyword.value
|
||||||
|
)
|
||||||
|
|
||||||
|
if (preferredRecord) {
|
||||||
|
selectBorrowRecord(preferredRecord)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredReturnRecords.value.length > 1) {
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '匹配到多条借用记录,请从下方结果中点选正确使用者。'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordKeyword.value.trim()) {
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '没有匹配到可归还的使用者,请换个关键词再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeQueryString = (value: unknown) => {
|
||||||
|
const rawValue = Array.isArray(value) ? value[0] : value
|
||||||
|
return typeof rawValue === 'string' ? rawValue.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeQueryNumber = (value: unknown) => {
|
||||||
|
const text = normalizeQueryString(value)
|
||||||
|
if (!text) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(text)
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScan = async (rawCode: string) => {
|
||||||
|
const normalizedCode = rawCode.trim().toUpperCase()
|
||||||
|
if (!normalizedCode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getBorrowRecordByCode(normalizedCode)
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
borrowRecordInfo.value = response.data
|
borrowRecordInfo.value = response.data
|
||||||
childrenSnapshot.value = []
|
childrenSnapshot.value = []
|
||||||
currentChildren.value = []
|
currentChildren.value = []
|
||||||
|
selectedBorrowRecordId.value = null
|
||||||
|
recordKeyword.value = ''
|
||||||
resetReturnForm()
|
resetReturnForm()
|
||||||
playBeepSound()
|
playBeepSound()
|
||||||
|
|
||||||
// 从借用记录中获取 equipment_id 来查询当前挂载的子资产
|
|
||||||
const equipmentId = response.data.borrow_records?.[0]?.equipment_id
|
const equipmentId = response.data.borrow_records?.[0]?.equipment_id
|
||||||
if (equipmentId) {
|
if (equipmentId) {
|
||||||
try {
|
try {
|
||||||
@ -475,37 +697,72 @@ const handleScan = async () => {
|
|||||||
currentChildren.value = childrenResponse.data.children
|
currentChildren.value = childrenResponse.data.children
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略错误
|
// 忽略查询当前子资产失败
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.data.borrow_records.length === 1) {
|
if (response.data.borrow_records.length === 1) {
|
||||||
selectedBorrowRecordId.value = String(response.data.borrow_records[0].id)
|
selectBorrowRecord(response.data.borrow_records[0])
|
||||||
handleRecordSelect()
|
|
||||||
pageNotice.value = {
|
pageNotice.value = {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: `已读取 ${response.data.equipment_name} 的借用记录,可以直接确认归还。`
|
message: `已读取 ${response.data.equipment_name} 的借用记录,可以直接确认归还。`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
selectedBorrowRecordId.value = ''
|
|
||||||
pageNotice.value = {
|
pageNotice.value = {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: `已读取 ${response.data.equipment_name},请先选择正确的归还使用者。`
|
message: `已读取 ${response.data.equipment_name},请先选择正确的归还使用者。`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scanCode.value = ''
|
clearManualAssetSearch()
|
||||||
focusScanInput()
|
focusScanInput()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
playErrorSound()
|
playErrorSound()
|
||||||
|
if (error?.status === 409) {
|
||||||
|
try {
|
||||||
|
const anomalySearch = await searchReturnAssets({ keyword: normalizedCode })
|
||||||
|
const preferredAsset = findPreferredManualReturnAssetCandidate(
|
||||||
|
filterManualReturnAssetCandidates(anomalySearch.data || [], normalizedCode),
|
||||||
|
normalizedCode
|
||||||
|
)
|
||||||
|
|
||||||
|
if (preferredAsset && isBorrowRecordMissingAsset(preferredAsset)) {
|
||||||
|
manualAssetKeyword.value = preferredAsset.equipment_code
|
||||||
|
if (preferredAsset.can_handle_borrow_anomaly) {
|
||||||
|
openBorrowAnomalyHandler(preferredAsset)
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: `${preferredAsset.equipment_code} 当前没有借用单,已为你打开异常借出处理。`
|
||||||
|
}
|
||||||
|
focusScanInput()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message:
|
||||||
|
[preferredAsset.state_message, preferredAsset.borrow_anomaly_blocked_reason]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(';') || error.message || '扫码失败,请检查资产编码是否正确'
|
||||||
|
}
|
||||||
|
focusScanInput()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略异常搜索补偿失败,沿用默认报错提示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pageNotice.value = {
|
pageNotice.value = {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: error.message || '扫码失败,请检查资产编码是否正确'
|
message: error.message || '扫码失败,请检查资产编码是否正确'
|
||||||
}
|
}
|
||||||
scanCode.value = ''
|
|
||||||
focusScanInput()
|
focusScanInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@ -567,6 +824,7 @@ const handleSubmit = async () => {
|
|||||||
message: `${borrowRecordInfo.value.equipment_name} 已完成归还,可以继续扫码下一件。`
|
message: `${borrowRecordInfo.value.equipment_name} 已完成归还,可以继续扫码下一件。`
|
||||||
}
|
}
|
||||||
resetCurrentFlow()
|
resetCurrentFlow()
|
||||||
|
clearManualAssetSearch()
|
||||||
focusScanInput()
|
focusScanInput()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
playErrorSound()
|
playErrorSound()
|
||||||
@ -580,6 +838,99 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => manualAssetKeyword.value,
|
||||||
|
(value) => {
|
||||||
|
if (assetSearchTimer) {
|
||||||
|
window.clearTimeout(assetSearchTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = value.trim()
|
||||||
|
if (!keyword) {
|
||||||
|
manualAssetSearchResults.value = []
|
||||||
|
manualAssetSearchLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assetSearchTimer = window.setTimeout(() => {
|
||||||
|
fetchManualAssetSearchResults(keyword)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => recordKeyword.value,
|
||||||
|
(value) => {
|
||||||
|
const matchedSelectedRecord = selectedBorrowRecord.value
|
||||||
|
const shouldKeepCurrentSelection =
|
||||||
|
matchedSelectedRecord !== null &&
|
||||||
|
isManualReturnSelectedRecordKeyword(matchedSelectedRecord, value)
|
||||||
|
|
||||||
|
if (selectedBorrowRecordId.value !== null && !shouldKeepCurrentSelection) {
|
||||||
|
selectedBorrowRecordId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedBorrowRecord.value,
|
||||||
|
(record) => {
|
||||||
|
if (record && (record as any).children_snapshot) {
|
||||||
|
try {
|
||||||
|
childrenSnapshot.value = JSON.parse((record as any).children_snapshot)
|
||||||
|
} catch {
|
||||||
|
childrenSnapshot.value = []
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
childrenSnapshot.value = []
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.equipmentCode, route.query.borrowRecordId],
|
||||||
|
async ([equipmentCodeQuery, borrowRecordIdQuery]) => {
|
||||||
|
const equipmentCode = normalizeQueryString(equipmentCodeQuery)
|
||||||
|
const borrowRecordId = normalizeQueryNumber(borrowRecordIdQuery)
|
||||||
|
|
||||||
|
if (!equipmentCode) {
|
||||||
|
appliedRouteQuickReturnKey.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickReturnKey = `${equipmentCode}#${borrowRecordId || ''}`
|
||||||
|
if (appliedRouteQuickReturnKey.value === quickReturnKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await handleScan(equipmentCode)
|
||||||
|
if (!loaded || !borrowRecordInfo.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (borrowRecordId) {
|
||||||
|
const matchedRecord = borrowRecordInfo.value.borrow_records.find((record) => record.id === borrowRecordId)
|
||||||
|
if (matchedRecord) {
|
||||||
|
selectBorrowRecord(matchedRecord)
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'success',
|
||||||
|
message: `已为 ${borrowRecordInfo.value.equipment_name} 选中 ${matchedRecord.person_nickname} 的归还记录,可以直接确认入库。`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pageNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: `已打开 ${borrowRecordInfo.value.equipment_name},但没有找到指定的借用记录,请手动选择归还使用者。`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appliedRouteQuickReturnKey.value = quickReturnKey
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
focusScanInput()
|
focusScanInput()
|
||||||
})
|
})
|
||||||
|
|||||||
8
frontend/src/views/borrow/borrowScanState.ts
Normal file
8
frontend/src/views/borrow/borrowScanState.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export const hasSelectedBorrowPerson = (selectedPersonId: number | null) =>
|
||||||
|
selectedPersonId !== null && !Number.isNaN(selectedPersonId)
|
||||||
|
|
||||||
|
export const canSubmitBorrowScan = (options: {
|
||||||
|
hasEquipment: boolean
|
||||||
|
selectedPersonId: number | null
|
||||||
|
loading: boolean
|
||||||
|
}) => options.hasEquipment && hasSelectedBorrowPerson(options.selectedPersonId) && !options.loading
|
||||||
148
frontend/src/views/borrow/manualBorrowPersonSearch.ts
Normal file
148
frontend/src/views/borrow/manualBorrowPersonSearch.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
export interface ManualBorrowSearchPerson {
|
||||||
|
id: number
|
||||||
|
nickname: string
|
||||||
|
real_name: string
|
||||||
|
id_number?: string | null
|
||||||
|
department?: string | null
|
||||||
|
department_names?: string[]
|
||||||
|
phone?: string | null
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (value?: string | null) => (value || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
export const buildManualBorrowPersonDisplay = (
|
||||||
|
person: Pick<ManualBorrowSearchPerson, 'nickname' | 'real_name'>
|
||||||
|
) => [person.nickname, person.real_name].filter(Boolean).join(' ').trim()
|
||||||
|
|
||||||
|
const includesKeyword = (value: string | undefined | null, keyword: string) =>
|
||||||
|
normalize(value).includes(keyword)
|
||||||
|
|
||||||
|
const buildDepartmentText = (
|
||||||
|
person: Pick<ManualBorrowSearchPerson, 'department' | 'department_names'>
|
||||||
|
) => {
|
||||||
|
const departmentNames = Array.isArray(person.department_names) ? person.department_names : []
|
||||||
|
return [person.department || '', ...departmentNames].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMatchScore = (
|
||||||
|
person: Pick<ManualBorrowSearchPerson, 'nickname' | 'real_name' | 'id_number'>,
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const nickname = normalize(person.nickname)
|
||||||
|
const realName = normalize(person.real_name)
|
||||||
|
const idNumber = normalize(person.id_number)
|
||||||
|
|
||||||
|
if (nickname === keyword) return 0
|
||||||
|
if (realName === keyword) return 1
|
||||||
|
if (idNumber === keyword) return 2
|
||||||
|
if (nickname.startsWith(keyword)) return 3
|
||||||
|
if (realName.startsWith(keyword)) return 4
|
||||||
|
if (idNumber.startsWith(keyword)) return 5
|
||||||
|
if (nickname.includes(keyword)) return 6
|
||||||
|
if (realName.includes(keyword)) return 7
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filterManualBorrowPersons = <T extends ManualBorrowSearchPerson>(
|
||||||
|
persons: T[],
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
const list = persons.filter((person) => {
|
||||||
|
if (person.status !== 'active') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
includesKeyword(person.nickname, normalizedKeyword) ||
|
||||||
|
includesKeyword(person.real_name, normalizedKeyword) ||
|
||||||
|
includesKeyword(person.id_number, normalizedKeyword) ||
|
||||||
|
includesKeyword(buildDepartmentText(person), normalizedKeyword) ||
|
||||||
|
includesKeyword(person.phone, normalizedKeyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return list.sort((left, right) => {
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return `${left.nickname}${left.real_name}`.localeCompare(
|
||||||
|
`${right.nickname}${right.real_name}`,
|
||||||
|
'zh-CN'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreDiff = getMatchScore(left, normalizedKeyword) - getMatchScore(right, normalizedKeyword)
|
||||||
|
if (scoreDiff !== 0) {
|
||||||
|
return scoreDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${left.nickname}${left.real_name}`.localeCompare(
|
||||||
|
`${right.nickname}${right.real_name}`,
|
||||||
|
'zh-CN'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findPreferredManualBorrowPerson = <T extends ManualBorrowSearchPerson>(
|
||||||
|
persons: T[],
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatch = persons.find((person) => {
|
||||||
|
return (
|
||||||
|
normalize(person.nickname) === normalizedKeyword ||
|
||||||
|
normalize(person.real_name) === normalizedKeyword ||
|
||||||
|
normalize(person.id_number) === normalizedKeyword
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
return exactMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return persons.length === 1 ? persons[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isManualBorrowSelectedPersonKeyword = <T extends ManualBorrowSearchPerson>(
|
||||||
|
person: T,
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = normalize(buildManualBorrowPersonDisplay(person))
|
||||||
|
return [
|
||||||
|
normalize(person.nickname),
|
||||||
|
normalize(person.real_name),
|
||||||
|
normalize(person.id_number),
|
||||||
|
displayName
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => value === normalizedKeyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shouldShowManualBorrowPersonResults = <T extends ManualBorrowSearchPerson>(
|
||||||
|
person: T | null,
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!person) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isManualBorrowSelectedPersonKeyword(person, keyword)
|
||||||
|
}
|
||||||
103
frontend/src/views/borrow/manualBorrowSearch.ts
Normal file
103
frontend/src/views/borrow/manualBorrowSearch.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
export interface ManualBorrowSearchCandidate {
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string | null
|
||||||
|
equipment_type?: string | null
|
||||||
|
status: string
|
||||||
|
remaining_quantity: number
|
||||||
|
parent_id?: number | null
|
||||||
|
storageLocation?: {
|
||||||
|
name?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (value?: string | null) => (value || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const includesKeyword = (value: string | undefined | null, keyword: string) =>
|
||||||
|
normalize(value).includes(keyword)
|
||||||
|
|
||||||
|
const getManualBorrowSearchScore = (
|
||||||
|
equipment: ManualBorrowSearchCandidate,
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedCode = normalize(equipment.equipment_code)
|
||||||
|
const normalizedName = normalize(equipment.equipment_name)
|
||||||
|
const normalizedModel = normalize(equipment.equipment_model)
|
||||||
|
const normalizedType = normalize(equipment.equipment_type)
|
||||||
|
|
||||||
|
if (normalizedCode === keyword) return 0
|
||||||
|
if (normalizedCode.startsWith(keyword)) return 1
|
||||||
|
if (normalizedName.startsWith(keyword)) return 2
|
||||||
|
if (normalizedModel.startsWith(keyword)) return 3
|
||||||
|
if (normalizedType.startsWith(keyword)) return 4
|
||||||
|
if (includesKeyword(equipment.equipment_code, keyword)) return 5
|
||||||
|
if (includesKeyword(equipment.equipment_name, keyword)) return 6
|
||||||
|
if (includesKeyword(equipment.equipment_model, keyword)) return 7
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isManualBorrowCandidate = (equipment: ManualBorrowSearchCandidate) =>
|
||||||
|
equipment.parent_id == null &&
|
||||||
|
equipment.status === '可用' &&
|
||||||
|
Number(equipment.remaining_quantity) > 0
|
||||||
|
|
||||||
|
export const filterManualBorrowCandidates = (
|
||||||
|
list: ManualBorrowSearchCandidate[],
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
|
||||||
|
const filtered = list.filter((equipment) => {
|
||||||
|
if (!isManualBorrowCandidate(equipment)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
includesKeyword(equipment.equipment_code, normalizedKeyword) ||
|
||||||
|
includesKeyword(equipment.equipment_name, normalizedKeyword) ||
|
||||||
|
includesKeyword(equipment.equipment_model, normalizedKeyword) ||
|
||||||
|
includesKeyword(equipment.equipment_type, normalizedKeyword) ||
|
||||||
|
includesKeyword(equipment.storageLocation?.name, normalizedKeyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered.sort((left, right) => {
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return left.equipment_code.localeCompare(right.equipment_code, 'zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreDiff =
|
||||||
|
getManualBorrowSearchScore(left, normalizedKeyword) -
|
||||||
|
getManualBorrowSearchScore(right, normalizedKeyword)
|
||||||
|
|
||||||
|
if (scoreDiff !== 0) {
|
||||||
|
return scoreDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.equipment_code.localeCompare(right.equipment_code, 'zh-CN')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findPreferredManualBorrowCandidate = (
|
||||||
|
list: ManualBorrowSearchCandidate[],
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactCodeMatch = list.find(
|
||||||
|
(equipment) => normalize(equipment.equipment_code) === normalizedKeyword
|
||||||
|
)
|
||||||
|
|
||||||
|
if (exactCodeMatch) {
|
||||||
|
return exactCodeMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.length === 1 ? list[0] : null
|
||||||
|
}
|
||||||
147
frontend/src/views/borrow/manualReturnRecordSearch.ts
Normal file
147
frontend/src/views/borrow/manualReturnRecordSearch.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
export interface ManualReturnRecordPerson {
|
||||||
|
nickname?: string | null
|
||||||
|
real_name?: string | null
|
||||||
|
department?: string | null
|
||||||
|
department_names?: string[]
|
||||||
|
phone?: string | null
|
||||||
|
status?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualReturnRecordCandidate {
|
||||||
|
id: number
|
||||||
|
person_name: string
|
||||||
|
person_nickname: string
|
||||||
|
person_identifier?: string | null
|
||||||
|
person?: ManualReturnRecordPerson | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (value?: string | null) => (value || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const includesKeyword = (value: string | undefined | null, keyword: string) =>
|
||||||
|
normalize(value).includes(keyword)
|
||||||
|
|
||||||
|
export const buildManualReturnRecordDisplay = (
|
||||||
|
record: Pick<ManualReturnRecordCandidate, 'person_nickname' | 'person_name'>
|
||||||
|
) => [record.person_nickname, record.person_name].filter(Boolean).join(' ').trim()
|
||||||
|
|
||||||
|
const buildDepartmentText = (record: ManualReturnRecordCandidate) => {
|
||||||
|
const person = record.person
|
||||||
|
const departmentNames = Array.isArray(person?.department_names) ? person?.department_names : []
|
||||||
|
return [person?.department || '', ...departmentNames].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMatchScore = (record: ManualReturnRecordCandidate, keyword: string) => {
|
||||||
|
const nickname = normalize(record.person_nickname)
|
||||||
|
const realName = normalize(record.person_name)
|
||||||
|
const identifier = normalize(record.person_identifier)
|
||||||
|
|
||||||
|
if (nickname === keyword) return 0
|
||||||
|
if (realName === keyword) return 1
|
||||||
|
if (identifier === keyword) return 2
|
||||||
|
if (nickname.startsWith(keyword)) return 3
|
||||||
|
if (realName.startsWith(keyword)) return 4
|
||||||
|
if (identifier.startsWith(keyword)) return 5
|
||||||
|
if (nickname.includes(keyword)) return 6
|
||||||
|
if (realName.includes(keyword)) return 7
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filterManualReturnRecordCandidates = <T extends ManualReturnRecordCandidate>(
|
||||||
|
records: T[],
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
|
||||||
|
const filtered = records.filter((record) => {
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
includesKeyword(record.person_nickname, normalizedKeyword) ||
|
||||||
|
includesKeyword(record.person_name, normalizedKeyword) ||
|
||||||
|
includesKeyword(record.person_identifier, normalizedKeyword) ||
|
||||||
|
includesKeyword(buildDepartmentText(record), normalizedKeyword) ||
|
||||||
|
includesKeyword(record.person?.phone, normalizedKeyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered.sort((left, right) => {
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return buildManualReturnRecordDisplay(left).localeCompare(
|
||||||
|
buildManualReturnRecordDisplay(right),
|
||||||
|
'zh-CN'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreDiff = getMatchScore(left, normalizedKeyword) - getMatchScore(right, normalizedKeyword)
|
||||||
|
if (scoreDiff !== 0) {
|
||||||
|
return scoreDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildManualReturnRecordDisplay(left).localeCompare(
|
||||||
|
buildManualReturnRecordDisplay(right),
|
||||||
|
'zh-CN'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findPreferredManualReturnRecordCandidate = <T extends ManualReturnRecordCandidate>(
|
||||||
|
records: T[],
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatch = records.find((record) => {
|
||||||
|
return (
|
||||||
|
normalize(record.person_nickname) === normalizedKeyword ||
|
||||||
|
normalize(record.person_name) === normalizedKeyword ||
|
||||||
|
normalize(record.person_identifier) === normalizedKeyword
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
return exactMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return records.length === 1 ? records[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isManualReturnSelectedRecordKeyword = <T extends ManualReturnRecordCandidate>(
|
||||||
|
record: T,
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = normalize(buildManualReturnRecordDisplay(record))
|
||||||
|
return [
|
||||||
|
normalize(record.person_nickname),
|
||||||
|
normalize(record.person_name),
|
||||||
|
normalize(record.person_identifier),
|
||||||
|
displayName
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => value === normalizedKeyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shouldShowManualReturnRecordResults = <T extends ManualReturnRecordCandidate>(
|
||||||
|
record: T | null,
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isManualReturnSelectedRecordKeyword(record, keyword)
|
||||||
|
}
|
||||||
112
frontend/src/views/borrow/manualReturnSearch.ts
Normal file
112
frontend/src/views/borrow/manualReturnSearch.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
export interface ManualReturnAssetCandidate {
|
||||||
|
equipment_id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string | null
|
||||||
|
equipment_type?: string | 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
|
||||||
|
storageLocation?: {
|
||||||
|
name?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (value?: string | null) => (value || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const includesKeyword = (value: string | undefined | null, keyword: string) =>
|
||||||
|
normalize(value).includes(keyword)
|
||||||
|
|
||||||
|
const getManualReturnAssetSearchScore = (
|
||||||
|
equipment: ManualReturnAssetCandidate,
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedCode = normalize(equipment.equipment_code)
|
||||||
|
const normalizedName = normalize(equipment.equipment_name)
|
||||||
|
const normalizedModel = normalize(equipment.equipment_model)
|
||||||
|
const normalizedType = normalize(equipment.equipment_type)
|
||||||
|
const normalizedLocation = normalize(equipment.storageLocation?.name)
|
||||||
|
|
||||||
|
if (normalizedCode === keyword) return 0
|
||||||
|
if (normalizedCode.startsWith(keyword)) return 1
|
||||||
|
if (normalizedName.startsWith(keyword)) return 2
|
||||||
|
if (normalizedModel.startsWith(keyword)) return 3
|
||||||
|
if (normalizedType.startsWith(keyword)) return 4
|
||||||
|
if (normalizedLocation.startsWith(keyword)) return 5
|
||||||
|
if (includesKeyword(equipment.equipment_code, keyword)) return 6
|
||||||
|
if (includesKeyword(equipment.equipment_name, keyword)) return 7
|
||||||
|
if (includesKeyword(equipment.equipment_model, keyword)) return 8
|
||||||
|
if (includesKeyword(equipment.equipment_type, keyword)) return 9
|
||||||
|
if (includesKeyword(equipment.storageLocation?.name, keyword)) return 10
|
||||||
|
return 11
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isManualReturnAssetCandidate = (equipment: ManualReturnAssetCandidate) =>
|
||||||
|
Number(equipment.active_borrow_count) > 0 || equipment.search_state === 'borrow_record_missing'
|
||||||
|
|
||||||
|
export const filterManualReturnAssetCandidates = (
|
||||||
|
list: ManualReturnAssetCandidate[],
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
|
||||||
|
const filtered = list.filter((equipment) => {
|
||||||
|
if (!isManualReturnAssetCandidate(equipment)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
includesKeyword(equipment.equipment_code, normalizedKeyword) ||
|
||||||
|
includesKeyword(equipment.equipment_name, normalizedKeyword) ||
|
||||||
|
includesKeyword(equipment.equipment_model, normalizedKeyword) ||
|
||||||
|
includesKeyword(equipment.equipment_type, normalizedKeyword) ||
|
||||||
|
includesKeyword(equipment.storageLocation?.name, normalizedKeyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered.sort((left, right) => {
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return left.equipment_code.localeCompare(right.equipment_code, 'zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreDiff =
|
||||||
|
getManualReturnAssetSearchScore(left, normalizedKeyword) -
|
||||||
|
getManualReturnAssetSearchScore(right, normalizedKeyword)
|
||||||
|
|
||||||
|
if (scoreDiff !== 0) {
|
||||||
|
return scoreDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.equipment_code.localeCompare(right.equipment_code, 'zh-CN')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findPreferredManualReturnAssetCandidate = (
|
||||||
|
list: ManualReturnAssetCandidate[],
|
||||||
|
keyword: string
|
||||||
|
) => {
|
||||||
|
const normalizedKeyword = normalize(keyword)
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactCodeMatch = list.find(
|
||||||
|
(equipment) => normalize(equipment.equipment_code) === normalizedKeyword
|
||||||
|
)
|
||||||
|
|
||||||
|
if (exactCodeMatch) {
|
||||||
|
return exactCodeMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.length === 1 ? list[0] : null
|
||||||
|
}
|
||||||
11
frontend/src/views/borrow/returnScanState.ts
Normal file
11
frontend/src/views/borrow/returnScanState.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export const hasSelectedReturnRecord = (selectedBorrowRecordId: number | null) =>
|
||||||
|
selectedBorrowRecordId !== null && !Number.isNaN(selectedBorrowRecordId)
|
||||||
|
|
||||||
|
export const canSubmitReturnScan = (options: {
|
||||||
|
hasBorrowRecordInfo: boolean
|
||||||
|
selectedBorrowRecordId: number | null
|
||||||
|
loading: boolean
|
||||||
|
}) =>
|
||||||
|
options.hasBorrowRecordInfo &&
|
||||||
|
hasSelectedReturnRecord(options.selectedBorrowRecordId) &&
|
||||||
|
!options.loading
|
||||||
@ -395,7 +395,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { createEquipment, updateEquipment, getEquipmentDetail, generateEquipmentCode, type EquipmentCreatePayload, type EquipmentUpdatePayload } from '@/api/equipment'
|
import { createEquipment, updateEquipment, getEquipmentDetail, generateEquipmentCode, deleteEquipment, type EquipmentCreatePayload, type EquipmentUpdatePayload } from '@/api/equipment'
|
||||||
import { getStorageLocationOptions } from '@/api/storageLocation'
|
import { getStorageLocationOptions } from '@/api/storageLocation'
|
||||||
import { getAssetTypeOptions } from '@/api/assetType'
|
import { getAssetTypeOptions } from '@/api/assetType'
|
||||||
import { getDepartmentOptions } from '@/api/department'
|
import { getDepartmentOptions } from '@/api/department'
|
||||||
@ -830,8 +830,20 @@ const handleSubmit = async () => {
|
|||||||
const handleCancel = () => router.push('/equipment')
|
const handleCancel = () => router.push('/equipment')
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm('确定要删除这条资产记录吗?')) return
|
if (!isEdit.value) return
|
||||||
// 删除逻辑已在原文件中,这里简化
|
|
||||||
|
const confirmed = confirm(
|
||||||
|
'仅当这条资产只是刚录错编码、还没进入借出、挂载、维修、报废或盘点流程时,才适合删除后重建。确定继续删除吗?'
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteEquipment(Number(route.params.id))
|
||||||
|
alert('资产已删除,请按正确编码重新新建并打印标签。')
|
||||||
|
router.push('/equipment')
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.message || '删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePrintBarcode = () => {
|
const handlePrintBarcode = () => {
|
||||||
|
|||||||
@ -102,6 +102,7 @@
|
|||||||
<div v-if="selectedIds.length > 0" class="mt-3 flex flex-col gap-2 border-t border-border pt-3 lg:flex-row lg:items-center lg:justify-between">
|
<div v-if="selectedIds.length > 0" class="mt-3 flex flex-col gap-2 border-t border-border pt-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="text-sm text-text-secondary">已选 {{ selectedIds.length }} 项</div>
|
<div class="text-sm text-text-secondary">已选 {{ selectedIds.length }} 项</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button @click="handleBatchPrintBarcode" class="btn-secondary btn-secondary--highlight text-sm">批量打印标签</button>
|
||||||
<button @click="showBatchBorrowModal = true" class="btn-secondary text-sm">批量借出</button>
|
<button @click="showBatchBorrowModal = true" class="btn-secondary text-sm">批量借出</button>
|
||||||
<button @click="showBatchReturnModal = true" class="btn-secondary text-sm">批量归还</button>
|
<button @click="showBatchReturnModal = true" class="btn-secondary text-sm">批量归还</button>
|
||||||
<button @click="showBatchTransferModal = true" class="btn-secondary text-sm">批量转移</button>
|
<button @click="showBatchTransferModal = true" class="btn-secondary text-sm">批量转移</button>
|
||||||
@ -158,6 +159,13 @@
|
|||||||
<button v-if="canManageMountAsset(equipment)" @click="toggleExpand(equipment.id)" class="btn-secondary text-xs">
|
<button v-if="canManageMountAsset(equipment)" @click="toggleExpand(equipment.id)" class="btn-secondary text-xs">
|
||||||
{{ expandedIds.has(equipment.id) ? '收起配件' : hasMountedChildren(equipment) ? `管理配件(${equipment.children?.length || 0})` : '管理配件' }}
|
{{ expandedIds.has(equipment.id) ? '收起配件' : hasMountedChildren(equipment) ? `管理配件(${equipment.children?.length || 0})` : '管理配件' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canHandleBorrowAnomaly(equipment)"
|
||||||
|
@click="openBorrowAnomalyHandler(equipment)"
|
||||||
|
class="btn-secondary text-xs"
|
||||||
|
>
|
||||||
|
异常处理
|
||||||
|
</button>
|
||||||
<button @click="handleEdit(equipment.id)" class="btn-secondary text-xs">编辑</button>
|
<button @click="handleEdit(equipment.id)" class="btn-secondary text-xs">编辑</button>
|
||||||
<button @click="handlePrintBarcode(equipment.id)" class="btn-secondary btn-secondary--highlight text-xs">标签</button>
|
<button @click="handlePrintBarcode(equipment.id)" class="btn-secondary btn-secondary--highlight text-xs">标签</button>
|
||||||
<button @click="handleDelete(equipment.id)" class="btn-secondary text-xs !text-status-error">删除</button>
|
<button @click="handleDelete(equipment.id)" class="btn-secondary text-xs !text-status-error">删除</button>
|
||||||
@ -242,6 +250,13 @@
|
|||||||
<button v-if="canManageMountAsset(equipment)" @click.stop="toggleExpand(equipment.id)" class="table-action table-action--primary">
|
<button v-if="canManageMountAsset(equipment)" @click.stop="toggleExpand(equipment.id)" class="table-action table-action--primary">
|
||||||
{{ expandedIds.has(equipment.id) ? '收起配件' : '管理配件' }}
|
{{ expandedIds.has(equipment.id) ? '收起配件' : '管理配件' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canHandleBorrowAnomaly(equipment)"
|
||||||
|
@click.stop="openBorrowAnomalyHandler(equipment)"
|
||||||
|
class="table-action"
|
||||||
|
>
|
||||||
|
异常处理
|
||||||
|
</button>
|
||||||
<button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">编辑</button>
|
<button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">编辑</button>
|
||||||
<button @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button>
|
<button @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button>
|
||||||
<button @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button>
|
<button @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button>
|
||||||
@ -284,13 +299,13 @@
|
|||||||
<CameraScannerModal v-model="showAssetScanner" title="扫码找资产" manual-placeholder="请输入资产编码" @detected="handleAssetDetected" />
|
<CameraScannerModal v-model="showAssetScanner" title="扫码找资产" manual-placeholder="请输入资产编码" @detected="handleAssetDetected" />
|
||||||
|
|
||||||
<!-- 条码打印弹窗 -->
|
<!-- 条码打印弹窗 -->
|
||||||
<div v-if="showBarcodePrint && selectedEquipmentId" class="sheet-backdrop">
|
<div v-if="showBarcodePrint && barcodePrintIds.length > 0" class="sheet-backdrop">
|
||||||
<div class="sheet-panel max-w-4xl">
|
<div class="sheet-panel max-w-4xl">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-xl font-semibold text-text-primary">打印条码</h3>
|
<h3 class="text-xl font-semibold text-text-primary">{{ barcodePrintTitle }}</h3>
|
||||||
<button @click="showBarcodePrint = false" class="text-text-tertiary hover:text-text-primary text-xl">✕</button>
|
<button @click="closeBarcodePrint" class="text-text-tertiary hover:text-text-primary text-xl">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<BarcodePrint :equipment-ids="[selectedEquipmentId]" />
|
<BarcodePrint :equipment-ids="barcodePrintIds" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -366,6 +381,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BorrowAnomalyHandler
|
||||||
|
v-model="showBorrowAnomalyHandler"
|
||||||
|
:asset="borrowAnomalyTarget"
|
||||||
|
@handled="handleBorrowAnomalyHandled"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -383,7 +404,9 @@ import { batchBorrow, batchReturn, batchTransfer } from '@/api/batch'
|
|||||||
import { exportEquipment, downloadFile } from '@/api/export'
|
import { exportEquipment, downloadFile } from '@/api/export'
|
||||||
import { batchImportEquipments } from '@/api/batchImport'
|
import { batchImportEquipments } from '@/api/batchImport'
|
||||||
import { formatCurrencyNoFraction, formatNumber } from '@/utils/format'
|
import { formatCurrencyNoFraction, formatNumber } from '@/utils/format'
|
||||||
|
import { canHandleBorrowAnomaly } from '@/utils/borrowAnomaly'
|
||||||
import BarcodePrint from '@/components/BarcodePrint.vue'
|
import BarcodePrint from '@/components/BarcodePrint.vue'
|
||||||
|
import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
|
||||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||||
import MountHistory from '@/components/MountHistory.vue'
|
import MountHistory from '@/components/MountHistory.vue'
|
||||||
import MountManager from '@/components/MountManager.vue'
|
import MountManager from '@/components/MountManager.vue'
|
||||||
@ -427,18 +450,29 @@ const isAllSelected = computed(() => selectableIds.value.length > 0 && selectabl
|
|||||||
|
|
||||||
const showBarcodePrint = ref(false)
|
const showBarcodePrint = ref(false)
|
||||||
const showAssetScanner = ref(false)
|
const showAssetScanner = ref(false)
|
||||||
const selectedEquipmentId = ref<number | null>(null)
|
const barcodePrintIds = ref<number[]>([])
|
||||||
const showBatchBorrowModal = ref(false)
|
const showBatchBorrowModal = ref(false)
|
||||||
const showBatchReturnModal = ref(false)
|
const showBatchReturnModal = ref(false)
|
||||||
const showBatchTransferModal = ref(false)
|
const showBatchTransferModal = ref(false)
|
||||||
const showMoreActions = ref(false)
|
const showMoreActions = ref(false)
|
||||||
const showAdvancedFilters = ref(false)
|
const showAdvancedFilters = ref(false)
|
||||||
|
const showBorrowAnomalyHandler = ref(false)
|
||||||
|
const borrowAnomalyTarget = ref<{
|
||||||
|
equipmentId: number
|
||||||
|
equipmentCode: string
|
||||||
|
equipmentName: string
|
||||||
|
equipmentModel?: string | null
|
||||||
|
equipmentType?: string | null
|
||||||
|
storageLocationName?: string | null
|
||||||
|
message?: string | null
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
const batchBorrowForm = ref({ person_id: '', is_long_term: false, expected_return_time: '' })
|
const batchBorrowForm = ref({ person_id: '', is_long_term: false, expected_return_time: '' })
|
||||||
const batchReturnForm = ref({ return_condition: '正常', notes: '' })
|
const batchReturnForm = ref({ return_condition: '正常', notes: '' })
|
||||||
const batchTransferForm = ref({ department_id: '' })
|
const batchTransferForm = ref({ department_id: '' })
|
||||||
|
|
||||||
const hasAdvancedFilters = computed(() => Boolean(filters.value.mountStatus || filters.value.storageLocationId || filters.value.departmentId))
|
const hasAdvancedFilters = computed(() => Boolean(filters.value.mountStatus || filters.value.storageLocationId || filters.value.departmentId))
|
||||||
|
const barcodePrintTitle = computed(() => barcodePrintIds.value.length > 1 ? `批量打印标签(${barcodePrintIds.value.length} 项)` : '打印条码')
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const params = {
|
const params = {
|
||||||
@ -493,10 +527,44 @@ const handleDelete = async (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePrintBarcode = (id: number) => {
|
const handlePrintBarcode = (id: number) => {
|
||||||
selectedEquipmentId.value = id
|
barcodePrintIds.value = [id]
|
||||||
showBarcodePrint.value = true
|
showBarcodePrint.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBatchPrintBarcode = () => {
|
||||||
|
if (selectedIds.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
barcodePrintIds.value = Array.from(new Set(selectedIds.value))
|
||||||
|
showBarcodePrint.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBarcodePrint = () => {
|
||||||
|
showBarcodePrint.value = false
|
||||||
|
barcodePrintIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBorrowAnomalyHandler = (equipment: Equipment) => {
|
||||||
|
borrowAnomalyTarget.value = {
|
||||||
|
equipmentId: equipment.id,
|
||||||
|
equipmentCode: equipment.equipment_code,
|
||||||
|
equipmentName: equipment.equipment_name,
|
||||||
|
equipmentModel: equipment.equipment_model,
|
||||||
|
equipmentType: equipment.equipment_type,
|
||||||
|
storageLocationName: equipment.storageLocation?.name || null,
|
||||||
|
message: equipment.borrow_anomaly?.message || null
|
||||||
|
}
|
||||||
|
showBorrowAnomalyHandler.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBorrowAnomalyHandled = async (payload: { action: 'mark-returned' | 'register-borrow'; message: string }) => {
|
||||||
|
alert(payload.message)
|
||||||
|
showBorrowAnomalyHandler.value = false
|
||||||
|
borrowAnomalyTarget.value = null
|
||||||
|
await fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
const handleAssetDetected = async (value: string) => {
|
const handleAssetDetected = async (value: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await scanEquipment(value.trim())
|
const response = await scanEquipment(value.trim())
|
||||||
@ -646,7 +714,7 @@ function hasMountedChildren(equipment: Equipment) {
|
|||||||
const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?: Equipment['current_borrow'] | null }) => {
|
const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?: Equipment['current_borrow'] | null }) => {
|
||||||
const currentBorrow = equipment.current_borrow
|
const currentBorrow = equipment.current_borrow
|
||||||
if (!currentBorrow) {
|
if (!currentBorrow) {
|
||||||
return equipment.status === '已借出' ? '已借出,待核对' : '未借出'
|
return equipment.status === '已借出' ? '借用单缺失' : '未借出'
|
||||||
}
|
}
|
||||||
|
|
||||||
const nickname = currentBorrow.person?.nickname || currentBorrow.person_nickname
|
const nickname = currentBorrow.person?.nickname || currentBorrow.person_nickname
|
||||||
@ -656,13 +724,22 @@ const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?:
|
|||||||
return `${nickname}(${realName})`
|
return `${nickname}(${realName})`
|
||||||
}
|
}
|
||||||
|
|
||||||
return nickname || realName || '待核对'
|
return nickname || realName || currentBorrow.person_identifier || '使用者待补充'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCurrentBorrowerMeta = (equipment: { current_borrow?: Equipment['current_borrow'] | null }) => {
|
const getCurrentBorrowerMeta = (equipment: {
|
||||||
|
status?: string
|
||||||
|
current_borrow?: Equipment['current_borrow'] | null
|
||||||
|
}) => {
|
||||||
const currentBorrow = equipment.current_borrow
|
const currentBorrow = equipment.current_borrow
|
||||||
|
if (!currentBorrow) {
|
||||||
|
return equipment.status === '已借出'
|
||||||
|
? '资产状态为已借出,但系统中没有对应借用记录'
|
||||||
|
: '暂无借用记录'
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentBorrow?.borrow_time) {
|
if (!currentBorrow?.borrow_time) {
|
||||||
return '暂无借用记录'
|
return '借出时间待补充'
|
||||||
}
|
}
|
||||||
|
|
||||||
const borrowDate = currentBorrow.borrow_time.includes('T')
|
const borrowDate = currentBorrow.borrow_time.includes('T')
|
||||||
|
|||||||
@ -19,12 +19,12 @@
|
|||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
<p class="page-meta__label">当前范围</p>
|
<p class="page-meta__label">当前范围</p>
|
||||||
<p class="page-meta__value text-base">{{ currentScopeLabel }}</p>
|
<p class="page-meta__value text-base">{{ currentScopeLabel }}</p>
|
||||||
<p class="page-meta__subvalue">部门/类型留空表示全量</p>
|
<p class="page-meta__subvalue">同维度多选并集,跨维度自动取交集</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
<p class="page-meta__label">预估参与人</p>
|
<p class="page-meta__label">预估参与人</p>
|
||||||
<p class="page-meta__value">{{ formatNumber(preview?.participants_count || 0) }}</p>
|
<p class="page-meta__value">{{ formatNumber(preview?.participants_count || 0) }}</p>
|
||||||
<p class="page-meta__subvalue">来自借用关系快照</p>
|
<p class="page-meta__subvalue">仅纳入能明确挂到 1 位当前使用人的资产</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
<p class="page-meta__label">预估资产数</p>
|
<p class="page-meta__label">预估资产数</p>
|
||||||
@ -47,27 +47,9 @@
|
|||||||
v-model.trim="form.title"
|
v-model.trim="form.title"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="例如:2026 年 3 月办公区资产盘点"
|
placeholder="例如:2026 年 3 月在用资产盘点"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-stack">
|
|
||||||
<label class="field-label">业务部门</label>
|
|
||||||
<select v-model="form.department_id" class="select">
|
|
||||||
<option value="">全部业务部门</option>
|
|
||||||
<option v-for="department in departments" :key="department.id" :value="String(department.id)">
|
|
||||||
{{ department.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field-stack">
|
|
||||||
<label class="field-label">资产类型</label>
|
|
||||||
<select v-model="form.asset_type_id" class="select">
|
|
||||||
<option value="">全部资产类型</option>
|
|
||||||
<option v-for="assetType in assetTypes" :key="assetType.id" :value="String(assetType.id)">
|
|
||||||
{{ assetType.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field-stack lg:col-span-2">
|
<div class="field-stack lg:col-span-2">
|
||||||
<label class="field-label">任务说明</label>
|
<label class="field-label">任务说明</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -80,12 +62,176 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card space-y-6">
|
||||||
|
<div class="list-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title">盘点范围</h3>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">留空表示该维度不限;同一维度可以多选。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-3">
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-text-primary">业务部门</p>
|
||||||
|
<p class="mt-1 text-xs text-text-tertiary">{{ selectedDepartmentLabel }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-xs"
|
||||||
|
:disabled="form.department_ids.length === 0"
|
||||||
|
@click="clearSelection('department_ids')"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedDepartments.length > 0" class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="department in selectedDepartments"
|
||||||
|
:key="department.id"
|
||||||
|
class="badge badge-neutral"
|
||||||
|
>
|
||||||
|
{{ department.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel max-h-72 space-y-2 overflow-auto">
|
||||||
|
<label
|
||||||
|
v-for="department in departments"
|
||||||
|
:key="department.id"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="form.department_ids.includes(department.id)"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
|
||||||
|
@change="toggleSelection('department_ids', department.id)"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block text-sm text-text-primary">{{ department.name }}</span>
|
||||||
|
<span v-if="department.parent_name" class="mt-1 block text-xs text-text-tertiary">
|
||||||
|
上级:{{ department.parent_name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="departments.length === 0" class="py-6 text-center text-sm text-text-tertiary">
|
||||||
|
暂无可选业务部门
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-text-primary">资产类型</p>
|
||||||
|
<p class="mt-1 text-xs text-text-tertiary">{{ selectedAssetTypeLabel }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-xs"
|
||||||
|
:disabled="form.asset_type_ids.length === 0"
|
||||||
|
@click="clearSelection('asset_type_ids')"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedAssetTypes.length > 0" class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="assetType in selectedAssetTypes"
|
||||||
|
:key="assetType.id"
|
||||||
|
class="badge badge-neutral"
|
||||||
|
>
|
||||||
|
{{ assetType.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel max-h-72 space-y-2 overflow-auto">
|
||||||
|
<label
|
||||||
|
v-for="assetType in assetTypes"
|
||||||
|
:key="assetType.id"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="form.asset_type_ids.includes(assetType.id)"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
|
||||||
|
@change="toggleSelection('asset_type_ids', assetType.id)"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block text-sm text-text-primary">{{ assetType.name }}</span>
|
||||||
|
<span v-if="assetType.short_name" class="mt-1 block text-xs text-text-tertiary">
|
||||||
|
编码缩写:{{ assetType.short_name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="assetTypes.length === 0" class="py-6 text-center text-sm text-text-tertiary">
|
||||||
|
暂无可选资产类型
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-text-primary">地区</p>
|
||||||
|
<p class="mt-1 text-xs text-text-tertiary">{{ selectedStorageLocationLabel }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-xs"
|
||||||
|
:disabled="form.storage_location_ids.length === 0"
|
||||||
|
@click="clearSelection('storage_location_ids')"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedStorageLocations.length > 0" class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="location in selectedStorageLocations"
|
||||||
|
:key="location.id"
|
||||||
|
class="badge badge-neutral"
|
||||||
|
>
|
||||||
|
{{ location.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel max-h-72 space-y-2 overflow-auto">
|
||||||
|
<label
|
||||||
|
v-for="location in storageLocations"
|
||||||
|
:key="location.id"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="form.storage_location_ids.includes(location.id)"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
|
||||||
|
@change="toggleSelection('storage_location_ids', location.id)"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block text-sm text-text-primary">{{ location.name }}</span>
|
||||||
|
<span v-if="location.code" class="mt-1 block text-xs text-text-tertiary">
|
||||||
|
编码:{{ location.code }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="storageLocations.length === 0" class="py-6 text-center text-sm text-text-tertiary">
|
||||||
|
暂无可选地区
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<h3 class="section-title">范围预览</h3>
|
<h3 class="section-title">范围预览</h3>
|
||||||
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
|
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
|
||||||
{{ previewLoading ? '刷新中...' : '刷新' }}
|
{{ previewLoading ? '刷新中...' : '刷新' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="previewLoading" class="empty-state">正在根据当前范围生成预览...</div>
|
<div v-if="previewLoading" class="empty-state">正在根据当前范围生成预览...</div>
|
||||||
@ -95,17 +241,17 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p class="mobile-card__label">参与人</p>
|
<p class="mobile-card__label">参与人</p>
|
||||||
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.participants_count) }}</p>
|
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.participants_count) }}</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">自动生成名单</p>
|
<p class="mt-1 text-xs text-text-secondary">只纳入有明确当前使用人的资产</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p class="mobile-card__label">待盘点资产</p>
|
<p class="mobile-card__label">待盘点资产</p>
|
||||||
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.items_count) }}</p>
|
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.items_count) }}</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">按当前快照生成</p>
|
<p class="mt-1 text-xs text-text-secondary">按当前借用快照生成</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p class="mobile-card__label">无人归属</p>
|
<p class="mobile-card__label">无人归属</p>
|
||||||
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p>
|
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">创建后尽快分配</p>
|
<p class="mt-1 text-xs text-text-secondary">新规则下应为 0</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -185,13 +331,11 @@
|
|||||||
<p class="text-sm font-medium text-text-primary">{{ item.equipment_name }}</p>
|
<p class="text-sm font-medium text-text-primary">{{ item.equipment_name }}</p>
|
||||||
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
|
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
|
||||||
<p class="mt-2 text-xs text-text-secondary">
|
<p class="mt-2 text-xs text-text-secondary">
|
||||||
{{ item.snapshot_person_nickname || '待分配人员' }}
|
{{ item.snapshot_person_nickname || '未纳入' }}
|
||||||
<span v-if="item.snapshot_department_name"> · {{ item.snapshot_department_name }}</span>
|
<span v-if="item.snapshot_department_name"> · {{ item.snapshot_department_name }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="item.is_unassigned ? 'badge-warning' : 'badge-neutral'">
|
<span class="badge badge-neutral">已纳入</span>
|
||||||
{{ item.is_unassigned ? '待分配' : '已归属' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -210,13 +354,16 @@ import { useRouter } from 'vue-router'
|
|||||||
import { getAssetTypeOptions } from '@/api/assetType'
|
import { getAssetTypeOptions } from '@/api/assetType'
|
||||||
import { getDepartmentOptions } from '@/api/department'
|
import { getDepartmentOptions } from '@/api/department'
|
||||||
import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
|
import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
|
||||||
import type { AssetType, Department, InventoryTaskPreview } from '@/types'
|
import { getStorageLocationOptions } from '@/api/storageLocation'
|
||||||
|
import type { AssetType, Department, InventoryTaskPreview, StorageLocation } from '@/types'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/format'
|
||||||
|
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const departments = ref<Department[]>([])
|
const departments = ref<Department[]>([])
|
||||||
const assetTypes = ref<AssetType[]>([])
|
const assetTypes = ref<AssetType[]>([])
|
||||||
|
const storageLocations = ref<StorageLocation[]>([])
|
||||||
const preview = ref<InventoryTaskPreview | null>(null)
|
const preview = ref<InventoryTaskPreview | null>(null)
|
||||||
const previewLoading = ref(false)
|
const previewLoading = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
@ -225,41 +372,78 @@ const previewDirty = ref(true)
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
department_id: '',
|
department_ids: [] as number[],
|
||||||
asset_type_id: ''
|
asset_type_ids: [] as number[],
|
||||||
|
storage_location_ids: [] as number[]
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedDepartment = computed(
|
const selectedDepartments = computed(() =>
|
||||||
() => departments.value.find((department) => String(department.id) === form.department_id) || null
|
departments.value.filter((department) => form.department_ids.includes(department.id))
|
||||||
)
|
)
|
||||||
const selectedAssetType = computed(
|
const selectedAssetTypes = computed(() =>
|
||||||
() => assetTypes.value.find((assetType) => String(assetType.id) === form.asset_type_id) || null
|
assetTypes.value.filter((assetType) => form.asset_type_ids.includes(assetType.id))
|
||||||
|
)
|
||||||
|
const selectedStorageLocations = computed(() =>
|
||||||
|
storageLocations.value.filter((location) => form.storage_location_ids.includes(location.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentScopeLabel = computed(() =>
|
||||||
|
buildInventoryScopeLabel({
|
||||||
|
scope_departments: selectedDepartments.value,
|
||||||
|
scope_asset_types: selectedAssetTypes.value,
|
||||||
|
scope_storage_locations: selectedStorageLocations.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const selectedDepartmentLabel = computed(() =>
|
||||||
|
form.department_ids.length > 0 ? `已选 ${form.department_ids.length} 个业务部门` : '留空表示全部业务部门'
|
||||||
|
)
|
||||||
|
const selectedAssetTypeLabel = computed(() =>
|
||||||
|
form.asset_type_ids.length > 0 ? `已选 ${form.asset_type_ids.length} 个资产类型` : '留空表示全部资产类型'
|
||||||
|
)
|
||||||
|
const selectedStorageLocationLabel = computed(() =>
|
||||||
|
form.storage_location_ids.length > 0 ? `已选 ${form.storage_location_ids.length} 个地区` : '留空表示全部地区'
|
||||||
)
|
)
|
||||||
const currentScopeLabel = computed(() => {
|
|
||||||
const segments = []
|
|
||||||
if (selectedDepartment.value) {
|
|
||||||
segments.push(`部门:${selectedDepartment.value.name}`)
|
|
||||||
}
|
|
||||||
if (selectedAssetType.value) {
|
|
||||||
segments.push(`资产类型:${selectedAssetType.value.name}`)
|
|
||||||
}
|
|
||||||
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
|
|
||||||
})
|
|
||||||
const canCreate = computed(() => form.title.length > 0 && !creating.value)
|
const canCreate = computed(() => form.title.length > 0 && !creating.value)
|
||||||
|
|
||||||
const buildPreviewPayload = () => ({
|
const buildPreviewPayload = () => ({
|
||||||
department_id: form.department_id ? Number(form.department_id) : null,
|
department_ids: [...form.department_ids],
|
||||||
asset_type_id: form.asset_type_id ? Number(form.asset_type_id) : null
|
asset_type_ids: [...form.asset_type_ids],
|
||||||
|
storage_location_ids: [...form.storage_location_ids]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleSelection = (
|
||||||
|
key: 'department_ids' | 'asset_type_ids' | 'storage_location_ids',
|
||||||
|
id: number
|
||||||
|
) => {
|
||||||
|
const currentValues = form[key]
|
||||||
|
const nextValues = currentValues.includes(id)
|
||||||
|
? currentValues.filter((currentId) => currentId !== id)
|
||||||
|
: [...currentValues, id]
|
||||||
|
|
||||||
|
form[key] = nextValues.sort((left, right) => left - right) as never
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelection = (key: 'department_ids' | 'asset_type_ids' | 'storage_location_ids') => {
|
||||||
|
form[key] = [] as never
|
||||||
|
}
|
||||||
|
|
||||||
const loadOptions = async () => {
|
const loadOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const [departmentResponse, assetTypeResponse] = await Promise.all([
|
const [departmentResponse, assetTypeResponse, storageLocationResponse] = await Promise.all([
|
||||||
getDepartmentOptions(),
|
getDepartmentOptions(),
|
||||||
getAssetTypeOptions()
|
getAssetTypeOptions(),
|
||||||
|
getStorageLocationOptions()
|
||||||
])
|
])
|
||||||
departments.value = departmentResponse.data.filter((department) => department.status === 'active')
|
|
||||||
assetTypes.value = assetTypeResponse.data.filter((assetType) => assetType.status === 'active')
|
departments.value = (departmentResponse.data || [])
|
||||||
|
.filter((department) => department.status === 'active')
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||||
|
assetTypes.value = (assetTypeResponse.data || [])
|
||||||
|
.filter((assetType) => assetType.status === 'active')
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||||
|
storageLocations.value = (storageLocationResponse.data || [])
|
||||||
|
.filter((location) => location.status === 'active')
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error?.message || '加载盘点范围选项失败')
|
alert(error?.message || '加载盘点范围选项失败')
|
||||||
}
|
}
|
||||||
@ -316,7 +500,7 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [form.department_id, form.asset_type_id],
|
() => [form.department_ids.join(','), form.asset_type_ids.join(','), form.storage_location_ids.join(',')],
|
||||||
() => {
|
() => {
|
||||||
previewDirty.value = true
|
previewDirty.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,15 @@
|
|||||||
:disabled="closing"
|
:disabled="closing"
|
||||||
@click="handleCloseTask"
|
@click="handleCloseTask"
|
||||||
>
|
>
|
||||||
{{ closing ? '关闭中...' : '关闭任务' }}
|
{{ closing ? '结束中...' : '结束任务' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm text-status-error"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="handleDeleteTask"
|
||||||
|
>
|
||||||
|
{{ deleting ? '删除中...' : '删除任务' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -435,6 +443,7 @@ import { getPersonOptions } from '@/api/person'
|
|||||||
import {
|
import {
|
||||||
assignInventoryTaskUnassignedItem,
|
assignInventoryTaskUnassignedItem,
|
||||||
closeInventoryTask,
|
closeInventoryTask,
|
||||||
|
deleteInventoryTask,
|
||||||
getInventoryTaskDetail,
|
getInventoryTaskDetail,
|
||||||
getInventoryTaskUnassignedItems
|
getInventoryTaskUnassignedItems
|
||||||
} from '@/api/inventory'
|
} from '@/api/inventory'
|
||||||
@ -446,6 +455,7 @@ import type {
|
|||||||
Person
|
Person
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
||||||
|
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||||||
|
|
||||||
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
|
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
|
||||||
|
|
||||||
@ -464,6 +474,7 @@ const unassignedItems = ref<InventoryTaskItem[]>([])
|
|||||||
const personOptions = ref<Person[]>([])
|
const personOptions = ref<Person[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const closing = ref(false)
|
const closing = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
const assigningItemId = ref<number | null>(null)
|
const assigningItemId = ref<number | null>(null)
|
||||||
const itemFilter = ref<ItemFilter>('all')
|
const itemFilter = ref<ItemFilter>('all')
|
||||||
const participantFilter = ref('all')
|
const participantFilter = ref('all')
|
||||||
@ -474,17 +485,7 @@ const taskId = computed(() => Number(route.params.id))
|
|||||||
const publicLink = computed(() =>
|
const publicLink = computed(() =>
|
||||||
task.value ? `${window.location.origin}/inventory/task/${task.value.public_token}` : ''
|
task.value ? `${window.location.origin}/inventory/task/${task.value.public_token}` : ''
|
||||||
)
|
)
|
||||||
const scopeLabel = computed(() => {
|
const scopeLabel = computed(() => (task.value ? buildInventoryScopeLabel(task.value) : '-'))
|
||||||
if (!task.value) return '-'
|
|
||||||
const segments = []
|
|
||||||
if (task.value.scope_department_name) {
|
|
||||||
segments.push(`部门:${task.value.scope_department_name}`)
|
|
||||||
}
|
|
||||||
if (task.value.scope_asset_type_name) {
|
|
||||||
segments.push(`资产类型:${task.value.scope_asset_type_name}`)
|
|
||||||
}
|
|
||||||
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
|
|
||||||
})
|
|
||||||
const taskProgressText = computed(() => {
|
const taskProgressText = computed(() => {
|
||||||
if (!task.value || task.value.items_count === 0) return '暂无资产'
|
if (!task.value || task.value.items_count === 0) return '暂无资产'
|
||||||
return `${formatPercent((task.value.completed_count / task.value.items_count) * 100)}`
|
return `${formatPercent((task.value.completed_count / task.value.items_count) * 100)}`
|
||||||
@ -688,21 +689,39 @@ const openPublicLink = () => {
|
|||||||
|
|
||||||
const handleCloseTask = async () => {
|
const handleCloseTask = async () => {
|
||||||
if (!task.value) return
|
if (!task.value) return
|
||||||
const shouldClose = window.confirm('关闭后手机端将不能继续提交盘点结果,确定关闭吗?')
|
const shouldClose = window.confirm('结束后手机端将不能继续提交盘点结果,确定结束吗?')
|
||||||
if (!shouldClose) return
|
if (!shouldClose) return
|
||||||
|
|
||||||
closing.value = true
|
closing.value = true
|
||||||
try {
|
try {
|
||||||
await closeInventoryTask(task.value.id)
|
await closeInventoryTask(task.value.id)
|
||||||
alert('盘点任务已关闭')
|
alert('盘点任务已结束')
|
||||||
loadTask()
|
loadTask()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error?.message || '关闭盘点任务失败')
|
alert(error?.message || '结束盘点任务失败')
|
||||||
} finally {
|
} finally {
|
||||||
closing.value = false
|
closing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteTask = async () => {
|
||||||
|
if (!task.value) return
|
||||||
|
|
||||||
|
const shouldDelete = window.confirm('删除后该任务、参与人、资产明细和动作记录都会一起移除,确定删除吗?')
|
||||||
|
if (!shouldDelete) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteInventoryTask(task.value.id)
|
||||||
|
alert('盘点任务已删除')
|
||||||
|
router.push('/inventory')
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.message || '删除盘点任务失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const assignItem = async (itemId: number) => {
|
const assignItem = async (itemId: number) => {
|
||||||
if (!task.value) return
|
if (!task.value) return
|
||||||
const personId = Number(assignPersonIdMap[itemId])
|
const personId = Number(assignPersonIdMap[itemId])
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
<p class="page-meta__label">当前页进行中</p>
|
<p class="page-meta__label">当前页进行中</p>
|
||||||
<p class="page-meta__value">{{ formatNumber(activeCount) }}</p>
|
<p class="page-meta__value">{{ formatNumber(activeCount) }}</p>
|
||||||
<p class="page-meta__subvalue">已关闭 {{ formatNumber(closedCount) }} 个</p>
|
<p class="page-meta__subvalue">已结束 {{ formatNumber(closedCount) }} 个</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
<p class="page-meta__label">当前页完成率</p>
|
<p class="page-meta__label">当前页完成率</p>
|
||||||
@ -87,12 +87,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-2">
|
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||||
<router-link :to="`/inventory/${task.id}`" class="btn-primary w-full text-sm">查看详情</router-link>
|
<router-link :to="`/inventory/${task.id}`" class="btn-primary w-full text-sm">查看详情</router-link>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<button
|
||||||
<button type="button" class="btn-secondary text-xs" @click="copyTaskLink(task)">复制手机链接</button>
|
type="button"
|
||||||
<button type="button" class="btn-secondary text-xs" @click="openTaskLink(task)">打开手机页</button>
|
class="btn-secondary w-full text-sm"
|
||||||
</div>
|
:disabled="task.status !== 'active' || actionLoadingId === task.id"
|
||||||
|
@click="handleCloseTask(task)"
|
||||||
|
>
|
||||||
|
{{ actionLoadingId === task.id && actionType === 'close' ? '结束中...' : '结束任务' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" @click="copyTaskLink(task)">复制手机链接</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" @click="openTaskLink(task)">打开手机页</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm text-status-error sm:col-span-2"
|
||||||
|
:disabled="actionLoadingId === task.id && actionType === 'delete'"
|
||||||
|
@click="handleDeleteTask(task)"
|
||||||
|
>
|
||||||
|
{{ actionLoadingId === task.id && actionType === 'delete' ? '删除中...' : '删除任务' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@ -150,13 +164,29 @@
|
|||||||
<td>
|
<td>
|
||||||
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
|
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">{{ formatDateTime(task.created_at) }}</p>
|
<p class="mt-1 text-xs text-text-secondary">{{ formatDateTime(task.created_at) }}</p>
|
||||||
<p v-if="task.closed_at" class="mt-1 text-xs text-text-tertiary">关闭于 {{ formatDateTime(task.closed_at) }}</p>
|
<p v-if="task.closed_at" class="mt-1 text-xs text-text-tertiary">结束于 {{ formatDateTime(task.closed_at) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="table-action-group">
|
<div class="table-action-group">
|
||||||
<router-link :to="`/inventory/${task.id}`" class="table-action table-action--primary">详情</router-link>
|
<router-link :to="`/inventory/${task.id}`" class="table-action table-action--primary">详情</router-link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="table-action"
|
||||||
|
:disabled="task.status !== 'active' || actionLoadingId === task.id"
|
||||||
|
@click="handleCloseTask(task)"
|
||||||
|
>
|
||||||
|
{{ actionLoadingId === task.id && actionType === 'close' ? '结束中' : '结束' }}
|
||||||
|
</button>
|
||||||
<button type="button" class="table-action" @click="copyTaskLink(task)">复制链接</button>
|
<button type="button" class="table-action" @click="copyTaskLink(task)">复制链接</button>
|
||||||
<button type="button" class="table-action" @click="openTaskLink(task)">打开手机页</button>
|
<button type="button" class="table-action" @click="openTaskLink(task)">手机页</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="table-action text-status-error"
|
||||||
|
:disabled="actionLoadingId === task.id && actionType === 'delete'"
|
||||||
|
@click="handleDeleteTask(task)"
|
||||||
|
>
|
||||||
|
{{ actionLoadingId === task.id && actionType === 'delete' ? '删除中' : '删除' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -183,15 +213,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { getInventoryTaskList } from '@/api/inventory'
|
import { closeInventoryTask, deleteInventoryTask, getInventoryTaskList } from '@/api/inventory'
|
||||||
import type { InventoryTaskListItem } from '@/types'
|
import type { InventoryTaskListItem } from '@/types'
|
||||||
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
||||||
|
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||||||
|
|
||||||
const tasks = ref<InventoryTaskListItem[]>([])
|
const tasks = ref<InventoryTaskListItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
|
const actionLoadingId = ref<number | null>(null)
|
||||||
|
const actionType = ref<'close' | 'delete' | null>(null)
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
const activeCount = computed(() => tasks.value.filter((task) => task.status === 'active').length)
|
const activeCount = computed(() => tasks.value.filter((task) => task.status === 'active').length)
|
||||||
@ -226,18 +259,9 @@ const fetchTasks = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveScopeLabel = (task: InventoryTaskListItem) => {
|
const resolveScopeLabel = (task: InventoryTaskListItem) => buildInventoryScopeLabel(task)
|
||||||
const segments = []
|
|
||||||
if (task.scope_department_name) {
|
|
||||||
segments.push(`部门:${task.scope_department_name}`)
|
|
||||||
}
|
|
||||||
if (task.scope_asset_type_name) {
|
|
||||||
segments.push(`资产类型:${task.scope_asset_type_name}`)
|
|
||||||
}
|
|
||||||
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTaskStatusText = (status: string) => (status === 'closed' ? '已关闭' : '进行中')
|
const getTaskStatusText = (status: string) => (status === 'closed' ? '已结束' : '进行中')
|
||||||
|
|
||||||
const getTaskStatusClass = (status: string) => (status === 'closed' ? 'badge-neutral' : 'badge-success')
|
const getTaskStatusClass = (status: string) => (status === 'closed' ? 'badge-neutral' : 'badge-success')
|
||||||
|
|
||||||
@ -263,6 +287,46 @@ const openTaskLink = (task: InventoryTaskListItem) => {
|
|||||||
window.open(buildTaskPublicUrl(task), '_blank', 'noopener,noreferrer')
|
window.open(buildTaskPublicUrl(task), '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCloseTask = async (task: InventoryTaskListItem) => {
|
||||||
|
if (task.status !== 'active') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldClose = window.confirm('结束后手机端将不能继续提交盘点结果,确定结束吗?')
|
||||||
|
if (!shouldClose) return
|
||||||
|
|
||||||
|
actionLoadingId.value = task.id
|
||||||
|
actionType.value = 'close'
|
||||||
|
try {
|
||||||
|
await closeInventoryTask(task.id)
|
||||||
|
alert('盘点任务已结束')
|
||||||
|
await fetchTasks()
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.message || '结束盘点任务失败')
|
||||||
|
} finally {
|
||||||
|
actionLoadingId.value = null
|
||||||
|
actionType.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTask = async (task: InventoryTaskListItem) => {
|
||||||
|
const shouldDelete = window.confirm('删除后该任务、参与人、资产明细和动作记录都会一起移除,确定删除吗?')
|
||||||
|
if (!shouldDelete) return
|
||||||
|
|
||||||
|
actionLoadingId.value = task.id
|
||||||
|
actionType.value = 'delete'
|
||||||
|
try {
|
||||||
|
await deleteInventoryTask(task.id)
|
||||||
|
alert('盘点任务已删除')
|
||||||
|
await fetchTasks()
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.message || '删除盘点任务失败')
|
||||||
|
} finally {
|
||||||
|
actionLoadingId.value = null
|
||||||
|
actionType.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handlePageSizeChange = () => {
|
const handlePageSizeChange = () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
fetchTasks()
|
fetchTasks()
|
||||||
|
|||||||
@ -433,6 +433,7 @@ import type {
|
|||||||
PublicInventoryTaskInfo
|
PublicInventoryTaskInfo
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { formatDateTime, formatNumber, formatPercent, playBeepSound, playErrorSound } from '@/utils/format'
|
import { formatDateTime, formatNumber, formatPercent, playBeepSound, playErrorSound } from '@/utils/format'
|
||||||
|
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||||||
|
|
||||||
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
|
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
|
||||||
type FeedbackType = 'success' | 'error' | 'info'
|
type FeedbackType = 'success' | 'error' | 'info'
|
||||||
@ -492,14 +493,7 @@ const displayedTask = computed(() => {
|
|||||||
const isTaskClosed = computed(() => displayedTask.value?.status === 'closed')
|
const isTaskClosed = computed(() => displayedTask.value?.status === 'closed')
|
||||||
const scopeLabel = computed(() => {
|
const scopeLabel = computed(() => {
|
||||||
if (!displayedTask.value) return '等待加载任务信息'
|
if (!displayedTask.value) return '等待加载任务信息'
|
||||||
const segments = []
|
return buildInventoryScopeLabel(displayedTask.value)
|
||||||
if (displayedTask.value.scope_department_name) {
|
|
||||||
segments.push(`部门:${displayedTask.value.scope_department_name}`)
|
|
||||||
}
|
|
||||||
if (displayedTask.value.scope_asset_type_name) {
|
|
||||||
segments.push(`资产类型:${displayedTask.value.scope_asset_type_name}`)
|
|
||||||
}
|
|
||||||
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
|
|
||||||
})
|
})
|
||||||
const myProgressPercent = computed(() => {
|
const myProgressPercent = computed(() => {
|
||||||
if (summary.value.total === 0) return 0
|
if (summary.value.total === 0) return 0
|
||||||
|
|||||||
60
frontend/src/views/inventory/inventoryScopeSummary.ts
Normal file
60
frontend/src/views/inventory/inventoryScopeSummary.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { InventoryScopeEntity } from '@/types'
|
||||||
|
|
||||||
|
type InventoryScopeLike = {
|
||||||
|
scope_departments?: InventoryScopeEntity[] | null
|
||||||
|
scope_asset_types?: InventoryScopeEntity[] | null
|
||||||
|
scope_storage_locations?: InventoryScopeEntity[] | null
|
||||||
|
scope_department_name?: string | null
|
||||||
|
scope_asset_type_name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeScopeEntities = (
|
||||||
|
values?: InventoryScopeEntity[] | null,
|
||||||
|
fallbackName?: string | null
|
||||||
|
) => {
|
||||||
|
if (Array.isArray(values) && values.length > 0) {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackName) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
name: fallbackName
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatScopeSegment = (label: string, values: InventoryScopeEntity[]) => {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.length === 1) {
|
||||||
|
return `${label}:${values[0].name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label}:已选 ${values.length} 项`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildInventoryScopeLabel = (scope: InventoryScopeLike) => {
|
||||||
|
const segments = [
|
||||||
|
formatScopeSegment(
|
||||||
|
'部门',
|
||||||
|
normalizeScopeEntities(scope.scope_departments, scope.scope_department_name)
|
||||||
|
),
|
||||||
|
formatScopeSegment(
|
||||||
|
'资产类型',
|
||||||
|
normalizeScopeEntities(scope.scope_asset_types, scope.scope_asset_type_name)
|
||||||
|
),
|
||||||
|
formatScopeSegment(
|
||||||
|
'地区',
|
||||||
|
normalizeScopeEntities(scope.scope_storage_locations)
|
||||||
|
)
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
|
||||||
|
}
|
||||||
@ -105,22 +105,6 @@
|
|||||||
{{ pair.label }} {{ pair.count }}
|
{{ pair.label }} {{ pair.count }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="item.metadata?.preview_changes?.length"
|
|
||||||
class="mt-4 panel"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-text-primary">变化预览</p>
|
|
||||||
<div class="mt-3 space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="change in item.metadata.preview_changes"
|
|
||||||
:key="`${item.id}-${change.title}`"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-text-primary">{{ change.title }}</p>
|
|
||||||
<p class="mt-1 text-sm text-text-secondary">{{ change.detail }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex shrink-0 flex-wrap gap-3 lg:w-[220px] lg:flex-col">
|
<div class="flex shrink-0 flex-wrap gap-3 lg:w-[220px] lg:flex-col">
|
||||||
@ -163,31 +147,413 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div v-if="syncDetailOpen" class="sheet-backdrop" @click.self="closeSyncDetail">
|
||||||
|
<div class="sheet-panel max-w-5xl">
|
||||||
|
<div class="form-panel__header">
|
||||||
|
<div>
|
||||||
|
<h3 class="form-panel__title">同步详情</h3>
|
||||||
|
<p class="form-panel__desc">
|
||||||
|
这里只看这一条同步消息对应的完整变更明细,不再跳到设置页。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-secondary text-sm" @click="closeSyncDetail">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="syncDetailLoading" class="panel mt-4 text-sm text-text-tertiary">
|
||||||
|
正在读取同步详情...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="selectedSyncJob" class="mt-4 space-y-4">
|
||||||
|
<div class="grid gap-3 md:grid-cols-4">
|
||||||
|
<div class="panel">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行状态</p>
|
||||||
|
<p class="mt-2 text-base font-semibold text-text-primary">
|
||||||
|
{{ getJobStatusLabel(selectedSyncJob.status) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行方式</p>
|
||||||
|
<p class="mt-2 text-base font-semibold text-text-primary">
|
||||||
|
{{ getTriggerModeLabel(selectedSyncJob.trigger_mode) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行人</p>
|
||||||
|
<p class="mt-2 text-base font-semibold text-text-primary">
|
||||||
|
{{ selectedSyncJob.operator || 'system-sync' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-text-tertiary">变化数量</p>
|
||||||
|
<p class="mt-2 text-base font-semibold text-text-primary">
|
||||||
|
{{ selectedSyncJob.changes_count }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-sm font-medium text-text-primary">同步摘要</p>
|
||||||
|
<span class="text-xs text-text-tertiary">{{ formatDateTime(selectedSyncJob.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 whitespace-pre-line text-sm leading-6 text-text-secondary">
|
||||||
|
{{ selectedSyncJob.summary || '本次同步没有额外摘要。' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedSyncJob.error_message" class="mt-3 text-sm text-status-error">
|
||||||
|
错误信息:{{ selectedSyncJob.error_message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="summaryPairs(selectedSyncJob).length > 0" class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="pair in summaryPairs(selectedSyncJob)"
|
||||||
|
:key="`detail-${pair.label}`"
|
||||||
|
class="badge badge-neutral"
|
||||||
|
>
|
||||||
|
{{ pair.label }} {{ pair.count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
|
<div class="order-2 xl:order-1">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-sm font-medium text-text-primary">完整变更</p>
|
||||||
|
<span class="badge badge-neutral">{{ selectedSyncJob.changes.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedSyncJob.changes.length > 0" class="mt-4 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="change in selectedSyncJob.changes"
|
||||||
|
:key="change.id"
|
||||||
|
class="rounded-lg border border-border bg-bg-secondary px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-text-primary">{{ getChangeDisplayTitle(change) }}</p>
|
||||||
|
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-text-secondary">
|
||||||
|
{{ change.detail || '无详细说明' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="getChangeAssets(change).length > 0"
|
||||||
|
class="mt-4 rounded-xl border border-border bg-bg-primary/40 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-text-tertiary">待处理资产</p>
|
||||||
|
<span class="text-xs text-text-tertiary">点资产即可在当前弹窗内直接入库</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="asset in getChangeAssets(change)"
|
||||||
|
:key="`${change.id}-${buildQuickReturnAssetKey(asset)}`"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex min-h-[44px] items-center gap-2 rounded-full border px-3 py-2 text-left transition"
|
||||||
|
:class="isQuickReturnAssetProcessed(asset)
|
||||||
|
? 'cursor-not-allowed border-status-success/30 bg-status-success/10 text-status-success'
|
||||||
|
: 'border-border bg-bg-elevated hover:border-accent-primary/40 hover:bg-bg-hover'"
|
||||||
|
:disabled="isQuickReturnAssetProcessed(asset)"
|
||||||
|
@click="openQuickReturn(asset)"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-semibold" :class="isQuickReturnAssetProcessed(asset) ? 'text-status-success' : 'text-text-primary'">
|
||||||
|
{{ asset.equipmentCode }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs" :class="isQuickReturnAssetProcessed(asset) ? 'text-status-success/90' : 'text-text-secondary'">
|
||||||
|
{{ asset.equipmentName }}
|
||||||
|
</span>
|
||||||
|
<span class="badge" :class="isQuickReturnAssetProcessed(asset) ? 'badge-success' : 'badge-neutral'">
|
||||||
|
{{ isQuickReturnAssetProcessed(asset) ? '已入库' : '快捷入库' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-neutral">
|
||||||
|
{{ CHANGE_TYPE_LABELS[change.change_type] || change.change_type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-text-tertiary">
|
||||||
|
<span class="badge" :class="getChangeProcessingBadgeClass(change.processing_status)">
|
||||||
|
{{ getChangeProcessingLabel(change.processing_status) }}
|
||||||
|
</span>
|
||||||
|
<span>处理人:{{ change.processed_by_name || change.processed_by || '未处理' }}</span>
|
||||||
|
<span>处理时间:{{ change.processed_at ? formatDateTime(change.processed_at) : '未处理' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state mt-4">
|
||||||
|
本次同步没有变化明细。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="order-1 xl:order-2">
|
||||||
|
<div class="panel space-y-4 xl:sticky xl:top-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-text-primary">快捷入库</p>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-text-tertiary">
|
||||||
|
在当前消息里直接处理,不会跳走,处理完可以继续点下一件。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="quickReturnAsset"
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
@click="closeQuickReturnPanel"
|
||||||
|
>
|
||||||
|
收起
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="quickReturnNotice"
|
||||||
|
class="rounded-lg border px-4 py-3 text-sm"
|
||||||
|
:class="getQuickReturnNoticeClass(quickReturnNotice.type)"
|
||||||
|
>
|
||||||
|
{{ quickReturnNotice.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="quickReturnLoading" class="rounded-lg border border-border bg-bg-primary/40 px-4 py-6 text-sm text-text-tertiary">
|
||||||
|
正在读取这件资产的借用记录...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="quickReturnBorrowInfo && quickReturnAsset" class="space-y-4">
|
||||||
|
<div class="rounded-lg border border-border bg-bg-primary/40 p-4">
|
||||||
|
<p class="text-xs uppercase tracking-wider text-text-tertiary">当前资产</p>
|
||||||
|
<p class="mt-2 text-base font-semibold text-text-primary">{{ quickReturnBorrowInfo.equipment_name }}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-text-secondary">
|
||||||
|
<span class="badge badge-neutral">{{ quickReturnBorrowInfo.equipment_code }}</span>
|
||||||
|
<span v-if="quickReturnBorrowInfo.storageLocation?.name">地址:{{ quickReturnBorrowInfo.storageLocation.name }}</span>
|
||||||
|
<span>未归还记录 {{ quickReturnBorrowInfo.borrow_records.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-stack">
|
||||||
|
<label class="field-label">归还使用者</label>
|
||||||
|
<input
|
||||||
|
v-model.trim="quickReturnRecordKeyword"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
|
||||||
|
@keyup.enter="handleQuickReturnRecordKeywordSubmit"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-text-tertiary">
|
||||||
|
{{ quickReturnBorrowInfo.borrow_records.length > 1
|
||||||
|
? '这件资产有多条未归还记录,请点选正确使用者。'
|
||||||
|
: '当前只有 1 条未归还记录,系统已自动帮你选中。' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="showQuickReturnRecordResults" class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="record in quickReturnFilteredRecords"
|
||||||
|
:key="record.id"
|
||||||
|
type="button"
|
||||||
|
class="touch-option w-full text-left"
|
||||||
|
:class="{ 'is-active': quickReturnSelectedBorrowRecordId === record.id }"
|
||||||
|
@click="selectQuickReturnBorrowRecord(record)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-text-primary">
|
||||||
|
{{ record.person_nickname }} · {{ record.person_name }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
{{ record.person?.department || '未填写部门' }}
|
||||||
|
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
|
||||||
|
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="quickReturnSelectedBorrowRecordId === record.id" class="badge badge-success">已选中</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="quickReturnFilteredRecords.length === 0" class="py-3 text-center text-sm text-text-tertiary">
|
||||||
|
没有匹配到使用者,请换个关键词再试。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-stack">
|
||||||
|
<label class="field-label">归还状态</label>
|
||||||
|
<select v-model="quickReturnForm.return_condition" class="select">
|
||||||
|
<option value="正常">正常</option>
|
||||||
|
<option value="损坏">损坏</option>
|
||||||
|
<option value="丢失">丢失</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-stack">
|
||||||
|
<label class="field-label">部门归属处理</label>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:class="quickReturnForm.department_action === 'keep' ? '!border-accent-primary !text-text-primary' : ''"
|
||||||
|
@click="quickReturnForm.department_action = 'keep'"
|
||||||
|
>
|
||||||
|
保留当前归属
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:class="quickReturnForm.department_action === 'clear' ? '!border-accent-primary !text-text-primary' : ''"
|
||||||
|
@click="quickReturnForm.department_action = 'clear'"
|
||||||
|
>
|
||||||
|
清空归属
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:class="quickReturnForm.department_action === 'set' ? '!border-accent-primary !text-text-primary' : ''"
|
||||||
|
@click="quickReturnForm.department_action = 'set'"
|
||||||
|
>
|
||||||
|
改为指定部门
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-if="quickReturnForm.department_action === 'set'"
|
||||||
|
v-model="quickReturnForm.department_id"
|
||||||
|
class="select"
|
||||||
|
>
|
||||||
|
<option :value="null">请选择业务部门</option>
|
||||||
|
<option
|
||||||
|
v-for="department in activeDepartments"
|
||||||
|
:key="department.id"
|
||||||
|
:value="department.id"
|
||||||
|
>
|
||||||
|
{{ department.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-text-tertiary">
|
||||||
|
有些资产归还后需要保留部门归属,有些则应清空,这里可以直接一起处理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-stack">
|
||||||
|
<label class="field-label">备注</label>
|
||||||
|
<textarea
|
||||||
|
v-model="quickReturnForm.notes"
|
||||||
|
class="input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="可选,补充这次归还情况"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary flex-1 text-sm"
|
||||||
|
:disabled="!canSubmitQuickReturn"
|
||||||
|
@click="submitQuickReturn"
|
||||||
|
>
|
||||||
|
{{ quickReturnSubmitting ? '处理中...' : '确认入库' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:disabled="quickReturnSubmitting"
|
||||||
|
@click="closeQuickReturnPanel"
|
||||||
|
>
|
||||||
|
暂不处理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-lg border border-dashed border-border bg-bg-primary/20 px-4 py-6 text-sm leading-6 text-text-tertiary">
|
||||||
|
点左侧的资产胶囊后,这里会直接出现该资产的快捷入库面板。处理完当前这件后,你可以继续点下一件,不需要离开站内消息。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="panel mt-4 text-sm text-text-tertiary">
|
||||||
|
暂时没有读取到这条消息对应的同步详情。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
import type { NotificationItem } from '@/types'
|
import type {
|
||||||
|
BorrowRecord,
|
||||||
|
Department,
|
||||||
|
HrSyncBorrowedAssetPreview,
|
||||||
|
HrSyncChangeItem,
|
||||||
|
HrSyncChangeProcessingStatus,
|
||||||
|
HrSyncJobDetail,
|
||||||
|
HrSyncPersonAfterSnapshot,
|
||||||
|
NotificationItem,
|
||||||
|
StorageLocation
|
||||||
|
} from '@/types'
|
||||||
|
import { getBorrowRecordByCode, scanReturn } from '@/api/borrow'
|
||||||
|
import { getDepartmentOptions } from '@/api/department'
|
||||||
import {
|
import {
|
||||||
getNotificationList,
|
getNotificationList,
|
||||||
markAllNotificationsAsRead,
|
markAllNotificationsAsRead,
|
||||||
markNotificationAsRead
|
markNotificationAsRead
|
||||||
} from '@/api/notifications'
|
} from '@/api/notifications'
|
||||||
|
import { getHrSyncJobDetail } from '@/api/sync'
|
||||||
|
import {
|
||||||
|
buildManualReturnRecordDisplay,
|
||||||
|
filterManualReturnRecordCandidates,
|
||||||
|
findPreferredManualReturnRecordCandidate,
|
||||||
|
isManualReturnSelectedRecordKeyword,
|
||||||
|
shouldShowManualReturnRecordResults
|
||||||
|
} from '@/views/borrow/manualReturnRecordSearch'
|
||||||
|
import { canSubmitReturnScan } from '@/views/borrow/returnScanState'
|
||||||
import { useNotificationStore } from '@/stores/notifications'
|
import { useNotificationStore } from '@/stores/notifications'
|
||||||
|
import {
|
||||||
|
buildNotificationQuickReturnAssetKey,
|
||||||
|
findNotificationQuickReturnRecord
|
||||||
|
} from './notificationQuickReturn'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const notificationStore = useNotificationStore()
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
const notifications = ref<NotificationItem[]>([])
|
const notifications = ref<NotificationItem[]>([])
|
||||||
|
const activeDepartments = ref<Department[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = 12
|
const pageSize = 12
|
||||||
const statusFilter = ref<'all' | 'unread'>('all')
|
const statusFilter = ref<'all' | 'unread'>('all')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const markAllLoading = ref(false)
|
const markAllLoading = ref(false)
|
||||||
|
const syncDetailOpen = ref(false)
|
||||||
|
const syncDetailLoading = ref(false)
|
||||||
|
const selectedSyncJob = ref<HrSyncJobDetail | null>(null)
|
||||||
|
const quickReturnAsset = ref<HrSyncBorrowedAssetPreview | null>(null)
|
||||||
|
const quickReturnLoading = ref(false)
|
||||||
|
const quickReturnSubmitting = ref(false)
|
||||||
|
const quickReturnBorrowInfo = ref<{
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
unit_price: number
|
||||||
|
equipment_value: number
|
||||||
|
storage_location_id?: number | null
|
||||||
|
storageLocation?: StorageLocation | null
|
||||||
|
borrow_records: BorrowRecord[]
|
||||||
|
can_return: boolean
|
||||||
|
message: string
|
||||||
|
} | null>(null)
|
||||||
|
const quickReturnRecordKeyword = ref('')
|
||||||
|
const quickReturnSelectedBorrowRecordId = ref<number | null>(null)
|
||||||
|
const quickReturnForm = ref({
|
||||||
|
return_condition: '正常',
|
||||||
|
department_action: 'keep' as 'keep' | 'clear' | 'set',
|
||||||
|
department_id: null as number | null,
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
const quickReturnNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
|
||||||
|
const processedQuickReturnAssetKeys = ref<string[]>([])
|
||||||
|
|
||||||
const CHANGE_TYPE_LABELS: Record<string, string> = {
|
const CHANGE_TYPE_LABELS: Record<string, string> = {
|
||||||
person_created: '新人入职',
|
person_created: '新人入职',
|
||||||
@ -204,7 +570,48 @@ const CHANGE_TYPE_LABELS: Record<string, string> = {
|
|||||||
equipment_department_unresolved: '资产部门待处理'
|
equipment_department_unresolved: '资产部门待处理'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHANGE_PROCESSING_LABELS: Record<HrSyncChangeProcessingStatus, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
processed: '已处理',
|
||||||
|
ignored: '已忽略'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_PROCESSING_BADGES: Record<HrSyncChangeProcessingStatus, string> = {
|
||||||
|
pending: 'badge-warning',
|
||||||
|
processed: 'badge-success',
|
||||||
|
ignored: 'badge-neutral'
|
||||||
|
}
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
|
const quickReturnSelectedRecord = computed(() => {
|
||||||
|
if (!quickReturnBorrowInfo.value || quickReturnSelectedBorrowRecordId.value === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
quickReturnBorrowInfo.value.borrow_records.find(
|
||||||
|
(record) => record.id === quickReturnSelectedBorrowRecordId.value
|
||||||
|
) || null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const quickReturnFilteredRecords = computed(() =>
|
||||||
|
filterManualReturnRecordCandidates(quickReturnBorrowInfo.value?.borrow_records || [], quickReturnRecordKeyword.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const showQuickReturnRecordResults = computed(() =>
|
||||||
|
shouldShowManualReturnRecordResults(quickReturnSelectedRecord.value, quickReturnRecordKeyword.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const canSubmitQuickReturn = computed(() =>
|
||||||
|
canSubmitReturnScan({
|
||||||
|
hasBorrowRecordInfo: Boolean(quickReturnBorrowInfo.value),
|
||||||
|
selectedBorrowRecordId: quickReturnSelectedBorrowRecordId.value,
|
||||||
|
loading:
|
||||||
|
quickReturnSubmitting.value ||
|
||||||
|
(quickReturnForm.value.department_action === 'set' && !quickReturnForm.value.department_id)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const getLevelLabel = (level?: string) => {
|
const getLevelLabel = (level?: string) => {
|
||||||
if (level === 'error') return '异常'
|
if (level === 'error') return '异常'
|
||||||
@ -218,8 +625,122 @@ const getLevelBadgeClass = (level?: string) => {
|
|||||||
return 'badge-neutral'
|
return 'badge-neutral'
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryPairs = (item: NotificationItem) =>
|
const normalizeChangeProcessingStatus = (
|
||||||
Object.entries(item.metadata?.counts || {})
|
status?: HrSyncChangeProcessingStatus | string | null
|
||||||
|
): HrSyncChangeProcessingStatus => {
|
||||||
|
if (status === 'processed' || status === 'ignored') {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJobStatusLabel = (status?: string) => (status === 'failed' ? '失败' : '成功')
|
||||||
|
|
||||||
|
const getTriggerModeLabel = (mode?: string) => (mode === 'scheduler' ? '定时执行' : '手动执行')
|
||||||
|
|
||||||
|
const getChangeProcessingLabel = (status?: HrSyncChangeProcessingStatus | string | null) =>
|
||||||
|
CHANGE_PROCESSING_LABELS[normalizeChangeProcessingStatus(status)]
|
||||||
|
|
||||||
|
const getChangeProcessingBadgeClass = (status?: HrSyncChangeProcessingStatus | string | null) =>
|
||||||
|
CHANGE_PROCESSING_BADGES[normalizeChangeProcessingStatus(status)]
|
||||||
|
|
||||||
|
const getQuickReturnNoticeClass = (type: 'success' | 'warning' | 'error') => {
|
||||||
|
if (type === 'success') {
|
||||||
|
return 'border-status-success/30 bg-status-success/10 text-status-success'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'warning') {
|
||||||
|
return 'border-status-warning/30 bg-status-warning/10 text-status-warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'border-status-error/30 bg-status-error/10 text-status-error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildQuickReturnAssetKey = (asset: HrSyncBorrowedAssetPreview) =>
|
||||||
|
buildNotificationQuickReturnAssetKey(asset)
|
||||||
|
|
||||||
|
const isQuickReturnAssetProcessed = (asset: HrSyncBorrowedAssetPreview) =>
|
||||||
|
processedQuickReturnAssetKeys.value.includes(buildQuickReturnAssetKey(asset))
|
||||||
|
|
||||||
|
const getChangeDisplayTitle = (change: HrSyncChangeItem) => {
|
||||||
|
const personChangeTitleMap: Record<string, string> = {
|
||||||
|
person_created: '新人入职',
|
||||||
|
person_deactivated: '人员离职/停用',
|
||||||
|
person_reactivated: '人员重新启用',
|
||||||
|
person_main_department_changed: '主部门变动',
|
||||||
|
person_departments_changed: '关联部门变动',
|
||||||
|
person_position_changed: '岗位变动'
|
||||||
|
}
|
||||||
|
|
||||||
|
const titlePrefix = personChangeTitleMap[change.change_type]
|
||||||
|
if (!titlePrefix) {
|
||||||
|
return change.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterSnapshot = change.after_snapshot as HrSyncPersonAfterSnapshot | null
|
||||||
|
const fallbackSnapshot = (change.before_snapshot || {}) as HrSyncPersonAfterSnapshot
|
||||||
|
const displayName =
|
||||||
|
afterSnapshot?.displayName ||
|
||||||
|
afterSnapshot?.nickname ||
|
||||||
|
afterSnapshot?.realName ||
|
||||||
|
fallbackSnapshot?.displayName ||
|
||||||
|
fallbackSnapshot?.nickname ||
|
||||||
|
fallbackSnapshot?.realName
|
||||||
|
|
||||||
|
return displayName ? `${titlePrefix}:${displayName}` : change.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractDetailAssets = (detail?: string | null): HrSyncBorrowedAssetPreview[] => {
|
||||||
|
if (!detail) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = detail.match(/名下(?:待处理)?资产:([^。]+)/)
|
||||||
|
if (!matched?.[1]) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched[1]
|
||||||
|
.split('、')
|
||||||
|
.map((rawItem) => {
|
||||||
|
const normalizedItem = rawItem.replace(/\s*等\s*\d+\s*项$/, '').trim()
|
||||||
|
const separatorIndex = normalizedItem.indexOf('/')
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipmentCode = normalizedItem.slice(0, separatorIndex).trim()
|
||||||
|
const equipmentName = normalizedItem.slice(separatorIndex + 1).trim()
|
||||||
|
if (!equipmentCode || !equipmentName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
equipmentCode,
|
||||||
|
equipmentName
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((asset): asset is HrSyncBorrowedAssetPreview => Boolean(asset))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChangeAssets = (change: HrSyncChangeItem): HrSyncBorrowedAssetPreview[] => {
|
||||||
|
const afterSnapshot = change.after_snapshot as HrSyncPersonAfterSnapshot | null
|
||||||
|
if (afterSnapshot && Array.isArray(afterSnapshot.assets) && afterSnapshot.assets.length > 0) {
|
||||||
|
return afterSnapshot.assets.filter(
|
||||||
|
(asset) => Boolean(asset?.equipmentCode) && Boolean(asset?.equipmentName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractDetailAssets(change.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryPairs = (item: {
|
||||||
|
counts?: Record<string, number>
|
||||||
|
metadata?: {
|
||||||
|
counts?: Record<string, number>
|
||||||
|
} | null
|
||||||
|
}) =>
|
||||||
|
Object.entries(item.counts || item.metadata?.counts || {})
|
||||||
.filter(([, count]) => Number(count) > 0)
|
.filter(([, count]) => Number(count) > 0)
|
||||||
.map(([key, count]) => ({
|
.map(([key, count]) => ({
|
||||||
label: CHANGE_TYPE_LABELS[key] || key,
|
label: CHANGE_TYPE_LABELS[key] || key,
|
||||||
@ -279,23 +800,228 @@ const handleMarkAllRead = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadDepartmentOptions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getDepartmentOptions()
|
||||||
|
activeDepartments.value = (response.data || [])
|
||||||
|
.filter((department) => department.status === 'active')
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取业务部门失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSyncDetail = () => {
|
||||||
|
syncDetailOpen.value = false
|
||||||
|
selectedSyncJob.value = null
|
||||||
|
quickReturnNotice.value = null
|
||||||
|
processedQuickReturnAssetKeys.value = []
|
||||||
|
closeQuickReturnPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetQuickReturnForm = () => {
|
||||||
|
quickReturnRecordKeyword.value = ''
|
||||||
|
quickReturnSelectedBorrowRecordId.value = null
|
||||||
|
quickReturnForm.value = {
|
||||||
|
return_condition: '正常',
|
||||||
|
department_action: 'keep',
|
||||||
|
department_id: null,
|
||||||
|
notes: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeQuickReturnPanel = () => {
|
||||||
|
quickReturnAsset.value = null
|
||||||
|
quickReturnBorrowInfo.value = null
|
||||||
|
quickReturnLoading.value = false
|
||||||
|
quickReturnSubmitting.value = false
|
||||||
|
resetQuickReturnForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectQuickReturnBorrowRecord = (record: BorrowRecord) => {
|
||||||
|
quickReturnSelectedBorrowRecordId.value = record.id
|
||||||
|
quickReturnRecordKeyword.value = buildManualReturnRecordDisplay(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markQuickReturnAssetProcessed = (asset: HrSyncBorrowedAssetPreview) => {
|
||||||
|
const assetKey = buildQuickReturnAssetKey(asset)
|
||||||
|
if (!processedQuickReturnAssetKeys.value.includes(assetKey)) {
|
||||||
|
processedQuickReturnAssetKeys.value = [...processedQuickReturnAssetKeys.value, assetKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuickReturnRecordKeywordSubmit = () => {
|
||||||
|
const preferredRecord = findPreferredManualReturnRecordCandidate(
|
||||||
|
quickReturnFilteredRecords.value,
|
||||||
|
quickReturnRecordKeyword.value
|
||||||
|
)
|
||||||
|
|
||||||
|
if (preferredRecord) {
|
||||||
|
selectQuickReturnBorrowRecord(preferredRecord)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quickReturnFilteredRecords.value.length > 1) {
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '匹配到多条借用记录,请先点选正确的使用者。'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quickReturnRecordKeyword.value.trim()) {
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '没有匹配到使用者,请换个关键词再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openQuickReturn = async (asset: HrSyncBorrowedAssetPreview) => {
|
||||||
|
if (isQuickReturnAssetProcessed(asset)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quickReturnAsset.value = asset
|
||||||
|
quickReturnBorrowInfo.value = null
|
||||||
|
quickReturnNotice.value = null
|
||||||
|
resetQuickReturnForm()
|
||||||
|
quickReturnLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getBorrowRecordByCode(asset.equipmentCode)
|
||||||
|
const borrowInfo = response.data || null
|
||||||
|
quickReturnBorrowInfo.value = borrowInfo
|
||||||
|
|
||||||
|
if (!borrowInfo) {
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'error',
|
||||||
|
message: '没有读取到这件资产的借用记录。'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredRecord = findNotificationQuickReturnRecord(borrowInfo.borrow_records, asset)
|
||||||
|
if (preferredRecord) {
|
||||||
|
selectQuickReturnBorrowRecord(preferredRecord)
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'success',
|
||||||
|
message: `已定位到 ${borrowInfo.equipment_name} 的归还记录,可以直接确认入库。`
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: `已读取 ${borrowInfo.equipment_name},请先选择正确的归还使用者。`
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
quickReturnBorrowInfo.value = null
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'error',
|
||||||
|
message: error?.message || '读取借用记录失败,请稍后再试。'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
quickReturnLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitQuickReturn = async () => {
|
||||||
|
if (!quickReturnBorrowInfo.value || !quickReturnAsset.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = quickReturnSelectedRecord.value
|
||||||
|
if (!record) {
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '请先选中正确的归还使用者。'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quickReturnForm.value.department_action === 'set' && !quickReturnForm.value.department_id) {
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'warning',
|
||||||
|
message: '请选择归还后要归属到的业务部门。'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
equipment_code: quickReturnBorrowInfo.value.equipment_code,
|
||||||
|
return_condition: quickReturnForm.value.return_condition,
|
||||||
|
notes: quickReturnForm.value.notes || undefined,
|
||||||
|
person_id: record.person_id,
|
||||||
|
borrow_record_id: record.id,
|
||||||
|
department_action: quickReturnForm.value.department_action,
|
||||||
|
department_id:
|
||||||
|
quickReturnForm.value.department_action === 'set'
|
||||||
|
? quickReturnForm.value.department_id
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
quickReturnSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const response = await scanReturn(payload)
|
||||||
|
if (response.data?.need_confirm) {
|
||||||
|
const confirmed = window.confirm('系统检测到配件与借出时不一致,确认继续入库吗?')
|
||||||
|
if (!confirmed) {
|
||||||
|
quickReturnSubmitting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await scanReturn({
|
||||||
|
...payload,
|
||||||
|
confirm_children_diff: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
markQuickReturnAssetProcessed(quickReturnAsset.value)
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'success',
|
||||||
|
message: `${quickReturnBorrowInfo.value.equipment_name} 已为 ${record.person_nickname} 完成入库。你现在可以继续处理下一件。`
|
||||||
|
}
|
||||||
|
closeQuickReturnPanel()
|
||||||
|
} catch (error: any) {
|
||||||
|
quickReturnNotice.value = {
|
||||||
|
type: 'error',
|
||||||
|
message: error?.message || '快捷入库失败,请稍后再试。'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
quickReturnSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSyncDetail = async (syncJobId: number) => {
|
||||||
|
syncDetailOpen.value = true
|
||||||
|
syncDetailLoading.value = true
|
||||||
|
quickReturnNotice.value = null
|
||||||
|
processedQuickReturnAssetKeys.value = []
|
||||||
|
closeQuickReturnPanel()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getHrSyncJobDetail(syncJobId)
|
||||||
|
selectedSyncJob.value = response.data || null
|
||||||
|
} catch (error: any) {
|
||||||
|
selectedSyncJob.value = null
|
||||||
|
alert(error?.message || '读取同步详情失败')
|
||||||
|
} finally {
|
||||||
|
syncDetailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openAction = async (item: NotificationItem) => {
|
const openAction = async (item: NotificationItem) => {
|
||||||
if (!item.is_read) {
|
if (!item.is_read) {
|
||||||
await handleMarkRead(item)
|
await handleMarkRead(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.related_sync_job_id) {
|
if (item.related_sync_job_id) {
|
||||||
await router.push({
|
await openSyncDetail(item.related_sync_job_id)
|
||||||
path: '/settings',
|
|
||||||
query: {
|
|
||||||
tab: 'sync',
|
|
||||||
syncJobId: String(item.related_sync_job_id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.push(item.action_url || '/settings?tab=sync')
|
window.location.href = item.action_url || '/settings?tab=sync'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
const handlePrevPage = () => {
|
||||||
@ -310,7 +1036,31 @@ const handleNextPage = () => {
|
|||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => quickReturnRecordKeyword.value,
|
||||||
|
(value) => {
|
||||||
|
const matchedSelectedRecord = quickReturnSelectedRecord.value
|
||||||
|
const shouldKeepCurrentSelection =
|
||||||
|
matchedSelectedRecord !== null &&
|
||||||
|
isManualReturnSelectedRecordKeyword(matchedSelectedRecord, value)
|
||||||
|
|
||||||
|
if (quickReturnSelectedBorrowRecordId.value !== null && !shouldKeepCurrentSelection) {
|
||||||
|
quickReturnSelectedBorrowRecordId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => quickReturnForm.value.department_action,
|
||||||
|
(value) => {
|
||||||
|
if (value !== 'set') {
|
||||||
|
quickReturnForm.value.department_id = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadDepartmentOptions()
|
||||||
await notificationStore.fetchUnreadCount()
|
await notificationStore.fetchUnreadCount()
|
||||||
await fetchNotifications()
|
await fetchNotifications()
|
||||||
})
|
})
|
||||||
|
|||||||
22
frontend/src/views/notifications/notificationQuickReturn.ts
Normal file
22
frontend/src/views/notifications/notificationQuickReturn.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { BorrowRecord, HrSyncBorrowedAssetPreview } from '../../types'
|
||||||
|
|
||||||
|
export const buildNotificationQuickReturnAssetKey = (
|
||||||
|
asset: Pick<HrSyncBorrowedAssetPreview, 'borrowRecordId' | 'equipmentCode'>
|
||||||
|
) => {
|
||||||
|
if (asset.borrowRecordId) {
|
||||||
|
return `borrow-record:${asset.borrowRecordId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `equipment:${(asset.equipmentCode || '').trim().toUpperCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findNotificationQuickReturnRecord = (
|
||||||
|
borrowRecords: BorrowRecord[],
|
||||||
|
asset: Pick<HrSyncBorrowedAssetPreview, 'borrowRecordId'>
|
||||||
|
) => {
|
||||||
|
if (asset.borrowRecordId) {
|
||||||
|
return borrowRecords.find((record) => record.id === asset.borrowRecordId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return borrowRecords.length === 1 ? borrowRecords[0] : null
|
||||||
|
}
|
||||||
@ -563,11 +563,12 @@ const statusBadgeClass = computed(() => {
|
|||||||
|
|
||||||
const scheduleSummary = computed(() => {
|
const scheduleSummary = computed(() => {
|
||||||
const scheduleTimes = syncStatus.value?.scheduleTimes || []
|
const scheduleTimes = syncStatus.value?.scheduleTimes || []
|
||||||
|
const scheduleTimezone = syncStatus.value?.scheduleTimezone || 'Asia/Shanghai'
|
||||||
if (!syncStatus.value?.enabled) {
|
if (!syncStatus.value?.enabled) {
|
||||||
return '建议先确认源库连通后,再决定是否开启自动同步。'
|
return '建议先确认源库连通后,再决定是否开启自动同步。'
|
||||||
}
|
}
|
||||||
return scheduleTimes.length > 0
|
return scheduleTimes.length > 0
|
||||||
? `计划时间:${scheduleTimes.join('、')}`
|
? `计划时间(${scheduleTimezone}):${scheduleTimes.join('、')}`
|
||||||
: '已启用定时计划,但还没有读取到具体时间。'
|
: '已启用定时计划,但还没有读取到具体时间。'
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -729,7 +730,8 @@ const fetchStatus = async () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
complete: false,
|
complete: false,
|
||||||
missingFields: [],
|
missingFields: [],
|
||||||
scheduleTimes: []
|
scheduleTimes: [],
|
||||||
|
scheduleTimezone: 'Asia/Shanghai'
|
||||||
}
|
}
|
||||||
previewErrorMessage.value = resolveErrorMessage(error, '读取同步状态失败')
|
previewErrorMessage.value = resolveErrorMessage(error, '读取同步状态失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -150,7 +150,7 @@
|
|||||||
:disabled="busyTerminalId === terminal.id"
|
:disabled="busyTerminalId === terminal.id"
|
||||||
@click="handleToggleStatus(terminal)"
|
@click="handleToggleStatus(terminal)"
|
||||||
>
|
>
|
||||||
{{ busyTerminalId === terminal.id ? '处理中...' : terminal.status === 'disabled' ? '重新启用' : '停用' }}
|
{{ busyTerminalId === terminal.id ? '处理中...' : getToggleStatusText(terminal) }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!terminal.is_default"
|
v-if="!terminal.is_default"
|
||||||
@ -483,6 +483,14 @@ const getDispatchNotice = () => {
|
|||||||
return '默认终端离线时,系统只排队不改派,等它重新上线后继续自动打印。'
|
return '默认终端离线时,系统只排队不改派,等它重新上线后继续自动打印。'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getToggleStatusText = (terminal: PrintTerminal) => {
|
||||||
|
if (terminal.status === 'disabled') {
|
||||||
|
return '重新启用'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '停用'
|
||||||
|
}
|
||||||
|
|
||||||
const loadTerminals = async () => {
|
const loadTerminals = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@ -570,7 +578,9 @@ const handleToggleStatus = async (terminal: PrintTerminal) => {
|
|||||||
const nextStatus = terminal.status === 'disabled' ? 'offline' : 'disabled'
|
const nextStatus = terminal.status === 'disabled' ? 'offline' : 'disabled'
|
||||||
const confirmText =
|
const confirmText =
|
||||||
nextStatus === 'disabled'
|
nextStatus === 'disabled'
|
||||||
? '停用后新的远程打印任务不会再发给这台电脑,确定继续吗?'
|
? terminal.is_default
|
||||||
|
? '这台电脑当前是默认打印终端。停用后远程标签会暂停自动派发,直到你重新启用它或改设其他默认电脑。确定继续吗?'
|
||||||
|
: '停用后新的远程打印任务不会再发给这台电脑,确定继续吗?'
|
||||||
: '重新启用后,这台电脑会在助手下一次心跳后恢复接单状态,确定继续吗?'
|
: '重新启用后,这台电脑会在助手下一次心跳后恢复接单状态,确定继续吗?'
|
||||||
|
|
||||||
if (!window.confirm(confirmText)) {
|
if (!window.confirm(confirmText)) {
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Version>0.2.0</Version>
|
<Version>0.2.5</Version>
|
||||||
<AssemblyVersion>0.2.0.0</AssemblyVersion>
|
<AssemblyVersion>0.2.5.0</AssemblyVersion>
|
||||||
<FileVersion>0.2.0.0</FileVersion>
|
<FileVersion>0.2.5.0</FileVersion>
|
||||||
<Company>常州简尚家具有限公司</Company>
|
<Company>常州简尚家具有限公司</Company>
|
||||||
<Product>固定资产打印助手</Product>
|
<Product>固定资产打印助手</Product>
|
||||||
<DebugType>none</DebugType>
|
<DebugType>none</DebugType>
|
||||||
|
|||||||
@ -59,6 +59,28 @@ internal sealed class LabelPrintService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> PrintRenderedLabelImagesAsync(
|
||||||
|
string printerName,
|
||||||
|
IReadOnlyList<AssetLabelRenderedImage> renderedLabels)
|
||||||
|
{
|
||||||
|
return Task.Run(() =>
|
||||||
|
{
|
||||||
|
var pages = renderedLabels.Select(RenderRenderedLabelImage).ToList();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PrintBitmaps(printerName, pages, "固定资产标签打印");
|
||||||
|
return pages.Count;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var page in pages)
|
||||||
|
{
|
||||||
|
page.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public Task PrintTestLabelAsync(string printerName)
|
public Task PrintTestLabelAsync(string printerName)
|
||||||
{
|
{
|
||||||
return Task.Run(() =>
|
return Task.Run(() =>
|
||||||
@ -79,6 +101,30 @@ internal sealed class LabelPrintService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Bitmap RenderRenderedLabelImage(AssetLabelRenderedImage renderedLabel)
|
||||||
|
{
|
||||||
|
const string dataUrlPrefix = "data:image/png;base64,";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(renderedLabel.ImageDataUrl) ||
|
||||||
|
!renderedLabel.ImageDataUrl.StartsWith(dataUrlPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("当前打印任务缺少有效的 PNG 成品标签图。");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var base64 = renderedLabel.ImageDataUrl[dataUrlPrefix.Length..];
|
||||||
|
var imageBytes = Convert.FromBase64String(base64);
|
||||||
|
using var stream = new MemoryStream(imageBytes);
|
||||||
|
using var image = Image.FromStream(stream, true, true);
|
||||||
|
return new Bitmap(image);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"成品标签图解析失败:{ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Bitmap RenderAssetLabel(AssetLabelItem item)
|
private static Bitmap RenderAssetLabel(AssetLabelItem item)
|
||||||
{
|
{
|
||||||
var bitmap = new Bitmap(CanvasWidth, CanvasHeight);
|
var bitmap = new Bitmap(CanvasWidth, CanvasHeight);
|
||||||
@ -303,7 +349,13 @@ internal sealed class LabelPrintService
|
|||||||
|
|
||||||
graphics.PageUnit = GraphicsUnit.Display;
|
graphics.PageUnit = GraphicsUnit.Display;
|
||||||
graphics.Clear(Color.White);
|
graphics.Clear(Color.White);
|
||||||
graphics.DrawImage(page, eventArgs.PageBounds);
|
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||||
|
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||||
|
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
||||||
|
graphics.SmoothingMode = SmoothingMode.HighQuality;
|
||||||
|
|
||||||
|
var destinationRect = CalculateContainRectangle(eventArgs.PageBounds, page.Size);
|
||||||
|
graphics.DrawImage(page, destinationRect);
|
||||||
pageIndex += 1;
|
pageIndex += 1;
|
||||||
eventArgs.HasMorePages = pageIndex < pages.Count;
|
eventArgs.HasMorePages = pageIndex < pages.Count;
|
||||||
};
|
};
|
||||||
@ -311,6 +363,25 @@ internal sealed class LabelPrintService
|
|||||||
document.Print();
|
document.Print();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Rectangle CalculateContainRectangle(Rectangle bounds, Size imageSize)
|
||||||
|
{
|
||||||
|
if (imageSize.Width <= 0 || imageSize.Height <= 0)
|
||||||
|
{
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
var widthScale = bounds.Width / (double)imageSize.Width;
|
||||||
|
var heightScale = bounds.Height / (double)imageSize.Height;
|
||||||
|
var scale = Math.Min(widthScale, heightScale);
|
||||||
|
|
||||||
|
var drawWidth = Math.Max(1, (int)Math.Round(imageSize.Width * scale));
|
||||||
|
var drawHeight = Math.Max(1, (int)Math.Round(imageSize.Height * scale));
|
||||||
|
var offsetX = bounds.X + (bounds.Width - drawWidth) / 2;
|
||||||
|
var offsetY = bounds.Y + (bounds.Height - drawHeight) / 2;
|
||||||
|
|
||||||
|
return new Rectangle(offsetX, offsetY, drawWidth, drawHeight);
|
||||||
|
}
|
||||||
|
|
||||||
private static string NormalizeText(string? value, string fallback)
|
private static string NormalizeText(string? value, string fallback)
|
||||||
{
|
{
|
||||||
var normalized = string.IsNullOrWhiteSpace(value)
|
var normalized = string.IsNullOrWhiteSpace(value)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -102,10 +102,22 @@ internal sealed class AssetLabelItem
|
|||||||
public DepartmentInfo? Department { get; set; }
|
public DepartmentInfo? Department { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class AssetLabelRenderedImage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("equipment_id")]
|
||||||
|
public int EquipmentId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("image_data_url")]
|
||||||
|
public string ImageDataUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class AssetLabelPayload
|
internal sealed class AssetLabelPayload
|
||||||
{
|
{
|
||||||
[JsonPropertyName("items")]
|
[JsonPropertyName("items")]
|
||||||
public List<AssetLabelItem> Items { get; set; } = [];
|
public List<AssetLabelItem> Items { get; set; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("rendered_labels")]
|
||||||
|
public List<AssetLabelRenderedImage> RenderedLabels { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class PrintJobDto
|
internal sealed class PrintJobDto
|
||||||
|
|||||||
@ -324,7 +324,7 @@ internal sealed class PrintAgentRuntime : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var payload = ResolvePayload(job);
|
var payload = ResolvePayload(job);
|
||||||
if (payload?.Items is null || payload.Items.Count == 0)
|
if (payload is null)
|
||||||
{
|
{
|
||||||
const string message = "当前打印任务缺少可用的资产标签数据,已阻止实际出纸。";
|
const string message = "当前打印任务缺少可用的资产标签数据,已阻止实际出纸。";
|
||||||
await _apiClient.ReportJobStatusAsync(
|
await _apiClient.ReportJobStatusAsync(
|
||||||
@ -342,11 +342,29 @@ internal sealed class PrintAgentRuntime : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.RenderedLabels is null || payload.RenderedLabels.Count == 0)
|
||||||
|
{
|
||||||
|
const string message = "当前任务缺少网页生成的成品标签图,请在系统里重新发送打印。";
|
||||||
|
await _apiClient.ReportJobStatusAsync(
|
||||||
|
CurrentState.Configuration.ServerUrl,
|
||||||
|
CurrentState.Configuration.DeviceToken,
|
||||||
|
job.Id,
|
||||||
|
"failed",
|
||||||
|
message,
|
||||||
|
false);
|
||||||
|
|
||||||
|
CurrentState.LastError = message;
|
||||||
|
CurrentState.LastJobResult = message;
|
||||||
|
AppendLog(message);
|
||||||
|
EmitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var printedCount = await _labelPrintService.PrintAssetLabelsAsync(
|
var printedCount = await _labelPrintService.PrintRenderedLabelImagesAsync(
|
||||||
CurrentState.Configuration.SelectedPrinterName,
|
CurrentState.Configuration.SelectedPrinterName,
|
||||||
payload.Items);
|
payload.RenderedLabels);
|
||||||
|
|
||||||
await _apiClient.ReportJobStatusAsync(
|
await _apiClient.ReportJobStatusAsync(
|
||||||
CurrentState.Configuration.ServerUrl,
|
CurrentState.Configuration.ServerUrl,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Version = '0.2.0',
|
[string]$Version = '0.2.5',
|
||||||
[string]$PublishedAt = '',
|
[string]$PublishedAt = '',
|
||||||
[string]$DotnetPath = '',
|
[string]$DotnetPath = '',
|
||||||
[string]$IsccPath = ''
|
[string]$IsccPath = ''
|
||||||
@ -39,11 +39,19 @@ $publishDir = Join-Path $scriptRoot 'dist\publish\win-x64'
|
|||||||
$installerDir = Join-Path $scriptRoot 'dist\installer'
|
$installerDir = Join-Path $scriptRoot 'dist\installer'
|
||||||
$releaseDir = Join-Path $repoRoot "downloads\print-agent\releases\$Version"
|
$releaseDir = Join-Path $repoRoot "downloads\print-agent\releases\$Version"
|
||||||
$latestDir = Join-Path $repoRoot 'downloads\print-agent\latest'
|
$latestDir = Join-Path $repoRoot 'downloads\print-agent\latest'
|
||||||
|
$projectFile = Join-Path $scriptRoot 'FixedAssetPrintAgent\FixedAssetPrintAgent.csproj'
|
||||||
|
$installerScript = Join-Path $scriptRoot 'installer\FixedAssetPrintAgent.iss'
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path $publishDir, $installerDir, $releaseDir, $latestDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $publishDir, $installerDir, $releaseDir, $latestDir | Out-Null
|
||||||
|
Remove-Item -Path (Join-Path $publishDir '*') -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path (Join-Path $installerDir '*') -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
& $DotnetPath restore `
|
||||||
|
$projectFile `
|
||||||
|
-r win-x64
|
||||||
|
|
||||||
& $DotnetPath publish `
|
& $DotnetPath publish `
|
||||||
(Join-Path $scriptRoot 'FixedAssetPrintAgent\FixedAssetPrintAgent.csproj') `
|
$projectFile `
|
||||||
-c Release `
|
-c Release `
|
||||||
-r win-x64 `
|
-r win-x64 `
|
||||||
--self-contained true `
|
--self-contained true `
|
||||||
@ -51,7 +59,7 @@ New-Item -ItemType Directory -Force -Path $publishDir, $installerDir, $releaseDi
|
|||||||
-p:EnableCompressionInSingleFile=true `
|
-p:EnableCompressionInSingleFile=true `
|
||||||
-o $publishDir
|
-o $publishDir
|
||||||
|
|
||||||
& $IsccPath (Join-Path $scriptRoot 'installer\FixedAssetPrintAgent.iss')
|
& $IsccPath "/DMyAppVersion=$Version" $installerScript
|
||||||
|
|
||||||
$installerSource = Join-Path $installerDir "print-agent-setup-$Version.exe"
|
$installerSource = Join-Path $installerDir "print-agent-setup-$Version.exe"
|
||||||
if (-not (Test-Path $installerSource)) {
|
if (-not (Test-Path $installerSource)) {
|
||||||
@ -68,7 +76,7 @@ $releaseManifest = [ordered]@{
|
|||||||
version = $Version
|
version = $Version
|
||||||
published_at = $PublishedAt
|
published_at = $PublishedAt
|
||||||
filename = "print-agent-setup-$Version.exe"
|
filename = "print-agent-setup-$Version.exe"
|
||||||
notes = '打印助手 v0.2.0,已切换为 .NET 8 原生 Windows 托盘版,带正式安装器、控制面板卸载入口、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。'
|
notes = "打印助手 v$Version,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。"
|
||||||
source_type = 'server_release'
|
source_type = 'server_release'
|
||||||
relative_path = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe"
|
relative_path = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe"
|
||||||
relativePath = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe"
|
relativePath = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe"
|
||||||
@ -78,14 +86,15 @@ $latestManifest = [ordered]@{
|
|||||||
version = $Version
|
version = $Version
|
||||||
published_at = $PublishedAt
|
published_at = $PublishedAt
|
||||||
filename = 'print-agent-setup.exe'
|
filename = 'print-agent-setup.exe'
|
||||||
notes = '打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。'
|
notes = '打印助手最新稳定版,当前为安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。'
|
||||||
source_type = 'server_release'
|
source_type = 'server_release'
|
||||||
relative_path = 'downloads/print-agent/latest/print-agent-setup.exe'
|
relative_path = 'downloads/print-agent/latest/print-agent-setup.exe'
|
||||||
relativePath = 'downloads/print-agent/latest/print-agent-setup.exe'
|
relativePath = 'downloads/print-agent/latest/print-agent-setup.exe'
|
||||||
} | ConvertTo-Json -Depth 5
|
} | ConvertTo-Json -Depth 5
|
||||||
|
|
||||||
Set-Content -Path (Join-Path $releaseDir 'manifest.json') -Value $releaseManifest -Encoding UTF8
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
Set-Content -Path (Join-Path $latestDir 'manifest.json') -Value $latestManifest -Encoding UTF8
|
[System.IO.File]::WriteAllText((Join-Path $releaseDir 'manifest.json'), $releaseManifest, $utf8NoBom)
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path $latestDir 'manifest.json'), $latestManifest, $utf8NoBom)
|
||||||
|
|
||||||
$installerSizeMB = [Math]::Round((Get-Item $installerSource).Length / 1MB, 2)
|
$installerSizeMB = [Math]::Round((Get-Item $installerSource).Length / 1MB, 2)
|
||||||
Write-Host "安装包已生成:$installerSource ($installerSizeMB MB)"
|
Write-Host "安装包已生成:$installerSource ($installerSizeMB MB)"
|
||||||
|
|||||||
418
print-agent-dotnet/installer/ChineseSimplified.isl
Normal file
418
print-agent-dotnet/installer/ChineseSimplified.isl
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
|
||||||
|
;
|
||||||
|
; To download user-contributed translations of this file, go to:
|
||||||
|
; https://jrsoftware.org/files/istrans/
|
||||||
|
;
|
||||||
|
; Note: When translating this text, do not add periods (.) to the end of
|
||||||
|
; messages that didn't have them already, because on those messages Inno
|
||||||
|
; Setup adds the periods automatically (appending a period would result in
|
||||||
|
; two periods being displayed).
|
||||||
|
;
|
||||||
|
; Maintained by Zhenghan Yang
|
||||||
|
; Email: 847320916@QQ.com
|
||||||
|
; Translation based on network resource
|
||||||
|
; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||||
|
;
|
||||||
|
|
||||||
|
[LangOptions]
|
||||||
|
; The following three entries are very important. Be sure to read and
|
||||||
|
; understand the '[LangOptions] section' topic in the help file.
|
||||||
|
LanguageName=简体中文
|
||||||
|
; If Language Name display incorrect, uncomment next line
|
||||||
|
; LanguageName=<7B80><4F53><4E2D><6587>
|
||||||
|
; About LanguageID, to reference link:
|
||||||
|
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
|
||||||
|
LanguageID=$0804
|
||||||
|
; About CodePage, to reference link:
|
||||||
|
; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers
|
||||||
|
LanguageCodePage=936
|
||||||
|
; If the language you are translating to requires special font faces or
|
||||||
|
; sizes, uncomment any of the following entries and change them accordingly.
|
||||||
|
;DialogFontName=
|
||||||
|
;DialogFontSize=9
|
||||||
|
;DialogFontBaseScaleWidth=7
|
||||||
|
;DialogFontBaseScaleHeight=15
|
||||||
|
;WelcomeFontName=Segoe UI
|
||||||
|
;WelcomeFontSize=14
|
||||||
|
|
||||||
|
[Messages]
|
||||||
|
|
||||||
|
; *** 应用程序标题
|
||||||
|
SetupAppTitle=安装
|
||||||
|
SetupWindowTitle=安装 - %1
|
||||||
|
UninstallAppTitle=卸载
|
||||||
|
UninstallAppFullTitle=%1 卸载
|
||||||
|
|
||||||
|
; *** Misc. common
|
||||||
|
InformationTitle=信息
|
||||||
|
ConfirmTitle=确认
|
||||||
|
ErrorTitle=错误
|
||||||
|
|
||||||
|
; *** SetupLdr messages
|
||||||
|
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
|
||||||
|
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
|
||||||
|
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
|
||||||
|
HelpTextNote=
|
||||||
|
|
||||||
|
; *** 启动错误消息
|
||||||
|
LastErrorMessage=%1。%n%n错误 %2: %3
|
||||||
|
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
|
||||||
|
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
|
||||||
|
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
|
||||||
|
InvalidParameter=无效的命令行参数:%n%n%1
|
||||||
|
SetupAlreadyRunning=安装程序正在运行。
|
||||||
|
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
|
||||||
|
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
|
||||||
|
NotOnThisPlatform=此程序不能在 %1 上运行。
|
||||||
|
OnlyOnThisPlatform=此程序只能在 %1 上运行。
|
||||||
|
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
|
||||||
|
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
|
||||||
|
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
|
||||||
|
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
|
||||||
|
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
|
||||||
|
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||||
|
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||||
|
|
||||||
|
; *** 启动问题
|
||||||
|
PrivilegesRequiredOverrideTitle=选择安装程序模式
|
||||||
|
PrivilegesRequiredOverrideInstruction=选择安装模式
|
||||||
|
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
|
||||||
|
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
|
||||||
|
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
|
||||||
|
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项)
|
||||||
|
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
|
||||||
|
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项)
|
||||||
|
|
||||||
|
; *** 其他错误
|
||||||
|
ErrorCreatingDir=安装程序无法创建目录“%1”
|
||||||
|
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
|
||||||
|
|
||||||
|
; *** 安装程序公共消息
|
||||||
|
ExitSetupTitle=退出安装程序
|
||||||
|
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
|
||||||
|
AboutSetupMenuItem=关于安装程序(&A)...
|
||||||
|
AboutSetupTitle=关于安装程序
|
||||||
|
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
|
||||||
|
AboutSetupNote=
|
||||||
|
TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||||
|
|
||||||
|
; *** 按钮
|
||||||
|
ButtonBack=< 上一步(&B)
|
||||||
|
ButtonNext=下一步(&N) >
|
||||||
|
ButtonInstall=安装(&I)
|
||||||
|
ButtonOK=确定
|
||||||
|
ButtonCancel=取消
|
||||||
|
ButtonYes=是(&Y)
|
||||||
|
ButtonYesToAll=全是(&A)
|
||||||
|
ButtonNo=否(&N)
|
||||||
|
ButtonNoToAll=全否(&O)
|
||||||
|
ButtonFinish=完成(&F)
|
||||||
|
ButtonBrowse=浏览(&B)...
|
||||||
|
ButtonWizardBrowse=浏览(&R)...
|
||||||
|
ButtonNewFolder=新建文件夹(&M)
|
||||||
|
|
||||||
|
; *** “选择语言”对话框消息
|
||||||
|
SelectLanguageTitle=选择安装语言
|
||||||
|
SelectLanguageLabel=选择安装时使用的语言。
|
||||||
|
|
||||||
|
; *** 公共向导文字
|
||||||
|
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
|
||||||
|
BeveledLabel=
|
||||||
|
BrowseDialogTitle=浏览文件夹
|
||||||
|
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
|
||||||
|
NewFolderName=新建文件夹
|
||||||
|
|
||||||
|
; *** “欢迎”向导页
|
||||||
|
WelcomeLabel1=欢迎使用 [name] 安装向导
|
||||||
|
WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。
|
||||||
|
|
||||||
|
; *** “密码”向导页
|
||||||
|
WizardPassword=密码
|
||||||
|
PasswordLabel1=这个安装程序有密码保护。
|
||||||
|
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
|
||||||
|
PasswordEditLabel=密码(&P):
|
||||||
|
IncorrectPassword=您输入的密码不正确,请重新输入。
|
||||||
|
|
||||||
|
; *** “许可协议”向导页
|
||||||
|
WizardLicense=许可协议
|
||||||
|
LicenseLabel=请在继续安装前阅读以下重要信息。
|
||||||
|
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
|
||||||
|
LicenseAccepted=我同意此协议(&A)
|
||||||
|
LicenseNotAccepted=我不同意此协议(&D)
|
||||||
|
|
||||||
|
; *** “信息”向导页
|
||||||
|
WizardInfoBefore=信息
|
||||||
|
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
|
||||||
|
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
|
||||||
|
WizardInfoAfter=信息
|
||||||
|
InfoAfterLabel=请在继续安装前阅读以下重要信息。
|
||||||
|
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
|
||||||
|
|
||||||
|
; *** “用户信息”向导页
|
||||||
|
WizardUserInfo=用户信息
|
||||||
|
UserInfoDesc=请输入您的信息。
|
||||||
|
UserInfoName=用户名(&U):
|
||||||
|
UserInfoOrg=组织(&O):
|
||||||
|
UserInfoSerial=序列号(&S):
|
||||||
|
UserInfoNameRequired=您必须输入用户名。
|
||||||
|
|
||||||
|
; *** “选择目标目录”向导页
|
||||||
|
WizardSelectDir=选择目标位置
|
||||||
|
SelectDirDesc=您想将 [name] 安装在哪里?
|
||||||
|
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
|
||||||
|
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||||
|
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
|
||||||
|
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
|
||||||
|
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
|
||||||
|
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
|
||||||
|
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share
|
||||||
|
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
|
||||||
|
DiskSpaceWarningTitle=磁盘空间不足
|
||||||
|
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗?
|
||||||
|
DirNameTooLong=文件夹名称或路径太长。
|
||||||
|
InvalidDirName=文件夹名称无效。
|
||||||
|
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
|
||||||
|
DirExistsTitle=文件夹已存在
|
||||||
|
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗?
|
||||||
|
DirDoesntExistTitle=文件夹不存在
|
||||||
|
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
|
||||||
|
|
||||||
|
; *** “选择组件”向导页
|
||||||
|
WizardSelectComponents=选择组件
|
||||||
|
SelectComponentsDesc=您想安装哪些程序组件?
|
||||||
|
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
|
||||||
|
FullInstallation=完全安装
|
||||||
|
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
|
||||||
|
CompactInstallation=简洁安装
|
||||||
|
CustomInstallation=自定义安装
|
||||||
|
NoUninstallWarningTitle=组件已存在
|
||||||
|
NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗?
|
||||||
|
ComponentSize1=%1 KB
|
||||||
|
ComponentSize2=%1 MB
|
||||||
|
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
|
||||||
|
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
|
||||||
|
|
||||||
|
; *** “选择附加任务”向导页
|
||||||
|
WizardSelectTasks=选择附加任务
|
||||||
|
SelectTasksDesc=您想要安装程序执行哪些附加任务?
|
||||||
|
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
|
||||||
|
|
||||||
|
; *** “选择开始菜单文件夹”向导页
|
||||||
|
WizardSelectProgramGroup=选择开始菜单文件夹
|
||||||
|
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
|
||||||
|
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
|
||||||
|
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||||
|
MustEnterGroupName=您必须输入一个文件夹名。
|
||||||
|
GroupNameTooLong=文件夹名或路径太长。
|
||||||
|
InvalidGroupName=无效的文件夹名字。
|
||||||
|
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
|
||||||
|
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
|
||||||
|
|
||||||
|
; *** “准备安装”向导页
|
||||||
|
WizardReady=准备安装
|
||||||
|
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。
|
||||||
|
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
|
||||||
|
ReadyLabel2b=点击“安装”继续此安装程序。
|
||||||
|
ReadyMemoUserInfo=用户信息:
|
||||||
|
ReadyMemoDir=目标位置:
|
||||||
|
ReadyMemoType=安装类型:
|
||||||
|
ReadyMemoComponents=已选择组件:
|
||||||
|
ReadyMemoGroup=开始菜单文件夹:
|
||||||
|
ReadyMemoTasks=附加任务:
|
||||||
|
|
||||||
|
; *** TExtractionWizardPage 向导页面与 ExtractArchive
|
||||||
|
ExtractingLabel=正在解压文件...
|
||||||
|
ButtonStopExtraction=停止解压(&S)
|
||||||
|
StopExtraction=您确定要停止解压吗?
|
||||||
|
ErrorExtractionAborted=解压已中止
|
||||||
|
ErrorExtractionFailed=解压失败:%1
|
||||||
|
|
||||||
|
; *** 压缩文件解压失败详情
|
||||||
|
ArchiveIncorrectPassword=压缩文件密码不正确
|
||||||
|
ArchiveIsCorrupted=压缩文件已损坏
|
||||||
|
ArchiveUnsupportedFormat=不支持的压缩文件格式
|
||||||
|
|
||||||
|
; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile
|
||||||
|
DownloadingLabel2=正在下载文件...
|
||||||
|
ButtonStopDownload=停止下载(&S)
|
||||||
|
StopDownload=您确定要停止下载吗?
|
||||||
|
ErrorDownloadAborted=下载已中止
|
||||||
|
ErrorDownloadFailed=下载失败:%1 %2
|
||||||
|
ErrorDownloadSizeFailed=获取下载大小失败:%1 %2
|
||||||
|
ErrorProgress=无效的进度:%1 / %2
|
||||||
|
ErrorFileSize=文件大小错误:预期 %1,实际 %2
|
||||||
|
|
||||||
|
; *** “正在准备安装”向导页
|
||||||
|
WizardPreparing=正在准备安装
|
||||||
|
PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。
|
||||||
|
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后,再次运行安装程序以完成 [name] 的安装。
|
||||||
|
CannotContinue=安装程序不能继续。请点击“取消”退出。
|
||||||
|
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
|
||||||
|
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
|
||||||
|
CloseApplications=自动关闭应用程序(&A)
|
||||||
|
DontCloseApplications=不要关闭应用程序(&D)
|
||||||
|
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
|
||||||
|
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动?
|
||||||
|
|
||||||
|
; *** “正在安装”向导页
|
||||||
|
WizardInstalling=正在安装
|
||||||
|
InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。
|
||||||
|
|
||||||
|
; *** “安装完成”向导页
|
||||||
|
FinishedHeadingLabel=[name] 安装完成
|
||||||
|
FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。
|
||||||
|
FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
|
||||||
|
ClickFinish=点击“完成”退出安装程序。
|
||||||
|
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗?
|
||||||
|
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗?
|
||||||
|
ShowReadmeCheck=是,我想查阅自述文件
|
||||||
|
YesRadio=是,立即重启电脑(&Y)
|
||||||
|
NoRadio=否,稍后重启电脑(&N)
|
||||||
|
; used for example as 'Run MyProg.exe'
|
||||||
|
RunEntryExec=运行 %1
|
||||||
|
; used for example as 'View Readme.txt'
|
||||||
|
RunEntryShellExec=查阅 %1
|
||||||
|
|
||||||
|
; *** “安装程序需要下一张磁盘”提示
|
||||||
|
ChangeDiskTitle=安装程序需要下一张磁盘
|
||||||
|
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
|
||||||
|
PathLabel=路径(&P):
|
||||||
|
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
|
||||||
|
SelectDirectoryLabel=请指定下一张磁盘的位置。
|
||||||
|
|
||||||
|
; *** 安装阶段消息
|
||||||
|
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
|
||||||
|
AbortRetryIgnoreSelectAction=选择操作
|
||||||
|
AbortRetryIgnoreRetry=重试(&T)
|
||||||
|
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
|
||||||
|
AbortRetryIgnoreCancel=关闭安装程序
|
||||||
|
RetryCancelSelectAction=选择操作
|
||||||
|
RetryCancelRetry=重试(&T)
|
||||||
|
RetryCancelCancel=取消(&C)
|
||||||
|
|
||||||
|
; *** 安装状态消息
|
||||||
|
StatusClosingApplications=正在关闭应用程序...
|
||||||
|
StatusCreateDirs=正在创建目录...
|
||||||
|
StatusExtractFiles=正在提取文件...
|
||||||
|
StatusDownloadFiles=正在下载文件...
|
||||||
|
StatusCreateIcons=正在创建快捷方式...
|
||||||
|
StatusCreateIniEntries=正在创建 INI 条目...
|
||||||
|
StatusCreateRegistryEntries=正在创建注册表条目...
|
||||||
|
StatusRegisterFiles=正在注册文件...
|
||||||
|
StatusSavingUninstall=正在保存卸载信息...
|
||||||
|
StatusRunProgram=正在完成安装...
|
||||||
|
StatusRestartingApplications=正在重启应用程序...
|
||||||
|
StatusRollback=正在撤销更改...
|
||||||
|
|
||||||
|
; *** 其他错误
|
||||||
|
ErrorInternal2=内部错误:%1
|
||||||
|
ErrorFunctionFailedNoCode=%1 失败
|
||||||
|
ErrorFunctionFailed=%1 失败;错误代码 %2
|
||||||
|
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
|
||||||
|
ErrorExecutingProgram=无法执行文件:%n%1
|
||||||
|
|
||||||
|
; *** 注册表错误
|
||||||
|
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
|
||||||
|
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
|
||||||
|
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
|
||||||
|
|
||||||
|
; *** INI 错误
|
||||||
|
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
|
||||||
|
|
||||||
|
; *** 文件复制错误
|
||||||
|
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐)
|
||||||
|
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐)
|
||||||
|
SourceIsCorrupted=源文件已损坏
|
||||||
|
SourceDoesntExist=源文件“%1”不存在
|
||||||
|
SourceVerificationFailed=源文件验证失败: %1
|
||||||
|
VerificationSignatureDoesntExist=签名文件“%1”不存在
|
||||||
|
VerificationSignatureInvalid=签名文件“%1”无效
|
||||||
|
VerificationKeyNotFound=签名文件“%1”使用了未知密钥
|
||||||
|
VerificationFileNameIncorrect=文件名不正确
|
||||||
|
VerificationFileTagIncorrect=文件标签不正确
|
||||||
|
VerificationFileSizeIncorrect=文件大小不正确
|
||||||
|
VerificationFileHashIncorrect=文件哈希值不正确
|
||||||
|
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
|
||||||
|
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
|
||||||
|
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
|
||||||
|
ErrorReadingExistingDest=尝试读取现有文件时出错:
|
||||||
|
FileExistsSelectAction=选择操作
|
||||||
|
FileExists2=文件已经存在。
|
||||||
|
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
|
||||||
|
FileExistsKeepExisting=保留现有的文件(&K)
|
||||||
|
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
|
||||||
|
ExistingFileNewerSelectAction=选择操作
|
||||||
|
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
|
||||||
|
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
|
||||||
|
ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐)
|
||||||
|
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
|
||||||
|
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
|
||||||
|
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
|
||||||
|
ErrorReadingSource=尝试读取下列源文件时出错:
|
||||||
|
ErrorCopying=尝试复制下列文件时出错:
|
||||||
|
ErrorDownloading=下载文件时出错:
|
||||||
|
ErrorExtracting=解压压缩文件时出错:
|
||||||
|
ErrorReplacingExistingFile=尝试替换现有文件时出错:
|
||||||
|
ErrorRestartReplace=重启并替换失败:
|
||||||
|
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
|
||||||
|
ErrorRegisterServer=无法注册 DLL/OCX:%1
|
||||||
|
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
|
||||||
|
ErrorRegisterTypeLib=无法注册类库:%1
|
||||||
|
|
||||||
|
; *** 卸载显示名字标记
|
||||||
|
; used for example as 'My Program (32-bit)'
|
||||||
|
UninstallDisplayNameMark=%1 (%2)
|
||||||
|
; used for example as 'My Program (32-bit, All users)'
|
||||||
|
UninstallDisplayNameMarks=%1 (%2, %3)
|
||||||
|
UninstallDisplayNameMark32Bit=32 位
|
||||||
|
UninstallDisplayNameMark64Bit=64 位
|
||||||
|
UninstallDisplayNameMarkAllUsers=所有用户
|
||||||
|
UninstallDisplayNameMarkCurrentUser=当前用户
|
||||||
|
|
||||||
|
; *** 安装后错误
|
||||||
|
ErrorOpeningReadme=尝试打开自述文件时出错。
|
||||||
|
ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。
|
||||||
|
|
||||||
|
; *** 卸载消息
|
||||||
|
UninstallNotFound=文件“%1”不存在。无法卸载。
|
||||||
|
UninstallOpenError=文件“%1”不能被打开。无法卸载。
|
||||||
|
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
|
||||||
|
UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1)
|
||||||
|
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
|
||||||
|
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
|
||||||
|
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
|
||||||
|
UninstallStatusLabel=正在从您的电脑中移除 %1,请稍候。
|
||||||
|
UninstalledAll=已顺利从您的电脑中移除 %1。
|
||||||
|
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
|
||||||
|
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗?
|
||||||
|
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
|
||||||
|
|
||||||
|
; *** 卸载状态消息
|
||||||
|
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
|
||||||
|
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。
|
||||||
|
SharedFileNameLabel=文件名:
|
||||||
|
SharedFileLocationLabel=位置:
|
||||||
|
WizardUninstalling=卸载状态
|
||||||
|
StatusUninstalling=正在卸载 %1...
|
||||||
|
|
||||||
|
; *** Shutdown block reasons
|
||||||
|
ShutdownBlockReasonInstallingApp=正在安装 %1。
|
||||||
|
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
|
||||||
|
|
||||||
|
; The custom messages below aren't used by Setup itself, but if you make
|
||||||
|
; use of them in your scripts, you'll want to translate them.
|
||||||
|
|
||||||
|
[CustomMessages]
|
||||||
|
|
||||||
|
NameAndVersion=%1 版本 %2
|
||||||
|
AdditionalIcons=附加快捷方式:
|
||||||
|
CreateDesktopIcon=创建桌面快捷方式(&D)
|
||||||
|
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
|
||||||
|
ProgramOnTheWeb=%1 网站
|
||||||
|
UninstallProgram=卸载 %1
|
||||||
|
LaunchProgram=运行 %1
|
||||||
|
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
|
||||||
|
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
|
||||||
|
AutoStartProgramGroupDescription=启动:
|
||||||
|
AutoStartProgram=自动启动 %1
|
||||||
|
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗?
|
||||||
@ -1,5 +1,7 @@
|
|||||||
#define MyAppName "固定资产打印助手"
|
#define MyAppName "固定资产打印助手"
|
||||||
#define MyAppVersion "0.2.0"
|
#ifndef MyAppVersion
|
||||||
|
#define MyAppVersion "0.2.5"
|
||||||
|
#endif
|
||||||
#define MyAppPublisher "常州简尚家具有限公司"
|
#define MyAppPublisher "常州简尚家具有限公司"
|
||||||
#define MyAppExeName "FixedAssetPrintAgent.exe"
|
#define MyAppExeName "FixedAssetPrintAgent.exe"
|
||||||
#define MyAppId "com.jsgdzc.printagent.dotnet"
|
#define MyAppId "com.jsgdzc.printagent.dotnet"
|
||||||
@ -22,11 +24,18 @@ WizardStyle=modern
|
|||||||
ArchitecturesAllowed=x64compatible
|
ArchitecturesAllowed=x64compatible
|
||||||
ArchitecturesInstallIn64BitMode=x64compatible
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||||
|
AppMutex=Jsgdzc.FixedAssetPrintAgent
|
||||||
|
CloseApplications=yes
|
||||||
|
CloseApplicationsFilter={#MyAppExeName}
|
||||||
|
RestartApplications=no
|
||||||
OutputDir={#MyAppOutputDir}
|
OutputDir={#MyAppOutputDir}
|
||||||
OutputBaseFilename=print-agent-setup-{#MyAppVersion}
|
OutputBaseFilename=print-agent-setup-{#MyAppVersion}
|
||||||
|
|
||||||
[Languages]
|
[Languages]
|
||||||
Name: "chinesesimp"; MessagesFile: "compiler:Default.isl"
|
Name: "chinesesimp"; MessagesFile: "compiler:Default.isl,ChineseSimplified.isl"
|
||||||
|
|
||||||
|
[CustomMessages]
|
||||||
|
chinesesimp.InstallationRunningHint=如果看到“打印助手正在运行”的提示,保持“自动关闭应用程序”即可,安装程序会先关闭旧版助手,再继续覆盖安装。
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
|
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
|
||||||
@ -41,3 +50,11 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de
|
|||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\{#MyAppExeName}"; Description: "立即启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "立即启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
procedure InitializeWizard();
|
||||||
|
begin
|
||||||
|
WizardForm.WelcomeLabel2.Caption :=
|
||||||
|
WizardForm.WelcomeLabel2.Caption + #13#10#13#10 +
|
||||||
|
CustomMessage('InstallationRunningHint');
|
||||||
|
end;
|
||||||
|
|||||||
5
print-agent/.gitignore
vendored
Normal file
5
print-agent/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
36
print-agent/README.md
Normal file
36
print-agent/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# 固定资产打印助手 v1 骨架
|
||||||
|
|
||||||
|
这是一个独立的 Windows 托盘常驻打印助手骨架,当前只落在 `print-agent/` 内,不改主系统前后端。
|
||||||
|
|
||||||
|
## 当前已具备
|
||||||
|
|
||||||
|
- Electron 最小桌面壳
|
||||||
|
- 本地配置存储
|
||||||
|
- 驱动资源清单读取
|
||||||
|
- 一次性配对码接入客户端
|
||||||
|
- 心跳 / 领取任务 / 状态回写客户端
|
||||||
|
- 首次向导基础界面
|
||||||
|
- 驱动帮助区块
|
||||||
|
- 测试打印占位
|
||||||
|
|
||||||
|
## 当前仍是占位
|
||||||
|
|
||||||
|
- 正式资产标签 PNG 渲染
|
||||||
|
- 静默打印到真实标签机
|
||||||
|
- 安装包构建与签名
|
||||||
|
- 服务器 `downloads/` 真正归档文件
|
||||||
|
|
||||||
|
## 本地启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd print-agent
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd print-agent
|
||||||
|
npm test
|
||||||
|
```
|
||||||
5082
print-agent/package-lock.json
generated
Normal file
5082
print-agent/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
print-agent/package.json
Normal file
36
print-agent/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "fixed-asset-print-agent",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "固定资产系统 Windows 托盘常驻打印助手,支持远程标签静默打印",
|
||||||
|
"main": "src/main.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dev": "electron .",
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"pack:dir": "electron-builder --dir",
|
||||||
|
"dist": "electron-builder --win nsis"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.jsgdzc.printagent",
|
||||||
|
"productName": "固定资产打印助手",
|
||||||
|
"files": [
|
||||||
|
"src/**/*",
|
||||||
|
"resources/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"signAndEditExecutable": false,
|
||||||
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^37.2.4",
|
||||||
|
"electron-builder": "^26.0.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
print-agent/resources/company-driver-archive-note.txt
Normal file
10
print-agent/resources/company-driver-archive-note.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
当前 print-agent 子项目只提供“公司归档版驱动”的清单信息与下载位说明。
|
||||||
|
|
||||||
|
归档版目标文件名:
|
||||||
|
- xprinter-driver-2026.01.28.01.exe
|
||||||
|
|
||||||
|
原始来源路径:
|
||||||
|
- D:\Users\54972\Downloads\芯烨windows一键安装版驱动2026.01.28.01.exe
|
||||||
|
|
||||||
|
由于本次子任务写入范围仅限 print-agent/,未把 exe 真正复制到仓库根目录 downloads/。
|
||||||
|
后续应由主线程或发布流程把该文件上传到服务器约定目录,并保持与 driver-resources.json 中的 relativePath 一致。
|
||||||
15
print-agent/resources/driver-resources.json
Normal file
15
print-agent/resources/driver-resources.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"officialLatest": {
|
||||||
|
"version": "latest",
|
||||||
|
"title": "芯烨官方最新版驱动",
|
||||||
|
"sourceUrl": "https://www.x-printer.cn/1/windows%E5%B9%B3%E5%8F%B0%E4%B8%80%E9%94%AE%E5%AE%89%E8%A3%85%E7%89%88/",
|
||||||
|
"checkedAt": "2026-03-25T00:00:00.000+08:00"
|
||||||
|
},
|
||||||
|
"companyArchive": {
|
||||||
|
"version": "2026.01.28.01",
|
||||||
|
"fileName": "xprinter-driver-2026.01.28.01.exe",
|
||||||
|
"relativePath": "downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe",
|
||||||
|
"sourcePath": "D:\\Users\\54972\\Downloads\\芯烨windows一键安装版驱动2026.01.28.01.exe",
|
||||||
|
"notes": "当前子项目只保留归档清单和下载位说明,实际服务器归档文件由主线程发布流程接手。"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
print-agent/src/config/storage.js
Normal file
92
print-agent/src/config/storage.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export const CONFIG_FILE_NAME = 'agent-config.json'
|
||||||
|
|
||||||
|
const DEFAULT_DRIVER_HELP = {
|
||||||
|
officialLatest: {
|
||||||
|
version: 'latest',
|
||||||
|
title: '芯烨官方最新版驱动',
|
||||||
|
sourceUrl:
|
||||||
|
'https://www.x-printer.cn/1/windows%E5%B9%B3%E5%8F%B0%E4%B8%80%E9%94%AE%E5%AE%89%E8%A3%85%E7%89%88/'
|
||||||
|
},
|
||||||
|
companyArchive: {
|
||||||
|
version: '2026.01.28.01',
|
||||||
|
fileName: 'xprinter-driver-2026.01.28.01.exe',
|
||||||
|
relativePath:
|
||||||
|
'downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAgentConfig() {
|
||||||
|
return {
|
||||||
|
serverUrl: 'https://www.jsgdzc.cn',
|
||||||
|
pairingCode: '',
|
||||||
|
deviceToken: '',
|
||||||
|
terminalId: null,
|
||||||
|
terminalName: '',
|
||||||
|
selectedPrinterName: '',
|
||||||
|
pairedAt: '',
|
||||||
|
pollIntervalMs: 3000,
|
||||||
|
heartbeatIntervalMs: 30000,
|
||||||
|
driverHelp: DEFAULT_DRIVER_HELP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConfigStore(baseDir) {
|
||||||
|
const filePath = path.join(baseDir, CONFIG_FILE_NAME)
|
||||||
|
|
||||||
|
async function ensureDir() {
|
||||||
|
await fs.mkdir(baseDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf8')
|
||||||
|
return {
|
||||||
|
...defaultAgentConfig(),
|
||||||
|
...JSON.parse(content)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
return defaultAgentConfig()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(patch) {
|
||||||
|
const current = await load()
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch?.pairResult) {
|
||||||
|
next.terminalId =
|
||||||
|
patch.pairResult.terminalId ??
|
||||||
|
patch.pairResult.terminal_id ??
|
||||||
|
current.terminalId
|
||||||
|
next.deviceToken =
|
||||||
|
patch.pairResult.deviceToken ??
|
||||||
|
patch.pairResult.device_token ??
|
||||||
|
current.deviceToken
|
||||||
|
next.pairedAt =
|
||||||
|
patch.pairResult.pairedAt ??
|
||||||
|
patch.pairResult.paired_at ??
|
||||||
|
current.pairedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
delete next.pairResult
|
||||||
|
|
||||||
|
await ensureDir()
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(next, null, 2))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
load,
|
||||||
|
save
|
||||||
|
}
|
||||||
|
}
|
||||||
170
print-agent/src/index.html
Normal file
170
print-agent/src/index.html
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>固定资产打印助手</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Windows 托盘常驻 exe</p>
|
||||||
|
<h1>固定资产打印助手</h1>
|
||||||
|
<p class="hero-copy">
|
||||||
|
先完成配对、打印机选择和驱动检查,再把远程标签任务交给本机执行。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="status-chip" id="runtime-chip">未连接</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel warning-panel">
|
||||||
|
<strong>当前是远程标签打印 v1:</strong>
|
||||||
|
以后手机或网页只负责发任务,真正出纸由这台电脑静默完成。首次接入仍需要先安装驱动、选择标签机并确认 60x40mm。
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid-two">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>1. 首次配对</h2>
|
||||||
|
<p>输入服务器地址、终端名称和一次性配对码。</p>
|
||||||
|
</div>
|
||||||
|
<form id="pair-form" class="field-stack">
|
||||||
|
<label class="field">
|
||||||
|
<span>服务器地址</span>
|
||||||
|
<input id="server-url" type="url" placeholder="https://www.jsgdzc.cn" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>终端名称</span>
|
||||||
|
<input id="terminal-name" type="text" placeholder="例如:行政办公室标签机" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>一次性配对码</span>
|
||||||
|
<input id="pair-code" type="text" placeholder="例如:ABCD-EFGH" />
|
||||||
|
</label>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" id="save-config-button">保存本地配置</button>
|
||||||
|
<button type="submit" class="primary">立即配对</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>2. 打印机与标签纸</h2>
|
||||||
|
<p>选择 60x40mm 标签打印机,并用真实测试标签检查出纸。</p>
|
||||||
|
</div>
|
||||||
|
<div class="field-stack">
|
||||||
|
<label class="field">
|
||||||
|
<span>当前标签打印机</span>
|
||||||
|
<select id="printer-select"></select>
|
||||||
|
</label>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" id="refresh-printers-button">刷新打印机</button>
|
||||||
|
<button type="button" id="save-printer-button">保存打印机</button>
|
||||||
|
<button type="button" id="test-print-button">打印测试标签</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-list">
|
||||||
|
<div>
|
||||||
|
<span>已配对终端 ID</span>
|
||||||
|
<strong id="terminal-id">-</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>设备令牌状态</span>
|
||||||
|
<strong id="token-status">未配对</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>预期标签纸</span>
|
||||||
|
<strong>60x40mm</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid-two">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>3. 驱动帮助</h2>
|
||||||
|
<p>先装驱动,再重新检测打印机。</p>
|
||||||
|
</div>
|
||||||
|
<div class="driver-card">
|
||||||
|
<div>
|
||||||
|
<p class="driver-label">官方最新版</p>
|
||||||
|
<h3 id="official-driver-title">芯烨官方驱动</h3>
|
||||||
|
<p id="official-driver-url" class="mono"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="open-official-driver-button">打开官方链接</button>
|
||||||
|
</div>
|
||||||
|
<div class="driver-card">
|
||||||
|
<div>
|
||||||
|
<p class="driver-label">公司归档版</p>
|
||||||
|
<h3 id="archive-driver-version">版本 -</h3>
|
||||||
|
<p id="archive-driver-path" class="mono"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="open-archive-driver-button">打开归档下载位</button>
|
||||||
|
</div>
|
||||||
|
<p class="footnote">
|
||||||
|
如果当前电脑还没识别到标签机,先装驱动,再点“刷新打印机”,最后再继续 60x40mm 测试。
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>4. 运行时联通</h2>
|
||||||
|
<p>用于手动检查心跳、领取任务和状态回写链路。</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" id="heartbeat-button">立即发送心跳</button>
|
||||||
|
<button type="button" id="claim-button">手动领取一条任务</button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-list">
|
||||||
|
<div>
|
||||||
|
<span>最近心跳</span>
|
||||||
|
<strong id="last-heartbeat">-</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>当前版本</span>
|
||||||
|
<strong id="current-version">-</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>服务器版本</span>
|
||||||
|
<strong id="latest-version">-</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>版本状态</span>
|
||||||
|
<strong id="update-status">-</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>最近轮询</span>
|
||||||
|
<strong id="last-claim">-</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>最近任务结果</span>
|
||||||
|
<strong id="last-job-result">-</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>最近错误</span>
|
||||||
|
<strong id="last-error">-</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>运行日志</h2>
|
||||||
|
<p id="flash-message">准备就绪。</p>
|
||||||
|
</div>
|
||||||
|
<pre id="log-output" class="log-output"></pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="./renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
275
print-agent/src/main.js
Normal file
275
print-agent/src/main.js
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
ipcMain,
|
||||||
|
Menu,
|
||||||
|
Tray,
|
||||||
|
nativeImage,
|
||||||
|
shell
|
||||||
|
} from 'electron'
|
||||||
|
|
||||||
|
import { createConfigStore } from './config/storage.js'
|
||||||
|
import {
|
||||||
|
loadDriverResources,
|
||||||
|
resolveDriverDownloadUrl
|
||||||
|
} from './services/driverManifest.js'
|
||||||
|
import { createPrintAgentClient } from './services/apiClient.js'
|
||||||
|
import { createJobProcessor } from './services/jobProcessor.js'
|
||||||
|
import { createAgentRuntime } from './services/agentRuntime.js'
|
||||||
|
import {
|
||||||
|
buildAssetLabelPrintHtml,
|
||||||
|
buildTestLabelPrintHtml
|
||||||
|
} from './services/labelDocument.js'
|
||||||
|
import { printHtmlSilently } from './services/electronPrinter.js'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const agentRoot = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
let mainWindow = null
|
||||||
|
let tray = null
|
||||||
|
let runtime = null
|
||||||
|
let store = null
|
||||||
|
let isQuitting = false
|
||||||
|
let shouldShowMainWindow = false
|
||||||
|
|
||||||
|
function createTrayIcon() {
|
||||||
|
return nativeImage.createFromDataURL(
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAK0lEQVR4AWP4//8/AyUYTFhYWPjPwMBgkJGR4T8GBgYmhjEwMDAwQAEA8A4Qbw6X8UIAAAAASUVORK5CYII='
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendStateToRenderer(state) {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('agent:state', state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPrinters() {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const printers = await mainWindow.webContents.getPrintersAsync()
|
||||||
|
return printers.map((printer) => ({
|
||||||
|
name: printer.name,
|
||||||
|
description: printer.description || '',
|
||||||
|
status: printer.status ?? 0,
|
||||||
|
isDefault: Boolean(printer.isDefault)
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
description: `打印机列表读取失败:${error.message}`,
|
||||||
|
status: -1,
|
||||||
|
isDefault: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1080,
|
||||||
|
height: 760,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 680,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
backgroundColor: '#0b0d12',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.loadFile(path.join(__dirname, 'index.html'))
|
||||||
|
|
||||||
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
if (shouldShowMainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!isQuitting) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow.hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTray() {
|
||||||
|
tray = new Tray(createTrayIcon())
|
||||||
|
tray.setToolTip('固定资产打印助手')
|
||||||
|
tray.setContextMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '打开打印助手',
|
||||||
|
click: () => mainWindow?.show()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '退出',
|
||||||
|
click: () => {
|
||||||
|
isQuitting = true
|
||||||
|
runtime?.stop()
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
tray.on('double-click', () => mainWindow?.show())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDriverResourcesForUi(config) {
|
||||||
|
const resources = await loadDriverResources(agentRoot)
|
||||||
|
return {
|
||||||
|
...resources,
|
||||||
|
companyArchive: {
|
||||||
|
...resources.companyArchive,
|
||||||
|
downloadUrl: resolveDriverDownloadUrl(
|
||||||
|
config?.serverUrl || 'https://www.jsgdzc.cn',
|
||||||
|
resources.companyArchive
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpcHandlers() {
|
||||||
|
ipcMain.handle('agent:bootstrap', async () => {
|
||||||
|
const config = await runtime.loadConfig()
|
||||||
|
const printers = await runtime.refreshPrinters()
|
||||||
|
const driverResources = await getDriverResourcesForUi(config)
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
printers,
|
||||||
|
driverResources,
|
||||||
|
runtimeState: runtime.getState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('agent:save-config', async (_event, patch) => {
|
||||||
|
const config = await runtime.updateConfig(patch)
|
||||||
|
const driverResources = await getDriverResourcesForUi(config)
|
||||||
|
return { config, driverResources }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('agent:list-printers', async () => runtime.refreshPrinters())
|
||||||
|
ipcMain.handle('agent:heartbeat-now', async () => runtime.heartbeatOnce())
|
||||||
|
ipcMain.handle('agent:claim-now', async () => runtime.pollOnce())
|
||||||
|
ipcMain.handle('agent:test-print', async () => runtime.runTestPrint())
|
||||||
|
ipcMain.handle('agent:open-external', async (_event, url) => shell.openExternal(url))
|
||||||
|
|
||||||
|
ipcMain.handle('agent:pair', async (_event, payload) => {
|
||||||
|
const currentConfig = await runtime.loadConfig()
|
||||||
|
const client = createPrintAgentClient({
|
||||||
|
serverUrl: payload.serverUrl || currentConfig.serverUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await client.pair({
|
||||||
|
pairCode: payload.pairCode,
|
||||||
|
terminalName: payload.terminalName,
|
||||||
|
machineName: payload.machineName || os.hostname(),
|
||||||
|
platform: payload.platform || process.platform
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = await runtime.updateConfig({
|
||||||
|
serverUrl: payload.serverUrl || currentConfig.serverUrl,
|
||||||
|
terminalName: payload.terminalName || currentConfig.terminalName,
|
||||||
|
pairingCode: '',
|
||||||
|
pairResult: result
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
pairResult: result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRuntime() {
|
||||||
|
store = createConfigStore(path.join(app.getPath('userData'), 'data'))
|
||||||
|
const jobProcessor = createJobProcessor({
|
||||||
|
async printAssetLabels({ items, printerName }) {
|
||||||
|
const html = await buildAssetLabelPrintHtml({
|
||||||
|
items
|
||||||
|
})
|
||||||
|
|
||||||
|
await printHtmlSilently({
|
||||||
|
html,
|
||||||
|
printerName,
|
||||||
|
jobName: '资产标签打印'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
printed_count: items.length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async printTestLabel({ printerName }) {
|
||||||
|
const html = await buildTestLabelPrintHtml({
|
||||||
|
printerName
|
||||||
|
})
|
||||||
|
|
||||||
|
await printHtmlSilently({
|
||||||
|
html,
|
||||||
|
printerName,
|
||||||
|
jobName: '测试标签打印'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
runtime = createAgentRuntime({
|
||||||
|
store,
|
||||||
|
clientFactory: (options) => createPrintAgentClient(options),
|
||||||
|
getPrinters,
|
||||||
|
jobProcessor,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
onStateChange: sendStateToRenderer
|
||||||
|
})
|
||||||
|
|
||||||
|
await runtime.start()
|
||||||
|
|
||||||
|
const config = runtime.getState().config
|
||||||
|
shouldShowMainWindow = !config?.deviceToken
|
||||||
|
if (shouldShowMainWindow) {
|
||||||
|
mainWindow?.show()
|
||||||
|
} else {
|
||||||
|
mainWindow?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.setAppUserModelId('com.jsgdzc.printagent')
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: true,
|
||||||
|
openAsHidden: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
isQuitting = true
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
createWindow()
|
||||||
|
createTray()
|
||||||
|
registerIpcHandlers()
|
||||||
|
await createRuntime()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
} else {
|
||||||
|
mainWindow?.show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
17
print-agent/src/preload.js
Normal file
17
print-agent/src/preload.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('printAgent', {
|
||||||
|
bootstrap: () => ipcRenderer.invoke('agent:bootstrap'),
|
||||||
|
saveConfig: (patch) => ipcRenderer.invoke('agent:save-config', patch),
|
||||||
|
pair: (payload) => ipcRenderer.invoke('agent:pair', payload),
|
||||||
|
listPrinters: () => ipcRenderer.invoke('agent:list-printers'),
|
||||||
|
heartbeatNow: () => ipcRenderer.invoke('agent:heartbeat-now'),
|
||||||
|
claimNow: () => ipcRenderer.invoke('agent:claim-now'),
|
||||||
|
testPrint: () => ipcRenderer.invoke('agent:test-print'),
|
||||||
|
openExternal: (url) => ipcRenderer.invoke('agent:open-external', url),
|
||||||
|
onStateChange: (callback) => {
|
||||||
|
const listener = (_event, state) => callback(state)
|
||||||
|
ipcRenderer.on('agent:state', listener)
|
||||||
|
return () => ipcRenderer.removeListener('agent:state', listener)
|
||||||
|
}
|
||||||
|
})
|
||||||
209
print-agent/src/renderer.js
Normal file
209
print-agent/src/renderer.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
const ui = {
|
||||||
|
config: null,
|
||||||
|
driverResources: null,
|
||||||
|
runtimeState: null,
|
||||||
|
printers: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function $(id) {
|
||||||
|
return document.getElementById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFlashMessage(message, isError = false) {
|
||||||
|
const el = $('flash-message')
|
||||||
|
el.textContent = message
|
||||||
|
el.style.color = isError ? 'var(--danger)' : 'var(--muted)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPrinterOptions() {
|
||||||
|
const select = $('printer-select')
|
||||||
|
select.innerHTML = ''
|
||||||
|
|
||||||
|
const printers = ui.printers || []
|
||||||
|
if (!printers.length) {
|
||||||
|
const option = document.createElement('option')
|
||||||
|
option.value = ''
|
||||||
|
option.textContent = '暂未识别到打印机'
|
||||||
|
select.append(option)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printers.forEach((printer) => {
|
||||||
|
const option = document.createElement('option')
|
||||||
|
option.value = printer.name
|
||||||
|
option.textContent = printer.name || '未命名打印机'
|
||||||
|
if (printer.name === ui.config?.selectedPrinterName) {
|
||||||
|
option.selected = true
|
||||||
|
}
|
||||||
|
select.append(option)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDriverResources() {
|
||||||
|
const resources = ui.driverResources
|
||||||
|
if (!resources) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$('official-driver-title').textContent =
|
||||||
|
resources.officialLatest.title || '芯烨官方最新版驱动'
|
||||||
|
$('official-driver-url').textContent = resources.officialLatest.sourceUrl || '-'
|
||||||
|
$('archive-driver-version').textContent = `版本 ${resources.companyArchive.version || '-'}`
|
||||||
|
$('archive-driver-path').textContent =
|
||||||
|
resources.companyArchive.downloadUrl || resources.companyArchive.relativePath || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRuntimeState() {
|
||||||
|
const runtimeState = ui.runtimeState || {}
|
||||||
|
const config = ui.config || {}
|
||||||
|
|
||||||
|
$('runtime-chip').textContent = config.deviceToken ? '已配对' : '未配对'
|
||||||
|
$('terminal-id').textContent = config.terminalId ?? '-'
|
||||||
|
$('token-status').textContent = config.deviceToken ? '已保存' : '未配对'
|
||||||
|
$('current-version').textContent = runtimeState.appVersion || '-'
|
||||||
|
$('latest-version').textContent = runtimeState.latestReleaseVersion || '-'
|
||||||
|
$('update-status').textContent = runtimeState.updateStatusMessage || '-'
|
||||||
|
$('last-heartbeat').textContent = runtimeState.lastHeartbeatAt || '-'
|
||||||
|
$('last-claim').textContent = runtimeState.lastClaimAt || '-'
|
||||||
|
$('last-job-result').textContent = runtimeState.lastJobResult || '-'
|
||||||
|
$('last-error').textContent = runtimeState.lastError || '-'
|
||||||
|
$('log-output').textContent = (runtimeState.logLines || []).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfig() {
|
||||||
|
const config = ui.config || {}
|
||||||
|
$('server-url').value = config.serverUrl || ''
|
||||||
|
$('terminal-name').value = config.terminalName || ''
|
||||||
|
$('pair-code').value = config.pairingCode || ''
|
||||||
|
renderPrinterOptions()
|
||||||
|
renderDriverResources()
|
||||||
|
renderRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfigPatch(patch) {
|
||||||
|
const result = await window.printAgent.saveConfig(patch)
|
||||||
|
ui.config = result.config
|
||||||
|
ui.driverResources = result.driverResources
|
||||||
|
renderConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFormValues() {
|
||||||
|
return {
|
||||||
|
serverUrl: $('server-url').value.trim(),
|
||||||
|
terminalName: $('terminal-name').value.trim(),
|
||||||
|
pairCode: $('pair-code').value.trim(),
|
||||||
|
selectedPrinterName: $('printer-select').value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const result = await window.printAgent.bootstrap()
|
||||||
|
ui.config = result.config
|
||||||
|
ui.printers = result.printers
|
||||||
|
ui.driverResources = result.driverResources
|
||||||
|
ui.runtimeState = result.runtimeState
|
||||||
|
renderConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
$('pair-form').addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const values = currentFormValues()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.printAgent.pair(values)
|
||||||
|
ui.config = result.config
|
||||||
|
$('pair-code').value = ''
|
||||||
|
renderConfig()
|
||||||
|
setFlashMessage(
|
||||||
|
`配对成功,终端 ID:${result.pairResult.terminalId || result.pairResult.terminal_id}`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
setFlashMessage(`配对失败:${error.message}`, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$('save-config-button').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await saveConfigPatch(currentFormValues())
|
||||||
|
setFlashMessage('本地配置已保存。')
|
||||||
|
} catch (error) {
|
||||||
|
setFlashMessage(`保存失败:${error.message}`, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$('save-printer-button').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await saveConfigPatch({
|
||||||
|
selectedPrinterName: $('printer-select').value
|
||||||
|
})
|
||||||
|
setFlashMessage('打印机选择已保存。')
|
||||||
|
} catch (error) {
|
||||||
|
setFlashMessage(`保存打印机失败:${error.message}`, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$('refresh-printers-button').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
ui.printers = await window.printAgent.listPrinters()
|
||||||
|
renderPrinterOptions()
|
||||||
|
setFlashMessage('打印机列表已刷新。')
|
||||||
|
} catch (error) {
|
||||||
|
setFlashMessage(`刷新失败:${error.message}`, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$('test-print-button').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await saveConfigPatch({
|
||||||
|
selectedPrinterName: $('printer-select').value
|
||||||
|
})
|
||||||
|
const result = await window.printAgent.testPrint()
|
||||||
|
setFlashMessage(result.message)
|
||||||
|
} catch (error) {
|
||||||
|
setFlashMessage(`测试打印失败:${error.message}`, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$('heartbeat-button').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await window.printAgent.heartbeatNow()
|
||||||
|
setFlashMessage('心跳已发送。')
|
||||||
|
} catch (error) {
|
||||||
|
setFlashMessage(`心跳失败:${error.message}`, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$('claim-button').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.printAgent.claimNow()
|
||||||
|
setFlashMessage(result ? '已完成一次远程标签处理。' : '当前没有可领取任务。')
|
||||||
|
} catch (error) {
|
||||||
|
setFlashMessage(`领取任务失败:${error.message}`, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$('open-official-driver-button').addEventListener('click', async () => {
|
||||||
|
const url = ui.driverResources?.officialLatest?.sourceUrl
|
||||||
|
if (url) {
|
||||||
|
await window.printAgent.openExternal(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$('open-archive-driver-button').addEventListener('click', async () => {
|
||||||
|
const url = ui.driverResources?.companyArchive?.downloadUrl
|
||||||
|
if (url) {
|
||||||
|
await window.printAgent.openExternal(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
window.printAgent.onStateChange((runtimeState) => {
|
||||||
|
ui.runtimeState = runtimeState
|
||||||
|
renderRuntimeState()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents()
|
||||||
|
bootstrap().catch((error) => {
|
||||||
|
setFlashMessage(`初始化失败:${error.message}`, true)
|
||||||
|
})
|
||||||
274
print-agent/src/services/agentRuntime.js
Normal file
274
print-agent/src/services/agentRuntime.js
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
export function createAgentRuntime({
|
||||||
|
store,
|
||||||
|
clientFactory,
|
||||||
|
getPrinters,
|
||||||
|
jobProcessor,
|
||||||
|
appVersion = '',
|
||||||
|
setIntervalImpl = globalThis.setInterval,
|
||||||
|
clearIntervalImpl = globalThis.clearInterval,
|
||||||
|
onStateChange = () => {}
|
||||||
|
}) {
|
||||||
|
const state = {
|
||||||
|
running: false,
|
||||||
|
config: null,
|
||||||
|
printers: [],
|
||||||
|
appVersion,
|
||||||
|
latestReleaseVersion: '',
|
||||||
|
updateStatusMessage: '',
|
||||||
|
lastHeartbeatAt: '',
|
||||||
|
lastClaimAt: '',
|
||||||
|
lastJobResult: '',
|
||||||
|
lastError: '',
|
||||||
|
logLines: []
|
||||||
|
}
|
||||||
|
|
||||||
|
let heartbeatTimer = null
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
function pushLog(message) {
|
||||||
|
const line = `${new Date().toLocaleString('zh-CN', { hour12: false })} ${message}`
|
||||||
|
state.logLines = [line, ...state.logLines].slice(0, 40)
|
||||||
|
onStateChange({ ...state })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
state.config = await store.load()
|
||||||
|
onStateChange({ ...state })
|
||||||
|
return state.config
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPrinters() {
|
||||||
|
state.printers = await getPrinters()
|
||||||
|
onStateChange({ ...state })
|
||||||
|
return state.printers
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLabelProfile() {
|
||||||
|
return {
|
||||||
|
paper_size: '60x40mm',
|
||||||
|
width_mm: 60,
|
||||||
|
height_mm: 40,
|
||||||
|
orientation: 'landscape',
|
||||||
|
scale_mode: 'fit'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClient() {
|
||||||
|
return clientFactory({
|
||||||
|
serverUrl: state.config?.serverUrl,
|
||||||
|
getDeviceToken: () => state.config?.deviceToken || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForUpdates() {
|
||||||
|
if (!state.config?.serverUrl) {
|
||||||
|
state.updateStatusMessage = state.appVersion
|
||||||
|
? `当前版本 ${state.appVersion}`
|
||||||
|
: '未配置版本信息'
|
||||||
|
onStateChange({ ...state })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getClient()
|
||||||
|
const releaseInfo = await client.getPrintAgentReleases()
|
||||||
|
const latestVersion =
|
||||||
|
releaseInfo?.latest_version ||
|
||||||
|
(Array.isArray(releaseInfo?.releases) ? releaseInfo.releases[0]?.version : '') ||
|
||||||
|
''
|
||||||
|
|
||||||
|
state.latestReleaseVersion = latestVersion
|
||||||
|
state.updateStatusMessage =
|
||||||
|
latestVersion && latestVersion !== state.appVersion
|
||||||
|
? `发现新版本 ${latestVersion},可从后台下载更新`
|
||||||
|
: state.appVersion
|
||||||
|
? `当前已是最新版本 ${state.appVersion}`
|
||||||
|
: latestVersion
|
||||||
|
? `服务器当前版本 ${latestVersion}`
|
||||||
|
: '暂未读取到版本信息'
|
||||||
|
onStateChange({ ...state })
|
||||||
|
return releaseInfo
|
||||||
|
} catch (error) {
|
||||||
|
state.updateStatusMessage = '版本检查失败,请稍后重试'
|
||||||
|
onStateChange({ ...state })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function heartbeatOnce() {
|
||||||
|
if (!state.config) {
|
||||||
|
await loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.config?.deviceToken || !state.config?.terminalId) {
|
||||||
|
return {
|
||||||
|
skipped: true,
|
||||||
|
message: '尚未完成配对'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const printers = await refreshPrinters()
|
||||||
|
const client = getClient()
|
||||||
|
const payload = {
|
||||||
|
terminalId: state.config?.terminalId,
|
||||||
|
terminalName: state.config?.terminalName,
|
||||||
|
selectedPrinterName: state.config?.selectedPrinterName,
|
||||||
|
printerSnapshot: printers,
|
||||||
|
labelProfile: buildLabelProfile(),
|
||||||
|
ready:
|
||||||
|
Boolean(state.config?.deviceToken) && Boolean(state.config?.selectedPrinterName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.sendHeartbeat(payload)
|
||||||
|
state.lastHeartbeatAt = new Date().toISOString()
|
||||||
|
state.lastError = ''
|
||||||
|
pushLog(`心跳已发送:${response.message || '服务器已接收'}`)
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
state.lastError = error.message
|
||||||
|
pushLog(`心跳失败:${error.message}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollOnce() {
|
||||||
|
if (!state.config?.deviceToken || !state.config?.terminalId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claimResult = await client.claimNextJob({
|
||||||
|
terminalId: state.config.terminalId
|
||||||
|
})
|
||||||
|
state.lastClaimAt = new Date().toISOString()
|
||||||
|
|
||||||
|
if (!claimResult?.job) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = claimResult.job
|
||||||
|
await client.reportJobStatus(job.id, {
|
||||||
|
status: 'printing'
|
||||||
|
})
|
||||||
|
|
||||||
|
const processingResult = await jobProcessor.process(job, {
|
||||||
|
selectedPrinterName: state.config?.selectedPrinterName
|
||||||
|
})
|
||||||
|
state.lastJobResult = processingResult.message || processingResult.errorMessage || ''
|
||||||
|
|
||||||
|
await client.reportJobStatus(job.id, {
|
||||||
|
status: processingResult.nextStatus,
|
||||||
|
errorMessage: processingResult.errorMessage || null
|
||||||
|
})
|
||||||
|
|
||||||
|
pushLog(
|
||||||
|
processingResult.ok
|
||||||
|
? `任务 ${job.id} 已发送到 ${state.config?.selectedPrinterName || '当前打印机'}。`
|
||||||
|
: `任务 ${job.id} 打印失败:${processingResult.errorMessage}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return processingResult
|
||||||
|
} catch (error) {
|
||||||
|
state.lastError = error.message
|
||||||
|
pushLog(`领取任务失败:${error.message}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTestPrint() {
|
||||||
|
if (!state.config) {
|
||||||
|
await loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
const printers = await refreshPrinters()
|
||||||
|
const result = await jobProcessor.runTestPrint({
|
||||||
|
selectedPrinterName: state.config?.selectedPrinterName
|
||||||
|
})
|
||||||
|
|
||||||
|
if (state.config?.deviceToken && state.config?.terminalId) {
|
||||||
|
try {
|
||||||
|
const client = getClient()
|
||||||
|
await client.requestTestPrint({
|
||||||
|
selectedPrinterName: state.config?.selectedPrinterName,
|
||||||
|
printerSnapshot: printers,
|
||||||
|
labelProfile: buildLabelProfile(),
|
||||||
|
success: result.ok,
|
||||||
|
errorMessage: result.ok ? null : result.message
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
state.lastError = error.message
|
||||||
|
pushLog(`测试打印结果回写失败:${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastJobResult = result.message
|
||||||
|
pushLog(result.message)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateConfig(patch) {
|
||||||
|
state.config = await store.save(patch)
|
||||||
|
onStateChange({ ...state })
|
||||||
|
return state.config
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearIntervalImpl(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pollTimer) {
|
||||||
|
clearIntervalImpl(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await loadConfig()
|
||||||
|
await refreshPrinters()
|
||||||
|
await checkForUpdates()
|
||||||
|
state.running = true
|
||||||
|
onStateChange({ ...state })
|
||||||
|
|
||||||
|
await heartbeatOnce().catch(() => {})
|
||||||
|
await pollOnce().catch(() => {})
|
||||||
|
|
||||||
|
heartbeatTimer = setIntervalImpl(() => {
|
||||||
|
heartbeatOnce().catch(() => {})
|
||||||
|
}, config.heartbeatIntervalMs)
|
||||||
|
|
||||||
|
pollTimer = setIntervalImpl(() => {
|
||||||
|
pollOnce().catch(() => {})
|
||||||
|
}, config.pollIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
state.running = false
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearIntervalImpl(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
if (pollTimer) {
|
||||||
|
clearIntervalImpl(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
onStateChange({ ...state })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getState() {
|
||||||
|
return { ...state }
|
||||||
|
},
|
||||||
|
loadConfig,
|
||||||
|
refreshPrinters,
|
||||||
|
checkForUpdates,
|
||||||
|
heartbeatOnce,
|
||||||
|
pollOnce,
|
||||||
|
runTestPrint,
|
||||||
|
updateConfig,
|
||||||
|
start,
|
||||||
|
stop
|
||||||
|
}
|
||||||
|
}
|
||||||
166
print-agent/src/services/apiClient.js
Normal file
166
print-agent/src/services/apiClient.js
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
function trimBaseUrl(serverUrl) {
|
||||||
|
return String(serverUrl || '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
.replace(/\/api$/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapEnvelope(payload) {
|
||||||
|
if (payload && typeof payload === 'object' && 'data' in payload && 'code' in payload) {
|
||||||
|
const data = payload.data
|
||||||
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
message: data.message || payload.message || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse(response) {
|
||||||
|
let data = {}
|
||||||
|
|
||||||
|
if (typeof response.text === 'function') {
|
||||||
|
const text = await response.text()
|
||||||
|
data = text ? JSON.parse(text) : {}
|
||||||
|
} else if (typeof response.json === 'function') {
|
||||||
|
data = (await response.json()) || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(data.message || '打印助手请求失败')
|
||||||
|
error.status = response.status
|
||||||
|
error.payload = data
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return unwrapEnvelope(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrintAgentClient({
|
||||||
|
serverUrl,
|
||||||
|
getDeviceToken = () => '',
|
||||||
|
fetchImpl = globalThis.fetch
|
||||||
|
}) {
|
||||||
|
if (typeof fetchImpl !== 'function') {
|
||||||
|
throw new Error('当前运行环境不支持 fetch,请补充 fetch 实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(endpoint, { method = 'GET', body, auth = true } = {}) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
const deviceToken = getDeviceToken()
|
||||||
|
if (deviceToken) {
|
||||||
|
headers.Authorization = `Bearer ${deviceToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchImpl(`${trimBaseUrl(serverUrl)}${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return parseResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSnakeCaseBody = (body = {}) => {
|
||||||
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = { ...body }
|
||||||
|
|
||||||
|
if ('pairCode' in mapped) {
|
||||||
|
mapped.code = mapped.pairCode
|
||||||
|
delete mapped.pairCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('terminalName' in mapped) {
|
||||||
|
mapped.terminal_name = mapped.terminalName
|
||||||
|
delete mapped.terminalName
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('machineName' in mapped) {
|
||||||
|
mapped.machine_name = mapped.machineName
|
||||||
|
delete mapped.machineName
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('selectedPrinterName' in mapped) {
|
||||||
|
mapped.selected_printer_name = mapped.selectedPrinterName
|
||||||
|
delete mapped.selectedPrinterName
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('printerSnapshot' in mapped) {
|
||||||
|
mapped.printer_snapshot = mapped.printerSnapshot
|
||||||
|
delete mapped.printerSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('labelProfile' in mapped) {
|
||||||
|
mapped.label_profile = mapped.labelProfile
|
||||||
|
delete mapped.labelProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('errorMessage' in mapped) {
|
||||||
|
mapped.error_message = mapped.errorMessage
|
||||||
|
delete mapped.errorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('retryable' in mapped) {
|
||||||
|
mapped.retryable = Boolean(mapped.retryable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('terminalId' in mapped) {
|
||||||
|
mapped.terminal_id = mapped.terminalId
|
||||||
|
delete mapped.terminalId
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pair(payload) {
|
||||||
|
return request('/api/print-agent/pair', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: toSnakeCaseBody(payload)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendHeartbeat(payload) {
|
||||||
|
return request('/api/print-agent/heartbeat', {
|
||||||
|
method: 'POST',
|
||||||
|
body: toSnakeCaseBody(payload)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
claimNextJob(payload) {
|
||||||
|
return request('/api/print-agent/jobs/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
body: toSnakeCaseBody(payload)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reportJobStatus(jobId, payload) {
|
||||||
|
return request(`/api/print-agent/jobs/${jobId}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: toSnakeCaseBody(payload)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
requestTestPrint(payload) {
|
||||||
|
return request('/api/print-agent/test-print', {
|
||||||
|
method: 'POST',
|
||||||
|
body: toSnakeCaseBody(payload)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getPrintAgentReleases() {
|
||||||
|
return request('/downloads/print-agent/releases', {
|
||||||
|
method: 'GET',
|
||||||
|
auth: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
print-agent/src/services/driverManifest.js
Normal file
61
print-agent/src/services/driverManifest.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export const DEFAULT_DRIVER_RESOURCES = {
|
||||||
|
officialLatest: {
|
||||||
|
version: 'latest',
|
||||||
|
title: '芯烨官方最新版驱动',
|
||||||
|
sourceUrl:
|
||||||
|
'https://www.x-printer.cn/1/windows%E5%B9%B3%E5%8F%B0%E4%B8%80%E9%94%AE%E5%AE%89%E8%A3%85%E7%89%88/',
|
||||||
|
checkedAt: '2026-03-25T00:00:00.000+08:00'
|
||||||
|
},
|
||||||
|
companyArchive: {
|
||||||
|
version: '2026.01.28.01',
|
||||||
|
fileName: 'xprinter-driver-2026.01.28.01.exe',
|
||||||
|
relativePath:
|
||||||
|
'downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe',
|
||||||
|
sourcePath:
|
||||||
|
'D:\\Users\\54972\\Downloads\\芯烨windows一键安装版驱动2026.01.28.01.exe'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDriverResources(repoRoot) {
|
||||||
|
const manifestPaths = [
|
||||||
|
path.join(repoRoot, 'resources', 'driver-resources.json'),
|
||||||
|
path.join(repoRoot, 'print-agent', 'resources', 'driver-resources.json')
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const manifestPath of manifestPaths) {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(manifestPath, 'utf8')
|
||||||
|
const manifest = JSON.parse(content)
|
||||||
|
return {
|
||||||
|
...DEFAULT_DRIVER_RESOURCES,
|
||||||
|
...manifest,
|
||||||
|
officialLatest: {
|
||||||
|
...DEFAULT_DRIVER_RESOURCES.officialLatest,
|
||||||
|
...manifest.officialLatest
|
||||||
|
},
|
||||||
|
companyArchive: {
|
||||||
|
...DEFAULT_DRIVER_RESOURCES.companyArchive,
|
||||||
|
...manifest.companyArchive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_DRIVER_RESOURCES
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDriverDownloadUrl(serverUrl, manifest) {
|
||||||
|
const baseUrl = String(serverUrl || '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
.replace(/\/api$/i, '')
|
||||||
|
const relativePath = String(manifest.relativePath || '').replace(/^\/+/, '')
|
||||||
|
return `${baseUrl}/${relativePath}`
|
||||||
|
}
|
||||||
101
print-agent/src/services/electronPrinter.js
Normal file
101
print-agent/src/services/electronPrinter.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { BrowserWindow } from 'electron'
|
||||||
|
|
||||||
|
const LABEL_PAGE_SIZE_MICRONS = {
|
||||||
|
width: 60000,
|
||||||
|
height: 40000
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitForPrintableContent = async (win) => {
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const waitForImages = () => {
|
||||||
|
const images = Array.from(document.images || [])
|
||||||
|
Promise.all(
|
||||||
|
images.map((img) =>
|
||||||
|
img.complete
|
||||||
|
? Promise.resolve()
|
||||||
|
: new Promise((done) => {
|
||||||
|
img.addEventListener('load', done, { once: true })
|
||||||
|
img.addEventListener('error', done, { once: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).then(() => setTimeout(resolve, 120))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.fonts && document.fonts.ready) {
|
||||||
|
document.fonts.ready.then(waitForImages).catch(waitForImages)
|
||||||
|
} else {
|
||||||
|
waitForImages()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const printHtmlSilently = async ({
|
||||||
|
html,
|
||||||
|
printerName,
|
||||||
|
jobName = '固定资产标签打印'
|
||||||
|
}) => {
|
||||||
|
const resolvedPrinterName = String(printerName ?? '').trim()
|
||||||
|
if (!resolvedPrinterName) {
|
||||||
|
throw new Error('尚未选择标签打印机,请先完成打印机配置。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gdzc-print-'))
|
||||||
|
const htmlPath = path.join(tempDir, 'print.html')
|
||||||
|
await fs.writeFile(htmlPath, html, 'utf8')
|
||||||
|
|
||||||
|
const printWindow = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 980,
|
||||||
|
height: 720,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
webPreferences: {
|
||||||
|
sandbox: false,
|
||||||
|
backgroundThrottling: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await printWindow.loadFile(htmlPath)
|
||||||
|
await waitForPrintableContent(printWindow)
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
printWindow.webContents.print(
|
||||||
|
{
|
||||||
|
silent: true,
|
||||||
|
printBackground: true,
|
||||||
|
deviceName: resolvedPrinterName,
|
||||||
|
margins: {
|
||||||
|
marginType: 'none'
|
||||||
|
},
|
||||||
|
pageSize: LABEL_PAGE_SIZE_MICRONS
|
||||||
|
},
|
||||||
|
(success, failureReason) => {
|
||||||
|
if (success) {
|
||||||
|
resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
failureReason || `${jobName}失败,请检查打印机是否在线、驱动是否正常。`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (!printWindow.isDestroyed()) {
|
||||||
|
printWindow.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(tempDir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
121
print-agent/src/services/jobProcessor.js
Normal file
121
print-agent/src/services/jobProcessor.js
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
const normalizePrinterName = (printerName) => String(printerName ?? '').trim()
|
||||||
|
|
||||||
|
const parsePayload = (job) => {
|
||||||
|
if (job?.payload && typeof job.payload === 'object') {
|
||||||
|
return job.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof job?.payload_json === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(job.payload_json)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job?.payload_json && typeof job.payload_json === 'object') {
|
||||||
|
return job.payload_json
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAssetLabelItems = (job) => {
|
||||||
|
const payload = parsePayload(job)
|
||||||
|
if (!payload || !Array.isArray(payload.items)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return payload.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestPrintSuccessMessage(printerName) {
|
||||||
|
const resolvedPrinterName = normalizePrinterName(printerName) || '已选打印机'
|
||||||
|
return `测试标签已发送到 ${resolvedPrinterName},请确认是否按 60x40mm 正常出纸。`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJobProcessor({
|
||||||
|
printAssetLabels = async () => {
|
||||||
|
throw new Error('尚未注入真实资产标签打印执行器')
|
||||||
|
},
|
||||||
|
printTestLabel = async () => {
|
||||||
|
throw new Error('尚未注入真实测试标签打印执行器')
|
||||||
|
}
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
async process(job, { selectedPrinterName } = {}) {
|
||||||
|
if (job?.job_type === 'asset_label') {
|
||||||
|
const printerName = normalizePrinterName(selectedPrinterName)
|
||||||
|
if (!printerName) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
nextStatus: 'failed',
|
||||||
|
errorMessage: '尚未选择标签打印机,请先在打印助手里完成打印机配置。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = getAssetLabelItems(job)
|
||||||
|
if (!items?.length) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
nextStatus: 'failed',
|
||||||
|
errorMessage: '当前打印任务缺少可用的资产标签数据,已阻止实际出纸。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const printResult = await printAssetLabels({
|
||||||
|
job,
|
||||||
|
items,
|
||||||
|
printerName
|
||||||
|
})
|
||||||
|
|
||||||
|
const printedCount =
|
||||||
|
typeof printResult?.printed_count === 'number' ? printResult.printed_count : items.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
nextStatus: 'completed',
|
||||||
|
message: `已发送到 ${printerName} 打印,准备输出 ${printedCount} 张资产标签。`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
nextStatus: 'failed',
|
||||||
|
errorMessage: error instanceof Error ? error.message : '资产标签打印失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
nextStatus: 'completed',
|
||||||
|
message: '当前任务类型未实现,已跳过。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async runTestPrint({ selectedPrinterName }) {
|
||||||
|
const printerName = normalizePrinterName(selectedPrinterName)
|
||||||
|
if (!printerName) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: '尚未选择标签打印机,请先保存打印机后再做测试打印。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await printTestLabel({
|
||||||
|
printerName
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: createTestPrintSuccessMessage(printerName)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : '测试打印失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user