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,
|
||||
syncMountedChildrenState
|
||||
} from '../utils/assetValue'
|
||||
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
|
||||
import {
|
||||
normalizeReturnDepartmentInput,
|
||||
resolveBorrowDepartmentUpdate
|
||||
} from '../utils/borrowDepartment'
|
||||
import { buildBusinessDepartmentWhere } from '../utils/organization'
|
||||
import { logOperation } from './operationLogController'
|
||||
|
||||
const parseNumber = (value: any): number | undefined => {
|
||||
const num = Number(value)
|
||||
@ -18,6 +25,18 @@ const parseDate = (value?: any): Date | null => {
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
const parseBorrowAnomalyBorrowTime = (value?: any): Date | null => {
|
||||
if (!value || typeof value !== 'string') return null
|
||||
const normalizedValue = value.trim()
|
||||
if (!normalizedValue) return null
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalizedValue)) {
|
||||
return parseDate(`${normalizedValue}T00:00:00`)
|
||||
}
|
||||
|
||||
return parseDate(normalizedValue)
|
||||
}
|
||||
|
||||
const equipmentInclude = {
|
||||
storageLocation: {
|
||||
select: {
|
||||
@ -38,6 +57,20 @@ const personInclude = {
|
||||
}
|
||||
}
|
||||
|
||||
const buildReturnAssetKeywordWhere = (keyword: string) => [
|
||||
{ equipment_code: { contains: keyword } },
|
||||
{ equipment_name: { contains: keyword } },
|
||||
{ equipment_model: { contains: keyword } },
|
||||
{ equipment_type: { contains: keyword } },
|
||||
{
|
||||
storageLocation: {
|
||||
is: {
|
||||
name: { contains: keyword }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: string) => {
|
||||
if (equipmentId) {
|
||||
return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude })
|
||||
@ -48,6 +81,62 @@ const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: st
|
||||
return null
|
||||
}
|
||||
|
||||
const getBorrowAnomalyMetaFromEquipment = (equipment: {
|
||||
status?: string | null
|
||||
total_quantity?: number | null
|
||||
parent_id?: number | null
|
||||
}, activeBorrowCount: number) =>
|
||||
getBorrowAnomalyMeta({
|
||||
status: equipment.status,
|
||||
activeBorrowCount,
|
||||
totalQuantity: equipment.total_quantity,
|
||||
parentId: equipment.parent_id
|
||||
})
|
||||
|
||||
const loadBorrowAnomalyTarget = async (equipmentId: number) => {
|
||||
const equipment = await getEquipmentByIdentifier(equipmentId)
|
||||
|
||||
if (!equipment) {
|
||||
throw new Error('资产不存在')
|
||||
}
|
||||
|
||||
const activeBorrowCount = await prisma.borrowRecord.count({
|
||||
where: {
|
||||
equipment_id: equipment.id,
|
||||
status: '已借出'
|
||||
}
|
||||
})
|
||||
|
||||
const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount)
|
||||
|
||||
return {
|
||||
equipment,
|
||||
activeBorrowCount,
|
||||
anomalyMeta
|
||||
}
|
||||
}
|
||||
|
||||
const ensureBorrowAnomalyHandleable = (
|
||||
equipment: {
|
||||
status?: string | null
|
||||
total_quantity?: number | null
|
||||
parent_id?: number | null
|
||||
},
|
||||
activeBorrowCount: number
|
||||
) => {
|
||||
const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount)
|
||||
|
||||
if (anomalyMeta.state !== 'borrow_record_missing') {
|
||||
throw new Error('该资产当前不存在借用单缺失问题')
|
||||
}
|
||||
|
||||
if (!anomalyMeta.canHandle) {
|
||||
throw new Error(anomalyMeta.blockedReason || '该资产当前不支持异常借出处理')
|
||||
}
|
||||
|
||||
return anomalyMeta
|
||||
}
|
||||
|
||||
const ensurePersonAvailable = async (personId?: number) => {
|
||||
if (!personId) {
|
||||
throw new Error('使用者ID不能为空')
|
||||
@ -59,6 +148,46 @@ const ensurePersonAvailable = async (personId?: number) => {
|
||||
return person
|
||||
}
|
||||
|
||||
const ensureBorrowAnomalyBorrowTime = (value?: any) => {
|
||||
const borrowTime = parseBorrowAnomalyBorrowTime(value)
|
||||
if (!borrowTime) {
|
||||
throw new Error('借出日期不能为空')
|
||||
}
|
||||
|
||||
if (borrowTime.getTime() > Date.now()) {
|
||||
throw new Error('借出日期不能晚于当前时间')
|
||||
}
|
||||
|
||||
return borrowTime
|
||||
}
|
||||
|
||||
const validateReturnDepartmentUpdate = async (input: Record<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) => {
|
||||
if (equipment.parent_id) {
|
||||
throw new Error('该子资产当前已挂载,请先卸载后再借用')
|
||||
@ -134,6 +263,7 @@ const borrowEquipmentInTransaction = async (
|
||||
|
||||
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
|
||||
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
|
||||
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
|
||||
const values = calculateEquipmentValues({
|
||||
unitPrice: Number(equipment.unit_price),
|
||||
totalQuantity: Number(equipment.total_quantity),
|
||||
@ -151,7 +281,8 @@ const borrowEquipmentInTransaction = async (
|
||||
total_value: values.total_value,
|
||||
borrowed_value: values.borrowed_value,
|
||||
available_value: values.available_value,
|
||||
status: nextStatus
|
||||
status: nextStatus,
|
||||
...(departmentUpdateData || {})
|
||||
}
|
||||
})
|
||||
|
||||
@ -246,7 +377,10 @@ const returnEquipmentInTransaction = async (
|
||||
equipment: any,
|
||||
borrowRecord: any,
|
||||
returnCondition: string,
|
||||
notes?: string
|
||||
notes?: string,
|
||||
equipmentUpdateData?: {
|
||||
department_id: number | null
|
||||
} | null
|
||||
) => {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const returnTime = new Date()
|
||||
@ -287,7 +421,8 @@ const returnEquipmentInTransaction = async (
|
||||
total_value: values.total_value,
|
||||
borrowed_value: values.borrowed_value,
|
||||
available_value: values.available_value,
|
||||
status: '可用'
|
||||
status: '可用',
|
||||
...(equipmentUpdateData || {})
|
||||
}
|
||||
})
|
||||
|
||||
@ -306,6 +441,89 @@ const returnEquipmentInTransaction = async (
|
||||
})
|
||||
}
|
||||
|
||||
const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any) => {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nextRemainingQuantity = Number(equipment.total_quantity)
|
||||
const values = calculateEquipmentValues({
|
||||
unitPrice: Number(equipment.unit_price),
|
||||
totalQuantity: Number(equipment.total_quantity),
|
||||
remainingQuantity: nextRemainingQuantity,
|
||||
status: '可用'
|
||||
})
|
||||
|
||||
const updatedEquipment = await tx.equipment.update({
|
||||
where: { id: equipment.id },
|
||||
data: {
|
||||
status: '可用',
|
||||
remaining_quantity: nextRemainingQuantity,
|
||||
total_value: values.total_value,
|
||||
borrowed_value: values.borrowed_value,
|
||||
available_value: values.available_value
|
||||
},
|
||||
include: equipmentInclude
|
||||
})
|
||||
|
||||
await syncMountedChildrenState(tx, equipment.id, '可用')
|
||||
|
||||
return updatedEquipment
|
||||
})
|
||||
}
|
||||
|
||||
const registerBorrowAnomalyInTransaction = async (
|
||||
equipment: any,
|
||||
person: any,
|
||||
borrowTime: Date,
|
||||
notes?: string
|
||||
) => {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const { children, snapshot } = await getChildrenSnapshot(equipment.id)
|
||||
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
|
||||
const nextRemainingQuantity = Math.max(Number(equipment.total_quantity) - 1, 0)
|
||||
const values = calculateEquipmentValues({
|
||||
unitPrice: Number(equipment.unit_price),
|
||||
totalQuantity: Number(equipment.total_quantity),
|
||||
remainingQuantity: nextRemainingQuantity,
|
||||
status: '已借出'
|
||||
})
|
||||
|
||||
const record = await tx.borrowRecord.create({
|
||||
data: {
|
||||
equipment_id: equipment.id,
|
||||
equipment_code: equipment.equipment_code,
|
||||
person_id: person.id,
|
||||
person_name: person.real_name,
|
||||
person_nickname: person.nickname,
|
||||
person_identifier: person.id_number,
|
||||
borrow_time: borrowTime,
|
||||
notes: notes?.trim() || null,
|
||||
status: '已借出',
|
||||
children_snapshot: snapshot
|
||||
}
|
||||
})
|
||||
|
||||
const updatedEquipment = await tx.equipment.update({
|
||||
where: { id: equipment.id },
|
||||
data: {
|
||||
status: '已借出',
|
||||
remaining_quantity: nextRemainingQuantity,
|
||||
total_value: values.total_value,
|
||||
borrowed_value: values.borrowed_value,
|
||||
available_value: values.available_value,
|
||||
...(departmentUpdateData || {})
|
||||
},
|
||||
include: equipmentInclude
|
||||
})
|
||||
|
||||
await syncMountedChildrenState(tx, equipment.id, '已借出')
|
||||
|
||||
return {
|
||||
record,
|
||||
updatedEquipment,
|
||||
childrenCount: children.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mapBorrowRecord = (record: any) => ({
|
||||
id: record.id,
|
||||
equipment_id: record.equipment_id,
|
||||
@ -320,7 +538,8 @@ const mapBorrowRecord = (record: any) => ({
|
||||
real_name: record.person.real_name,
|
||||
department: record.person.mainDepartment?.name || record.person.department,
|
||||
main_department: record.person.mainDepartment || null,
|
||||
phone: record.person.phone
|
||||
phone: record.person.phone,
|
||||
status: record.person.status
|
||||
}
|
||||
: {
|
||||
id: record.person_id,
|
||||
@ -568,6 +787,13 @@ export const returnEquipment = async (req: Request, res: Response) => {
|
||||
confirm_children_diff
|
||||
} = req.body as Record<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 equipment = await getEquipmentByIdentifier(equipmentIdNum, equipment_code)
|
||||
|
||||
@ -605,7 +831,13 @@ export const returnEquipment = async (req: Request, res: Response) => {
|
||||
.join(';')
|
||||
: notes
|
||||
|
||||
await returnEquipmentInTransaction(equipment, borrowRecord, return_condition, finalNotes)
|
||||
await returnEquipmentInTransaction(
|
||||
equipment,
|
||||
borrowRecord,
|
||||
return_condition,
|
||||
finalNotes,
|
||||
departmentUpdate.equipmentUpdateData
|
||||
)
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
@ -639,6 +871,13 @@ export const scanReturn = async (req: Request, res: Response) => {
|
||||
confirm_children_diff
|
||||
} = req.body as Record<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)
|
||||
|
||||
if (!equipment) {
|
||||
@ -679,7 +918,8 @@ export const scanReturn = async (req: Request, res: Response) => {
|
||||
equipment,
|
||||
borrowRecord,
|
||||
return_condition,
|
||||
finalNotes
|
||||
finalNotes,
|
||||
departmentUpdate.equipmentUpdateData
|
||||
)
|
||||
|
||||
successResponse(
|
||||
@ -834,6 +1074,15 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => {
|
||||
})
|
||||
|
||||
if (borrowRecords.length === 0) {
|
||||
const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, 0)
|
||||
if (anomalyMeta.state === 'borrow_record_missing') {
|
||||
const suffix = anomalyMeta.blockedReason ? `;${anomalyMeta.blockedReason}` : ''
|
||||
return errorResponse(
|
||||
res,
|
||||
`该资产当前标记为已借出,但系统里没有对应借用记录,无法直接归还,请先核对。${suffix}`,
|
||||
409
|
||||
)
|
||||
}
|
||||
return errorResponse(res, '未找到对应的借用记录', 404)
|
||||
}
|
||||
|
||||
@ -944,3 +1193,241 @@ export const getBorrowRecordList = async (req: Request, res: Response) => {
|
||||
errorResponse(res, error.message || '获取借用记录列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索可归还资产
|
||||
export const searchReturnAssets = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const keyword = typeof req.query.keyword === 'string' ? req.query.keyword.trim() : ''
|
||||
|
||||
const equipmentList = await prisma.equipment.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ status: '已借出' },
|
||||
{
|
||||
borrow_records: {
|
||||
some: {
|
||||
status: '已借出'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
...(keyword
|
||||
? [
|
||||
{
|
||||
OR: buildReturnAssetKeywordWhere(keyword)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
equipment_code: true,
|
||||
equipment_name: true,
|
||||
equipment_model: true,
|
||||
equipment_type: true,
|
||||
status: true,
|
||||
total_quantity: true,
|
||||
parent_id: true,
|
||||
storage_location_id: true,
|
||||
storageLocation: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
status: true,
|
||||
}
|
||||
},
|
||||
borrow_records: {
|
||||
where: {
|
||||
status: '已借出'
|
||||
},
|
||||
orderBy: [{ borrow_time: 'desc' }, { id: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
person_name: true,
|
||||
person_nickname: true,
|
||||
person_identifier: true,
|
||||
person: {
|
||||
select: {
|
||||
department: true,
|
||||
mainDepartment: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const pushUnique = (bucket: string[], value?: string | null) => {
|
||||
const normalized = (value || '').trim()
|
||||
if (!normalized) return
|
||||
if (!bucket.includes(normalized)) {
|
||||
bucket.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
const list = equipmentList.map((equipment) => {
|
||||
const borrowerNames: string[] = []
|
||||
const borrowerIdentifiers: string[] = []
|
||||
const borrowerDepartments: string[] = []
|
||||
|
||||
for (const record of equipment.borrow_records) {
|
||||
const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ')
|
||||
const borrowerIdentifier = record.person_identifier || null
|
||||
const borrowerDepartment =
|
||||
record.person?.mainDepartment?.name || record.person?.department || null
|
||||
|
||||
pushUnique(borrowerNames, borrowerName)
|
||||
pushUnique(borrowerIdentifiers, borrowerIdentifier)
|
||||
pushUnique(borrowerDepartments, borrowerDepartment)
|
||||
}
|
||||
|
||||
const activeBorrowCount = equipment.borrow_records.length
|
||||
const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount)
|
||||
const searchState = anomalyMeta.state === 'borrow_record_missing' ? 'borrow_record_missing' : 'returnable'
|
||||
|
||||
return {
|
||||
equipment_id: equipment.id,
|
||||
equipment_code: equipment.equipment_code,
|
||||
equipment_name: equipment.equipment_name,
|
||||
equipment_model: equipment.equipment_model,
|
||||
equipment_type: equipment.equipment_type,
|
||||
storage_location_id: equipment.storage_location_id,
|
||||
storageLocation: equipment.storageLocation,
|
||||
active_borrow_count: activeBorrowCount,
|
||||
borrower_names: borrowerNames,
|
||||
borrower_identifiers: borrowerIdentifiers,
|
||||
borrower_departments: borrowerDepartments,
|
||||
can_return: activeBorrowCount > 0,
|
||||
can_handle_borrow_anomaly: anomalyMeta.canHandle,
|
||||
borrow_anomaly_blocked_reason: anomalyMeta.blockedReason,
|
||||
search_state: searchState,
|
||||
state_message: searchState === 'returnable' ? '可直接归还' : anomalyMeta.message
|
||||
}
|
||||
})
|
||||
|
||||
successResponse(res, list, '获取成功')
|
||||
} catch (error: any) {
|
||||
errorResponse(res, error.message || '搜索可归还资产失败')
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveBorrowAnomalyAsReturned = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const equipmentId = parseNumber(req.params.equipment_id)
|
||||
if (!equipmentId) {
|
||||
return errorResponse(res, '资产ID无效', 422)
|
||||
}
|
||||
|
||||
const { equipment, activeBorrowCount } = await loadBorrowAnomalyTarget(equipmentId)
|
||||
|
||||
try {
|
||||
ensureBorrowAnomalyHandleable(equipment, activeBorrowCount)
|
||||
} catch (error: any) {
|
||||
return errorResponse(res, error.message || '该资产当前不支持异常借出处理', 422)
|
||||
}
|
||||
|
||||
const updatedEquipment = await resolveBorrowAnomalyReturnedInTransaction(equipment)
|
||||
const userId = res.locals.user?.id || null
|
||||
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||
await logOperation(
|
||||
userId,
|
||||
username,
|
||||
'borrow_anomaly_mark_returned',
|
||||
'异常借出处理:确认已归还,系统补齐为可用状态',
|
||||
equipment.id,
|
||||
equipment.equipment_code,
|
||||
req.ip
|
||||
)
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
equipment_id: updatedEquipment.id,
|
||||
equipment_code: updatedEquipment.equipment_code,
|
||||
equipment_name: updatedEquipment.equipment_name,
|
||||
status: updatedEquipment.status,
|
||||
remaining_quantity: updatedEquipment.remaining_quantity
|
||||
},
|
||||
'已按异常借出处理为归还完成'
|
||||
)
|
||||
} catch (error: any) {
|
||||
errorResponse(res, error.message || '异常借出归还处理失败')
|
||||
}
|
||||
}
|
||||
|
||||
export const registerBorrowAnomaly = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const equipmentId = parseNumber(req.params.equipment_id)
|
||||
if (!equipmentId) {
|
||||
return errorResponse(res, '资产ID无效', 422)
|
||||
}
|
||||
|
||||
const { person_id, borrow_time, notes } = req.body as Record<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,
|
||||
syncMountedChildrenState
|
||||
} from '../utils/assetValue'
|
||||
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
|
||||
import { getEquipmentDeleteBlockedReason } from '../utils/equipmentDeletion'
|
||||
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
|
||||
|
||||
type EquipmentEntryMode = 'single' | 'parent' | 'child'
|
||||
@ -163,7 +165,13 @@ const serializeEquipmentListItem = (equipment: any) => {
|
||||
return {
|
||||
...restEquipment,
|
||||
children,
|
||||
current_borrow: mapCurrentBorrow(borrow_records)
|
||||
current_borrow: mapCurrentBorrow(borrow_records),
|
||||
borrow_anomaly: getBorrowAnomalyMeta({
|
||||
status: restEquipment.status,
|
||||
activeBorrowCount: Array.isArray(borrow_records) ? borrow_records.length : 0,
|
||||
totalQuantity: restEquipment.total_quantity,
|
||||
parentId: restEquipment.parent_id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -894,7 +902,8 @@ export const deleteEquipment = async (req: Request, res: Response) => {
|
||||
children: true,
|
||||
mountHistories: true,
|
||||
disposalRecords: true,
|
||||
maintenanceRecords: true
|
||||
maintenanceRecords: true,
|
||||
inventoryTaskItems: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -904,37 +913,21 @@ export const deleteEquipment = async (req: Request, res: Response) => {
|
||||
return errorResponse(res, '资产不存在', 404)
|
||||
}
|
||||
|
||||
// 检查是否有未归还的借用记录
|
||||
const activeBorrowCount = await prisma.borrowRecord.count({
|
||||
where: {
|
||||
equipment_id: equipmentId,
|
||||
status: '已借出'
|
||||
}
|
||||
const blockedReason = getEquipmentDeleteBlockedReason({
|
||||
borrowRecordCount: equipment._count.borrow_records,
|
||||
isMountedChild: Boolean(equipment.parent_id),
|
||||
childCount: equipment._count.children,
|
||||
mountHistoryCount: equipment._count.mountHistories,
|
||||
disposalRecordCount: equipment._count.disposalRecords,
|
||||
maintenanceRecordCount: equipment._count.maintenanceRecords,
|
||||
inventoryTaskItemCount: equipment._count.inventoryTaskItems
|
||||
})
|
||||
|
||||
if (activeBorrowCount > 0) {
|
||||
return errorResponse(res, '该资产存在未归还的借用记录,无法删除')
|
||||
}
|
||||
|
||||
if (equipment.parent_id) {
|
||||
return errorResponse(res, '该资产当前作为子资产挂载中,无法删除')
|
||||
}
|
||||
|
||||
if (equipment._count.children > 0) {
|
||||
return errorResponse(res, '该资产存在已挂载的子资产,无法删除')
|
||||
}
|
||||
|
||||
if (equipment._count.mountHistories > 0 || equipment._count.disposalRecords > 0 || equipment._count.maintenanceRecords > 0) {
|
||||
return errorResponse(res, '该资产已存在维修、报废或挂载历史,无法删除')
|
||||
if (blockedReason) {
|
||||
return errorResponse(res, blockedReason)
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.borrowRecord.deleteMany({
|
||||
where: {
|
||||
equipment_id: equipmentId
|
||||
}
|
||||
})
|
||||
|
||||
await tx.equipment.delete({
|
||||
where: { id: equipmentId }
|
||||
})
|
||||
|
||||
@ -4,11 +4,13 @@ import {
|
||||
assignUnassignedTaskItem,
|
||||
closeInventoryTask,
|
||||
createInventoryTask,
|
||||
deleteInventoryTask,
|
||||
getInventoryTaskDetail,
|
||||
getTaskUnassignedItems,
|
||||
listInventoryTasks,
|
||||
previewInventoryTask
|
||||
} from '../services/inventoryService'
|
||||
import { normalizeInventoryScopeInput } from '../utils/inventoryScope'
|
||||
|
||||
const toOptionalNumber = (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
@ -18,10 +20,15 @@ const toOptionalNumber = (value: unknown) => {
|
||||
|
||||
export const previewInventoryTaskController = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await previewInventoryTask({
|
||||
departmentId: toOptionalNumber(req.body?.department_id),
|
||||
assetTypeId: toOptionalNumber(req.body?.asset_type_id)
|
||||
const normalizedScope = normalizeInventoryScopeInput({
|
||||
department_id: toOptionalNumber(req.body?.department_id),
|
||||
department_ids: req.body?.department_ids,
|
||||
asset_type_id: toOptionalNumber(req.body?.asset_type_id),
|
||||
asset_type_ids: req.body?.asset_type_ids,
|
||||
storage_location_id: toOptionalNumber(req.body?.storage_location_id),
|
||||
storage_location_ids: req.body?.storage_location_ids
|
||||
})
|
||||
const result = await previewInventoryTask(normalizedScope)
|
||||
successResponse(res, result, '预览成功')
|
||||
} catch (error: any) {
|
||||
errorResponse(res, error.message || '预览盘点任务失败', 422)
|
||||
@ -35,12 +42,22 @@ export const createInventoryTaskController = async (req: Request, res: Response)
|
||||
return errorResponse(res, '未获取到当前登录用户', 401)
|
||||
}
|
||||
|
||||
const normalizedScope = normalizeInventoryScopeInput({
|
||||
department_id: toOptionalNumber(req.body?.department_id),
|
||||
department_ids: req.body?.department_ids,
|
||||
asset_type_id: toOptionalNumber(req.body?.asset_type_id),
|
||||
asset_type_ids: req.body?.asset_type_ids,
|
||||
storage_location_id: toOptionalNumber(req.body?.storage_location_id),
|
||||
storage_location_ids: req.body?.storage_location_ids
|
||||
})
|
||||
|
||||
const result = await createInventoryTask(
|
||||
{
|
||||
title: req.body?.title,
|
||||
description: req.body?.description,
|
||||
department_id: toOptionalNumber(req.body?.department_id),
|
||||
asset_type_id: toOptionalNumber(req.body?.asset_type_id)
|
||||
department_ids: normalizedScope.departmentIds,
|
||||
asset_type_ids: normalizedScope.assetTypeIds,
|
||||
storage_location_ids: normalizedScope.storageLocationIds
|
||||
},
|
||||
currentUserId
|
||||
)
|
||||
@ -96,6 +113,24 @@ export const closeInventoryTaskController = async (req: Request, res: Response)
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteInventoryTaskController = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const taskId = Number(req.params.id)
|
||||
if (!Number.isInteger(taskId) || taskId <= 0) {
|
||||
return errorResponse(res, '盘点任务不存在', 404)
|
||||
}
|
||||
|
||||
await deleteInventoryTask(taskId)
|
||||
successResponse(res, null, '任务已删除')
|
||||
} catch (error: any) {
|
||||
const message = error.message || '删除盘点任务失败'
|
||||
if (message.includes('不存在')) {
|
||||
return errorResponse(res, message, 404)
|
||||
}
|
||||
errorResponse(res, message, 422)
|
||||
}
|
||||
}
|
||||
|
||||
export const getInventoryTaskUnassignedController = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const taskId = Number(req.params.id)
|
||||
|
||||
@ -16,6 +16,23 @@ const getCurrentUserId = (res: Response) => {
|
||||
return Number.isNaN(userId) ? null : userId
|
||||
}
|
||||
|
||||
const getCurrentUserRole = (res: Response) => {
|
||||
const role = res.locals.user?.role
|
||||
return typeof role === 'string' ? role : null
|
||||
}
|
||||
|
||||
const ensureAdminNotificationInbox = async (userId: number, role: string | null) => {
|
||||
if (role !== 'admin') {
|
||||
return
|
||||
}
|
||||
|
||||
await prisma.$executeRaw`
|
||||
INSERT IGNORE INTO user_notification (user_id, notification_id)
|
||||
SELECT ${userId}, n.id
|
||||
FROM notification n
|
||||
`
|
||||
}
|
||||
|
||||
const sanitizeNotification = (item: any) => ({
|
||||
id: item.id,
|
||||
is_read: item.is_read,
|
||||
@ -38,6 +55,7 @@ export const getNotificationList = async (req: Request, res: Response) => {
|
||||
if (!userId) {
|
||||
return errorResponse(res, '未获取到当前用户信息', 401)
|
||||
}
|
||||
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
|
||||
|
||||
const { page = 1, pageSize = 10, status = 'all' } = req.query
|
||||
const where: any = { user_id: userId }
|
||||
@ -89,6 +107,7 @@ export const getUnreadNotificationCount = async (_req: Request, res: Response) =
|
||||
if (!userId) {
|
||||
return errorResponse(res, '未获取到当前用户信息', 401)
|
||||
}
|
||||
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
|
||||
|
||||
const count = await prisma.userNotification.count({
|
||||
where: {
|
||||
@ -109,6 +128,7 @@ export const markNotificationAsRead = async (req: Request, res: Response) => {
|
||||
if (!userId) {
|
||||
return errorResponse(res, '未获取到当前用户信息', 401)
|
||||
}
|
||||
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
|
||||
|
||||
const id = Number(req.params.id)
|
||||
const existing = await prisma.userNotification.findFirst({
|
||||
@ -148,6 +168,7 @@ export const markAllNotificationsAsRead = async (_req: Request, res: Response) =
|
||||
if (!userId) {
|
||||
return errorResponse(res, '未获取到当前用户信息', 401)
|
||||
}
|
||||
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
|
||||
|
||||
const result = await prisma.userNotification.updateMany({
|
||||
where: {
|
||||
|
||||
@ -108,7 +108,7 @@ export const createAssetLabelPrintJob = async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createAssetLabelPrintJobService(req.body?.equipment_ids, userId)
|
||||
const result = await createAssetLabelPrintJobService(req.body, userId)
|
||||
successResponse(res, result.job, result.queue_notice)
|
||||
} catch (error) {
|
||||
handlePrintServiceError(res, error, '创建打印任务失败')
|
||||
|
||||
@ -43,7 +43,7 @@ const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secre
|
||||
|
||||
// Middleware
|
||||
app.use(cors({ origin: true, credentials: true }))
|
||||
app.use(express.json())
|
||||
app.use(express.json({ limit: '30mb' }))
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(
|
||||
session({
|
||||
|
||||
@ -36,10 +36,12 @@ router.get('/user', (req, res) => {
|
||||
|
||||
// Keycloak 配置信息(前端使用)
|
||||
router.get('/config', (req, res) => {
|
||||
const authMode = process.env.AUTH_MODE === 'keycloak' ? 'keycloak' : 'local'
|
||||
successResponse(res, {
|
||||
url: process.env.KEYCLOAK_URL || 'http://localhost:8080',
|
||||
realm: process.env.KEYCLOAK_REALM || 'equipment-management',
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client'
|
||||
authMode,
|
||||
url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '',
|
||||
realm: authMode === 'keycloak' ? (process.env.KEYCLOAK_REALM || 'equipment-management') : '',
|
||||
clientId: authMode === 'keycloak' ? (process.env.KEYCLOAK_CLIENT_ID || 'equipment-client') : ''
|
||||
}, '获取成功')
|
||||
})
|
||||
|
||||
@ -114,4 +116,3 @@ router.post('/local/login', async (req: Request, res: Response) => {
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@ import {
|
||||
borrowEquipment,
|
||||
scanBorrow,
|
||||
scanBorrowBatch,
|
||||
resolveBorrowAnomalyAsReturned,
|
||||
registerBorrowAnomaly,
|
||||
getBorrowRecordByCode,
|
||||
getBorrowRecordList
|
||||
} from '../controllers/borrowController'
|
||||
@ -13,6 +15,8 @@ const router = Router()
|
||||
router.post('/', borrowEquipment)
|
||||
router.post('/scan', scanBorrow)
|
||||
router.post('/scan-batch', scanBorrowBatch)
|
||||
router.post('/anomalies/:equipment_id/mark-returned', resolveBorrowAnomalyAsReturned)
|
||||
router.post('/anomalies/:equipment_id/register-borrow', registerBorrowAnomaly)
|
||||
router.get('/scan/:equipment_code', getBorrowRecordByCode)
|
||||
router.get('/records', getBorrowRecordList)
|
||||
|
||||
@ -24,9 +28,11 @@ export const returnRouter = Router()
|
||||
import {
|
||||
returnEquipment,
|
||||
scanReturn,
|
||||
scanReturnBatch
|
||||
scanReturnBatch,
|
||||
searchReturnAssets
|
||||
} from '../controllers/borrowController'
|
||||
|
||||
returnRouter.post('/', returnEquipment)
|
||||
returnRouter.post('/scan', scanReturn)
|
||||
returnRouter.post('/scan-batch', scanReturnBatch)
|
||||
returnRouter.get('/search-assets', searchReturnAssets)
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
assignInventoryTaskUnassignedController,
|
||||
closeInventoryTaskController,
|
||||
createInventoryTaskController,
|
||||
deleteInventoryTaskController,
|
||||
getInventoryTaskDetailController,
|
||||
getInventoryTaskListController,
|
||||
getInventoryTaskUnassignedController,
|
||||
@ -19,6 +20,7 @@ router.post('/tasks', createInventoryTaskController)
|
||||
router.get('/tasks', getInventoryTaskListController)
|
||||
router.get('/tasks/:id', getInventoryTaskDetailController)
|
||||
router.post('/tasks/:id/close', closeInventoryTaskController)
|
||||
router.delete('/tasks/:id', deleteInventoryTaskController)
|
||||
router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController)
|
||||
router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController)
|
||||
|
||||
|
||||
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 assert from 'node:assert/strict'
|
||||
import {
|
||||
getPrintTerminalAdminStatusChangeError,
|
||||
resolveEffectiveTerminalStatus,
|
||||
normalizeLabelProfile,
|
||||
pickNextClaimablePrintJob,
|
||||
resolveTerminalReadiness
|
||||
resolveTerminalReadiness,
|
||||
sanitizeRenderedLabelImages,
|
||||
stripRenderedLabelData
|
||||
} from '../printService'
|
||||
|
||||
test('normalizeLabelProfile 应回填 60x40 与默认方向', () => {
|
||||
@ -142,3 +145,86 @@ test('resolveEffectiveTerminalStatus 对手动设为 offline 的终端保持 off
|
||||
|
||||
assert.equal(status, 'offline')
|
||||
})
|
||||
|
||||
test('getPrintTerminalAdminStatusChangeError 允许默认终端被直接停用', () => {
|
||||
const error = getPrintTerminalAdminStatusChangeError(
|
||||
{
|
||||
is_default: true
|
||||
},
|
||||
'disabled'
|
||||
)
|
||||
|
||||
assert.equal(error, null)
|
||||
})
|
||||
|
||||
test('sanitizeRenderedLabelImages 会按 equipment_ids 顺序重排网页生成的 PNG 标签', () => {
|
||||
const renderedLabels = sanitizeRenderedLabelImages(
|
||||
[
|
||||
{
|
||||
equipment_id: 2,
|
||||
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0y'
|
||||
},
|
||||
{
|
||||
equipment_id: 1,
|
||||
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
|
||||
}
|
||||
],
|
||||
[1, 2]
|
||||
)
|
||||
|
||||
assert.deepEqual(renderedLabels, [
|
||||
{
|
||||
equipment_id: 1,
|
||||
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
|
||||
},
|
||||
{
|
||||
equipment_id: 2,
|
||||
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0y'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('sanitizeRenderedLabelImages 在缺少网页成品图时直接报错,避免错误打印旧版布局', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
sanitizeRenderedLabelImages(
|
||||
[
|
||||
{
|
||||
equipment_id: 1,
|
||||
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
|
||||
}
|
||||
],
|
||||
[1, 2]
|
||||
),
|
||||
/成品标签图/
|
||||
)
|
||||
})
|
||||
|
||||
test('stripRenderedLabelData 不向管理页返回大图或内部存储引用', () => {
|
||||
const payload = stripRenderedLabelData({
|
||||
job_type: 'asset_label',
|
||||
generated_at: '2026-03-26T09:00:00.000Z',
|
||||
equipment_ids: [1],
|
||||
items: [],
|
||||
rendered_labels: [
|
||||
{
|
||||
equipment_id: 1,
|
||||
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
|
||||
}
|
||||
],
|
||||
rendered_label_files: [
|
||||
{
|
||||
equipment_id: 1,
|
||||
storage_key: '20260326/test/1.png',
|
||||
content_type: 'image/png'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
job_type: 'asset_label',
|
||||
generated_at: '2026-03-26T09:00:00.000Z',
|
||||
equipment_ids: [1],
|
||||
items: []
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
buildSyncedBusinessDepartmentWhere
|
||||
} from '../utils/organization'
|
||||
import {
|
||||
buildHrSyncPreviewLine,
|
||||
resolveHrSyncPersonDisplayName,
|
||||
resolveSourcePersonStatus,
|
||||
type OpenBorrowAssetPreview
|
||||
} from './hrSyncHelpers'
|
||||
|
||||
type SourceDepartment = {
|
||||
sourceGroupId: number | null
|
||||
@ -147,6 +153,7 @@ const REQUIRED_SQLSERVER_FIELDS = [
|
||||
'SQLSERVER_USER',
|
||||
'SQLSERVER_PASSWORD'
|
||||
] as const
|
||||
const DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE = 'Asia/Shanghai'
|
||||
|
||||
const departmentCollator = new Intl.Collator('zh-CN', {
|
||||
numeric: true,
|
||||
@ -181,9 +188,6 @@ const getConfiguredSet = (value: string, defaultValue: string) =>
|
||||
const getFormalStatusValues = () =>
|
||||
getConfiguredSet(process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', '3')
|
||||
|
||||
const getInactiveStatusValues = () =>
|
||||
getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '5')
|
||||
|
||||
const getExcludedSourceDepartmentNames = () =>
|
||||
getConfiguredSet(
|
||||
process.env.HR_SYNC_EXCLUDED_DEPARTMENT_NAMES || process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '',
|
||||
@ -192,6 +196,61 @@ const getExcludedSourceDepartmentNames = () =>
|
||||
|
||||
const getHrSyncSourceMode = (): HrSyncSourceMode => 'register_primary'
|
||||
|
||||
const isValidTimeZone = (timeZone: string) => {
|
||||
try {
|
||||
new Intl.DateTimeFormat('zh-CN', { timeZone }).format(new Date())
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const getConfiguredScheduleTimezone = () => {
|
||||
const configuredTimezone = (process.env.HR_SYNC_TIMEZONE || '').trim()
|
||||
if (!configuredTimezone) {
|
||||
return DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE
|
||||
}
|
||||
|
||||
if (isValidTimeZone(configuredTimezone)) {
|
||||
return configuredTimezone
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[hr-sync] invalid HR_SYNC_TIMEZONE=${configuredTimezone}, fallback=${DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE}`
|
||||
)
|
||||
return DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE
|
||||
}
|
||||
|
||||
const getCurrentClockInTimeZone = (date: Date, timeZone: string) => {
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
})
|
||||
const partMap = new Map<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 = () => {
|
||||
const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => {
|
||||
const value = process.env[field]
|
||||
@ -202,7 +261,8 @@ export const getHrSyncConfigStatus = () => {
|
||||
enabled: process.env.HR_SYNC_ENABLED === 'true',
|
||||
complete: missingFields.length === 0,
|
||||
missingFields,
|
||||
scheduleTimes: getConfiguredScheduleTimes()
|
||||
scheduleTimes: getConfiguredScheduleTimes(),
|
||||
scheduleTimezone: getConfiguredScheduleTimezone()
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,8 +318,8 @@ const formatDepartmentName = (value: string | null | undefined) => value || '未
|
||||
const inferTriggerMode = (operator: string): SyncTriggerMode =>
|
||||
operator.includes('scheduler') ? 'scheduler' : 'manual'
|
||||
|
||||
const getConfiguredScheduleTimes = () =>
|
||||
Array.from(
|
||||
const getConfiguredScheduleTimes = () => {
|
||||
const scheduleTimes = Array.from(
|
||||
new Set(
|
||||
(process.env.HR_SYNC_SCHEDULES || process.env.HR_SYNC_SCHEDULE || '03:00')
|
||||
.split(',')
|
||||
@ -268,6 +328,9 @@ const getConfiguredScheduleTimes = () =>
|
||||
)
|
||||
)
|
||||
|
||||
return scheduleTimes.length > 0 ? scheduleTimes : ['03:00']
|
||||
}
|
||||
|
||||
const normalizePhone = (value: unknown) => {
|
||||
const text = safeTrim(value)
|
||||
if (!text) return null
|
||||
@ -293,16 +356,8 @@ const parseExtensionFlowerName = (extension: unknown) => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' => {
|
||||
const inactiveStatusValues = getInactiveStatusValues()
|
||||
const sourceStatus = safeTrim(employeeStatus)
|
||||
|
||||
if (sourceStatus && inactiveStatusValues.has(sourceStatus)) {
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
return 'active'
|
||||
}
|
||||
const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' =>
|
||||
resolveSourcePersonStatus(safeTrim(employeeStatus))
|
||||
|
||||
const resolveDepartmentBinding = (
|
||||
sourceDepartmentName: string | null,
|
||||
@ -720,6 +775,45 @@ const getPersonSnapshotMap = async () => {
|
||||
)
|
||||
}
|
||||
|
||||
const getOpenBorrowAssetMap = async () => {
|
||||
const borrowRecords = await prisma.borrowRecord.findMany({
|
||||
where: {
|
||||
return_time: null
|
||||
},
|
||||
orderBy: [
|
||||
{ person_id: 'asc' },
|
||||
{ equipment_code: 'asc' }
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
equipment_id: true,
|
||||
person_id: true,
|
||||
equipment_code: true,
|
||||
equipment: {
|
||||
select: {
|
||||
equipment_name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const assetMap = new Map<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 diffDepartmentSnapshots = (
|
||||
@ -779,7 +873,8 @@ const diffDepartmentSnapshots = (
|
||||
|
||||
const diffPersonSnapshots = (
|
||||
beforeMap: Map<string, PersonSnapshot>,
|
||||
afterMap: Map<string, PersonSnapshot>
|
||||
afterMap: Map<string, PersonSnapshot>,
|
||||
openBorrowAssetMap: Map<number, OpenBorrowAssetPreview[]>
|
||||
) => {
|
||||
const changes: HrSyncChangeDraft[] = []
|
||||
const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) =>
|
||||
@ -789,6 +884,8 @@ const diffPersonSnapshots = (
|
||||
for (const key of keys) {
|
||||
const before = beforeMap.get(key)
|
||||
const after = afterMap.get(key)
|
||||
const currentPerson = after || before
|
||||
const personDisplayName = resolveHrSyncPersonDisplayName(currentPerson || {})
|
||||
|
||||
if (!before && after && after.status === 'active') {
|
||||
changes.push(
|
||||
@ -797,10 +894,13 @@ const diffPersonSnapshots = (
|
||||
entityKey: key,
|
||||
entityName: after.realName,
|
||||
changeType: 'person_created',
|
||||
title: `新人入职:${after.realName}`,
|
||||
title: `新人入职:${personDisplayName}`,
|
||||
detail: `系统归属部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`,
|
||||
beforeSnapshot: null,
|
||||
afterSnapshot: after
|
||||
afterSnapshot: {
|
||||
...after,
|
||||
displayName: personDisplayName
|
||||
}
|
||||
})
|
||||
)
|
||||
continue
|
||||
@ -810,6 +910,8 @@ const diffPersonSnapshots = (
|
||||
continue
|
||||
}
|
||||
|
||||
const openBorrowAssets = openBorrowAssetMap.get(after.id || before.id) || []
|
||||
|
||||
if (before.status !== after.status) {
|
||||
changes.push(
|
||||
buildSyncChange({
|
||||
@ -817,13 +919,21 @@ const diffPersonSnapshots = (
|
||||
entityKey: key,
|
||||
entityName: after.realName,
|
||||
changeType: after.status === 'inactive' ? 'person_deactivated' : 'person_reactivated',
|
||||
title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${after.realName}`,
|
||||
title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${personDisplayName}`,
|
||||
detail:
|
||||
after.status === 'inactive'
|
||||
? '该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。'
|
||||
? `该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。${
|
||||
openBorrowAssets.length > 0
|
||||
? ` 当前名下还有 ${openBorrowAssets.length} 件待处理资产,可在下方直接快捷入库。`
|
||||
: ' 当前名下没有待处理资产。'
|
||||
}`
|
||||
: '该人员重新进入同步名单,可继续参与借还与盘点流程。',
|
||||
beforeSnapshot: before,
|
||||
afterSnapshot: after
|
||||
afterSnapshot: {
|
||||
...after,
|
||||
displayName: personDisplayName,
|
||||
assets: openBorrowAssets
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -839,10 +949,13 @@ const diffPersonSnapshots = (
|
||||
entityKey: key,
|
||||
entityName: after.realName,
|
||||
changeType: 'person_main_department_changed',
|
||||
title: `主部门变动:${after.realName}`,
|
||||
title: `主部门变动:${personDisplayName}`,
|
||||
detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName(after.mainDepartmentName)}”。`,
|
||||
beforeSnapshot: before,
|
||||
afterSnapshot: after
|
||||
afterSnapshot: {
|
||||
...after,
|
||||
displayName: personDisplayName
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -854,10 +967,13 @@ const diffPersonSnapshots = (
|
||||
entityKey: key,
|
||||
entityName: after.realName,
|
||||
changeType: 'person_departments_changed',
|
||||
title: `关联部门变动:${after.realName}`,
|
||||
title: `关联部门变动:${personDisplayName}`,
|
||||
detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList(after.departmentNames)}”。`,
|
||||
beforeSnapshot: before,
|
||||
afterSnapshot: after
|
||||
afterSnapshot: {
|
||||
...after,
|
||||
displayName: personDisplayName
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -869,10 +985,18 @@ const diffPersonSnapshots = (
|
||||
entityKey: key,
|
||||
entityName: after.realName,
|
||||
changeType: 'person_position_changed',
|
||||
title: `岗位变动:${after.realName}`,
|
||||
detail: `岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。`,
|
||||
title: `岗位变动:${personDisplayName}`,
|
||||
detail:
|
||||
`岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。` +
|
||||
(openBorrowAssets.length > 0
|
||||
? ` 当前名下还有 ${openBorrowAssets.length} 件资产,可在下方直接快捷入库。`
|
||||
: ''),
|
||||
beforeSnapshot: before,
|
||||
afterSnapshot: after
|
||||
afterSnapshot: {
|
||||
...after,
|
||||
displayName: personDisplayName,
|
||||
assets: openBorrowAssets
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -910,7 +1034,13 @@ const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary =>
|
||||
change_type: change.changeType
|
||||
}))
|
||||
|
||||
const previewText = previewChanges.map((change, index) => `${index + 1}. ${change.title}`).join('\n')
|
||||
const previewText = previewChanges
|
||||
.map((change, index) => `${index + 1}. ${buildHrSyncPreviewLine({
|
||||
changeType: change.change_type,
|
||||
title: change.title,
|
||||
detail: change.detail
|
||||
})}`)
|
||||
.join('\n')
|
||||
|
||||
return {
|
||||
title: `组织同步发现 ${changes.length} 项变化`,
|
||||
@ -981,44 +1111,41 @@ const persistHrSyncArtifacts = async ({
|
||||
})
|
||||
}
|
||||
|
||||
let notificationId: number | null = null
|
||||
if (status === 'failed' || changes.length > 0) {
|
||||
const notification = await tx.notification.create({
|
||||
data: {
|
||||
title: summary.title,
|
||||
content: summary.content,
|
||||
type: 'hr_sync',
|
||||
level: status === 'failed' ? 'error' : 'warning',
|
||||
action_url: '/settings?tab=sync',
|
||||
related_sync_job_id: syncJob.id,
|
||||
metadata: toJsonText({
|
||||
status,
|
||||
changes_count: changes.length,
|
||||
counts: summary.counts,
|
||||
preview_changes: summary.previewChanges
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
notificationId = notification.id
|
||||
|
||||
const adminUsers = await tx.user.findMany({
|
||||
where: {
|
||||
role: 'admin',
|
||||
status: 'active'
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
await tx.userNotification.createMany({
|
||||
data: adminUsers.map((user) => ({
|
||||
user_id: user.id,
|
||||
notification_id: notification.id
|
||||
})),
|
||||
skipDuplicates: true
|
||||
const notification = await tx.notification.create({
|
||||
data: {
|
||||
title: summary.title,
|
||||
content: summary.content,
|
||||
type: 'hr_sync',
|
||||
level: status === 'failed' ? 'error' : changes.length > 0 ? 'warning' : 'info',
|
||||
action_url: '/notifications',
|
||||
related_sync_job_id: syncJob.id,
|
||||
metadata: toJsonText({
|
||||
status,
|
||||
changes_count: changes.length,
|
||||
counts: summary.counts,
|
||||
preview_changes: summary.previewChanges
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const notificationId: number | null = notification.id
|
||||
|
||||
const adminUsers = await tx.user.findMany({
|
||||
where: {
|
||||
role: 'admin',
|
||||
status: 'active'
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
await tx.userNotification.createMany({
|
||||
data: adminUsers.map((user) => ({
|
||||
user_id: user.id,
|
||||
notification_id: notification.id
|
||||
})),
|
||||
skipDuplicates: true
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@ -1459,6 +1586,9 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo
|
||||
const deactivatedResult = await prisma.person.updateMany({
|
||||
where: {
|
||||
source_system: HR_SOURCE_SYSTEM,
|
||||
status: {
|
||||
not: 'inactive'
|
||||
},
|
||||
source_userid: {
|
||||
notIn: sourceUserIds
|
||||
}
|
||||
@ -1651,14 +1781,15 @@ export const runHrSync = async (
|
||||
}
|
||||
}
|
||||
|
||||
const [afterDepartmentMap, afterPersonMap] = await Promise.all([
|
||||
const [afterDepartmentMap, afterPersonMap, openBorrowAssetMap] = await Promise.all([
|
||||
getDepartmentSnapshotMap(),
|
||||
getPersonSnapshotMap()
|
||||
getPersonSnapshotMap(),
|
||||
getOpenBorrowAssetMap()
|
||||
])
|
||||
|
||||
const changes: HrSyncChangeDraft[] = [
|
||||
...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap),
|
||||
...diffPersonSnapshots(beforePersonMap, afterPersonMap)
|
||||
...diffPersonSnapshots(beforePersonMap, afterPersonMap, openBorrowAssetMap)
|
||||
]
|
||||
|
||||
if (equipmentDepartmentMigration.unresolved > 0) {
|
||||
@ -1753,12 +1884,12 @@ export const startHrSyncScheduler = () => {
|
||||
}
|
||||
|
||||
const scheduleTimes = getConfiguredScheduleTimes()
|
||||
const scheduleTimezone = getConfiguredScheduleTimezone()
|
||||
setInterval(async () => {
|
||||
const now = new Date()
|
||||
const hh = String(now.getHours()).padStart(2, '0')
|
||||
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||
const currentTime = `${hh}:${mm}`
|
||||
const runKey = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}@${currentTime}`
|
||||
const currentClock = getCurrentClockInTimeZone(now, scheduleTimezone)
|
||||
const currentTime = currentClock.timeKey
|
||||
const runKey = `${currentClock.dateKey}@${currentTime}`
|
||||
|
||||
if (!scheduleTimes.includes(currentTime) || schedulerRunning || lastSchedulerRunKeys.has(runKey)) {
|
||||
return
|
||||
@ -1768,7 +1899,9 @@ export const startHrSyncScheduler = () => {
|
||||
try {
|
||||
await runHrSync('system-scheduler', 'scheduler')
|
||||
lastSchedulerRunKeys.add(runKey)
|
||||
console.log(`[hr-sync] completed at ${now.toISOString()}`)
|
||||
console.log(
|
||||
`[hr-sync] completed at ${now.toISOString()} (schedule_timezone=${scheduleTimezone} ${currentTime})`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[hr-sync] failed:', error)
|
||||
} finally {
|
||||
@ -1776,5 +1909,7 @@ export const startHrSyncScheduler = () => {
|
||||
}
|
||||
}, 60 * 1000)
|
||||
|
||||
console.log(`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}`)
|
||||
console.log(
|
||||
`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}, timezone=${scheduleTimezone}`
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,22 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import prisma from '../utils/prisma'
|
||||
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
|
||||
import { buildBusinessDepartmentWhere } from '../utils/organization'
|
||||
import {
|
||||
normalizeInventoryScopeInput,
|
||||
resolveUniqueActiveBorrowRecord,
|
||||
type NormalizeInventoryScopeInputParams,
|
||||
type NormalizedInventoryScopeInput
|
||||
} from '../utils/inventoryScope'
|
||||
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
|
||||
|
||||
type InventoryScopeOptions = {
|
||||
departmentId?: number | null
|
||||
assetTypeId?: number | null
|
||||
type ScopeEntity = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
type InventoryScopeOptions = NormalizeInventoryScopeInputParams | NormalizedInventoryScopeInput
|
||||
|
||||
type FrozenParticipantDraft = {
|
||||
person_id: number
|
||||
person_name: string
|
||||
@ -37,8 +46,9 @@ type FrozenItemDraft = {
|
||||
}
|
||||
|
||||
type InventoryScopeBuildResult = {
|
||||
scopeDepartment: { id: number; name: string } | null
|
||||
scopeAssetType: { id: number; name: string } | null
|
||||
scopeDepartments: ScopeEntity[]
|
||||
scopeAssetTypes: ScopeEntity[]
|
||||
scopeStorageLocations: ScopeEntity[]
|
||||
participants: FrozenParticipantDraft[]
|
||||
items: FrozenItemDraft[]
|
||||
}
|
||||
@ -62,54 +72,101 @@ const INVENTORY_PARTICIPANT_STATUS = {
|
||||
|
||||
const EXCLUDED_EQUIPMENT_STATUSES = ['已报废']
|
||||
|
||||
const buildScopeWhere = ({ departmentId, assetTypeId }: InventoryScopeOptions) => {
|
||||
const buildScopeWhere = ({
|
||||
departmentIds,
|
||||
assetTypeIds,
|
||||
storageLocationIds
|
||||
}: NormalizedInventoryScopeInput) => {
|
||||
const where: Record<string, any> = {
|
||||
status: {
|
||||
notIn: EXCLUDED_EQUIPMENT_STATUSES
|
||||
}
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
where.department_id = departmentId
|
||||
if (departmentIds.length > 0) {
|
||||
where.department_id = {
|
||||
in: departmentIds
|
||||
}
|
||||
}
|
||||
|
||||
if (assetTypeId) {
|
||||
where.asset_type_id = assetTypeId
|
||||
if (assetTypeIds.length > 0) {
|
||||
where.asset_type_id = {
|
||||
in: assetTypeIds
|
||||
}
|
||||
}
|
||||
|
||||
if (storageLocationIds.length > 0) {
|
||||
where.storage_location_id = {
|
||||
in: storageLocationIds
|
||||
}
|
||||
}
|
||||
|
||||
return where
|
||||
}
|
||||
|
||||
const getScopeMeta = async ({ departmentId, assetTypeId }: InventoryScopeOptions) => {
|
||||
const [department, assetType] = await Promise.all([
|
||||
departmentId
|
||||
? prisma.department.findFirst({
|
||||
const getScopeMeta = async ({
|
||||
departmentIds,
|
||||
assetTypeIds,
|
||||
storageLocationIds
|
||||
}: NormalizedInventoryScopeInput) => {
|
||||
const [departments, assetTypes, storageLocations] = await Promise.all([
|
||||
departmentIds.length > 0
|
||||
? prisma.department.findMany({
|
||||
where: {
|
||||
id: departmentId,
|
||||
...buildSyncedBusinessDepartmentWhere()
|
||||
id: {
|
||||
in: departmentIds
|
||||
},
|
||||
AND: [
|
||||
buildBusinessDepartmentWhere(),
|
||||
{ status: 'active' }
|
||||
]
|
||||
},
|
||||
select: { id: true, name: true, status: true }
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
assetTypeId
|
||||
? prisma.assetType.findUnique({
|
||||
where: { id: assetTypeId },
|
||||
select: { id: true, name: true, status: true }
|
||||
: Promise.resolve([]),
|
||||
assetTypeIds.length > 0
|
||||
? prisma.assetType.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: assetTypeIds
|
||||
},
|
||||
status: 'active'
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
})
|
||||
: Promise.resolve(null)
|
||||
: Promise.resolve([]),
|
||||
storageLocationIds.length > 0
|
||||
? prisma.storageLocation.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: storageLocationIds
|
||||
},
|
||||
status: 'active'
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
})
|
||||
: Promise.resolve([])
|
||||
])
|
||||
|
||||
if (departmentId && (!department || department.status !== 'active')) {
|
||||
if (departmentIds.length > 0 && departments.length !== departmentIds.length) {
|
||||
throw new Error('所选业务部门不存在或已停用')
|
||||
}
|
||||
|
||||
if (assetTypeId && (!assetType || assetType.status !== 'active')) {
|
||||
if (assetTypeIds.length > 0 && assetTypes.length !== assetTypeIds.length) {
|
||||
throw new Error('所选资产类型不存在或已停用')
|
||||
}
|
||||
|
||||
if (storageLocationIds.length > 0 && storageLocations.length !== storageLocationIds.length) {
|
||||
throw new Error('所选地区不存在或已停用')
|
||||
}
|
||||
|
||||
return {
|
||||
scopeDepartment: department ? { id: department.id, name: department.name } : null,
|
||||
scopeAssetType: assetType ? { id: assetType.id, name: assetType.name } : null
|
||||
scopeDepartments: departments,
|
||||
scopeAssetTypes: assetTypes,
|
||||
scopeStorageLocations: storageLocations
|
||||
}
|
||||
}
|
||||
|
||||
@ -243,10 +300,16 @@ const buildAssetTypeBreakdown = (items: FrozenItemDraft[]) =>
|
||||
export const buildInventoryScope = async (
|
||||
options: InventoryScopeOptions
|
||||
): Promise<InventoryScopeBuildResult> => {
|
||||
const meta = await getScopeMeta(options)
|
||||
const normalizedScope = normalizeInventoryScopeInput(options)
|
||||
const meta = await getScopeMeta(normalizedScope)
|
||||
const equipments = await prisma.equipment.findMany({
|
||||
where: buildScopeWhere(options),
|
||||
orderBy: [{ department_id: 'asc' }, { asset_type_id: 'asc' }, { created_at: 'desc' }],
|
||||
where: buildScopeWhere(normalizedScope),
|
||||
orderBy: [
|
||||
{ storage_location_id: 'asc' },
|
||||
{ department_id: 'asc' },
|
||||
{ asset_type_id: 'asc' },
|
||||
{ created_at: 'desc' }
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
equipment_code: true,
|
||||
@ -267,6 +330,12 @@ export const buildInventoryScope = async (
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
storageLocation: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -281,12 +350,13 @@ export const buildInventoryScope = async (
|
||||
for (const equipment of equipments) {
|
||||
const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap)
|
||||
const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || []
|
||||
const activeBorrowRecord =
|
||||
activeBorrowRecords.length === 1 && activeBorrowRecords[0].person?.status === 'active'
|
||||
? activeBorrowRecords[0]
|
||||
: null
|
||||
const activeBorrowRecord = resolveUniqueActiveBorrowRecord(activeBorrowRecords)
|
||||
|
||||
const participantPersonId = activeBorrowRecord?.person?.id || null
|
||||
if (!activeBorrowRecord?.person) {
|
||||
continue
|
||||
}
|
||||
|
||||
const participantPersonId = activeBorrowRecord.person.id
|
||||
const snapshotDepartmentId =
|
||||
activeBorrowRecord?.person?.mainDepartment?.id ||
|
||||
equipment.department?.id ||
|
||||
@ -297,19 +367,17 @@ export const buildInventoryScope = async (
|
||||
equipment.department?.name ||
|
||||
null
|
||||
|
||||
if (participantPersonId && activeBorrowRecord?.person) {
|
||||
participantMap.set(participantPersonId, {
|
||||
person_id: activeBorrowRecord.person.id,
|
||||
person_name: activeBorrowRecord.person.real_name,
|
||||
person_nickname: activeBorrowRecord.person.nickname,
|
||||
person_source_userid: activeBorrowRecord.person.source_userid || null,
|
||||
department_id: activeBorrowRecord.person.mainDepartment?.id || null,
|
||||
department_name:
|
||||
activeBorrowRecord.person.mainDepartment?.name ||
|
||||
activeBorrowRecord.person.department ||
|
||||
null
|
||||
})
|
||||
}
|
||||
participantMap.set(participantPersonId, {
|
||||
person_id: activeBorrowRecord.person.id,
|
||||
person_name: activeBorrowRecord.person.real_name,
|
||||
person_nickname: activeBorrowRecord.person.nickname,
|
||||
person_source_userid: activeBorrowRecord.person.source_userid || null,
|
||||
department_id: activeBorrowRecord.person.mainDepartment?.id || null,
|
||||
department_name:
|
||||
activeBorrowRecord.person.mainDepartment?.name ||
|
||||
activeBorrowRecord.person.department ||
|
||||
null
|
||||
})
|
||||
|
||||
items.push({
|
||||
participant_person_id: participantPersonId,
|
||||
@ -327,13 +395,14 @@ export const buildInventoryScope = async (
|
||||
snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null,
|
||||
snapshot_status: equipment.status,
|
||||
source_borrow_record_id: activeBorrowRecord?.id || null,
|
||||
is_unassigned: !activeBorrowRecord
|
||||
is_unassigned: false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
scopeDepartment: meta.scopeDepartment,
|
||||
scopeAssetType: meta.scopeAssetType,
|
||||
scopeDepartments: meta.scopeDepartments,
|
||||
scopeAssetTypes: meta.scopeAssetTypes,
|
||||
scopeStorageLocations: meta.scopeStorageLocations,
|
||||
participants: Array.from(participantMap.values()).sort((left, right) =>
|
||||
left.person_nickname.localeCompare(right.person_nickname, 'zh-CN')
|
||||
),
|
||||
@ -343,13 +412,17 @@ export const buildInventoryScope = async (
|
||||
|
||||
export const previewInventoryTask = async (options: InventoryScopeOptions) => {
|
||||
const scope = await buildInventoryScope(options)
|
||||
const unassignedCount = scope.items.filter((item) => item.is_unassigned).length
|
||||
|
||||
return {
|
||||
scope_department: scope.scopeDepartment,
|
||||
scope_asset_type: scope.scopeAssetType,
|
||||
scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null,
|
||||
scope_asset_type: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0] : null,
|
||||
scope_departments: scope.scopeDepartments,
|
||||
scope_asset_types: scope.scopeAssetTypes,
|
||||
scope_storage_locations: scope.scopeStorageLocations,
|
||||
participants_count: scope.participants.length,
|
||||
items_count: scope.items.length,
|
||||
unassigned_count: scope.items.filter((item) => item.is_unassigned).length,
|
||||
unassigned_count: unassignedCount,
|
||||
department_breakdown: buildDepartmentBreakdown(scope.items),
|
||||
asset_type_breakdown: buildAssetTypeBreakdown(scope.items),
|
||||
participants_sample: scope.participants.slice(0, 10),
|
||||
@ -358,8 +431,11 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => {
|
||||
}
|
||||
|
||||
const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({
|
||||
scope_department: scope.scopeDepartment,
|
||||
scope_asset_type: scope.scopeAssetType,
|
||||
scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null,
|
||||
scope_asset_type: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0] : null,
|
||||
scope_departments: scope.scopeDepartments,
|
||||
scope_asset_types: scope.scopeAssetTypes,
|
||||
scope_storage_locations: scope.scopeStorageLocations,
|
||||
participants_count: scope.participants.length,
|
||||
items_count: scope.items.length,
|
||||
unassigned_count: scope.items.filter((item) => item.is_unassigned).length,
|
||||
@ -447,34 +523,78 @@ const refreshParticipantStatus = async (tx: PrismaClient, participantId: number)
|
||||
})
|
||||
}
|
||||
|
||||
const serializeTaskListItem = (task: any) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
public_token: task.public_token,
|
||||
scope_department_id: task.scope_department_id,
|
||||
scope_department_name: task.scope_department_name,
|
||||
scope_asset_type_id: task.scope_asset_type_id,
|
||||
scope_asset_type_name: task.scope_asset_type_name,
|
||||
participants_count: task.participants_count,
|
||||
items_count: task.items_count,
|
||||
completed_count: task.completed_count,
|
||||
exception_count: task.exception_count,
|
||||
unassigned_count: task.unassigned_count,
|
||||
created_by: task.created_by,
|
||||
created_by_name: task.creator?.name || task.creator?.username || null,
|
||||
closed_at: task.closed_at,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at
|
||||
})
|
||||
const parseScopeSnapshot = (task: any) => {
|
||||
let snapshot = null
|
||||
|
||||
if (task.scope_snapshot_json) {
|
||||
try {
|
||||
snapshot = JSON.parse(task.scope_snapshot_json)
|
||||
} catch {
|
||||
snapshot = null
|
||||
}
|
||||
}
|
||||
|
||||
const scopeDepartments = Array.isArray(snapshot?.scope_departments)
|
||||
? snapshot.scope_departments
|
||||
: task.scope_department_id && task.scope_department_name
|
||||
? [{ id: task.scope_department_id, name: task.scope_department_name }]
|
||||
: []
|
||||
|
||||
const scopeAssetTypes = Array.isArray(snapshot?.scope_asset_types)
|
||||
? snapshot.scope_asset_types
|
||||
: task.scope_asset_type_id && task.scope_asset_type_name
|
||||
? [{ id: task.scope_asset_type_id, name: task.scope_asset_type_name }]
|
||||
: []
|
||||
|
||||
const scopeStorageLocations = Array.isArray(snapshot?.scope_storage_locations)
|
||||
? snapshot.scope_storage_locations
|
||||
: []
|
||||
|
||||
return {
|
||||
scope_snapshot: snapshot,
|
||||
scope_departments: scopeDepartments,
|
||||
scope_asset_types: scopeAssetTypes,
|
||||
scope_storage_locations: scopeStorageLocations
|
||||
}
|
||||
}
|
||||
|
||||
const serializeTaskListItem = (task: any) => {
|
||||
const serializedScope = parseScopeSnapshot(task)
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
public_token: task.public_token,
|
||||
scope_department_id: task.scope_department_id,
|
||||
scope_department_name: task.scope_department_name,
|
||||
scope_asset_type_id: task.scope_asset_type_id,
|
||||
scope_asset_type_name: task.scope_asset_type_name,
|
||||
participants_count: task.participants_count,
|
||||
items_count: task.items_count,
|
||||
completed_count: task.completed_count,
|
||||
exception_count: task.exception_count,
|
||||
unassigned_count: task.unassigned_count,
|
||||
created_by: task.created_by,
|
||||
created_by_name: task.creator?.name || task.creator?.username || null,
|
||||
closed_at: task.closed_at,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
...serializedScope
|
||||
}
|
||||
}
|
||||
|
||||
export const createInventoryTask = async (
|
||||
input: {
|
||||
title: string
|
||||
description?: string | null
|
||||
department_id?: number | null
|
||||
asset_type_id?: number | null
|
||||
department_id?: number | null
|
||||
asset_type_id?: number | null
|
||||
storage_location_id?: number | null
|
||||
department_ids?: number[] | null
|
||||
asset_type_ids?: number[] | null
|
||||
storage_location_ids?: number[] | null
|
||||
},
|
||||
operatorUserId: number
|
||||
) => {
|
||||
@ -484,8 +604,12 @@ export const createInventoryTask = async (
|
||||
}
|
||||
|
||||
const scope = await buildInventoryScope({
|
||||
departmentId: input.department_id || null,
|
||||
assetTypeId: input.asset_type_id || null
|
||||
department_id: input.department_id || null,
|
||||
asset_type_id: input.asset_type_id || null,
|
||||
storage_location_id: input.storage_location_id || null,
|
||||
department_ids: input.department_ids || [],
|
||||
asset_type_ids: input.asset_type_ids || [],
|
||||
storage_location_ids: input.storage_location_ids || []
|
||||
})
|
||||
|
||||
const task = await prisma.$transaction(async (tx) => {
|
||||
@ -495,10 +619,10 @@ export const createInventoryTask = async (
|
||||
description: input.description?.trim() || null,
|
||||
status: INVENTORY_TASK_STATUSES.active,
|
||||
public_token: createPublicToken(),
|
||||
scope_department_id: scope.scopeDepartment?.id || null,
|
||||
scope_department_name: scope.scopeDepartment?.name || null,
|
||||
scope_asset_type_id: scope.scopeAssetType?.id || null,
|
||||
scope_asset_type_name: scope.scopeAssetType?.name || null,
|
||||
scope_department_id: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].id : null,
|
||||
scope_department_name: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].name : null,
|
||||
scope_asset_type_id: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].id : null,
|
||||
scope_asset_type_name: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].name : null,
|
||||
scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)),
|
||||
created_by: operatorUserId
|
||||
}
|
||||
@ -642,6 +766,8 @@ export const getInventoryTaskDetail = async (taskId: number) => {
|
||||
throw new Error('盘点任务不存在')
|
||||
}
|
||||
|
||||
const serializedScope = parseScopeSnapshot(task)
|
||||
|
||||
const departmentBreakdown = Array.from(
|
||||
task.items.reduce((map, item) => {
|
||||
const key = item.snapshot_department_name || '未归属部门'
|
||||
@ -684,7 +810,7 @@ export const getInventoryTaskDetail = async (taskId: number) => {
|
||||
|
||||
return {
|
||||
...serializeTaskListItem(task),
|
||||
scope_snapshot: task.scope_snapshot_json ? JSON.parse(task.scope_snapshot_json) : null,
|
||||
...serializedScope,
|
||||
participants: task.participants,
|
||||
items: task.items,
|
||||
recent_actions: task.actions,
|
||||
@ -729,6 +855,29 @@ export const closeInventoryTask = async (taskId: number, operatorUserId: number)
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteInventoryTask = async (taskId: number) => {
|
||||
const task = await prisma.inventoryTask.findUnique({
|
||||
where: { id: taskId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
throw new Error('盘点任务不存在')
|
||||
}
|
||||
|
||||
const blockedReason = getInventoryTaskDeleteBlockedReason(task.status)
|
||||
if (blockedReason) {
|
||||
throw new Error(blockedReason)
|
||||
}
|
||||
|
||||
await prisma.inventoryTask.delete({
|
||||
where: { id: taskId }
|
||||
})
|
||||
}
|
||||
|
||||
export const getInventoryTaskPublicInfo = async (publicToken: string) => {
|
||||
const task = await prisma.inventoryTask.findUnique({
|
||||
where: {
|
||||
@ -746,6 +895,7 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
|
||||
completed_count: true,
|
||||
exception_count: true,
|
||||
unassigned_count: true,
|
||||
scope_snapshot_json: true,
|
||||
created_at: true,
|
||||
closed_at: true
|
||||
}
|
||||
@ -755,7 +905,10 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
|
||||
throw new Error('盘点任务不存在')
|
||||
}
|
||||
|
||||
return task
|
||||
return {
|
||||
...task,
|
||||
...parseScopeSnapshot(task)
|
||||
}
|
||||
}
|
||||
|
||||
export const searchTaskParticipants = async (publicToken: string, keyword: string) => {
|
||||
@ -875,7 +1028,13 @@ export const getParticipantTaskContext = async (taskId: number, participantId: n
|
||||
throw new Error('盘点任务会话已失效')
|
||||
}
|
||||
|
||||
return { task, participant }
|
||||
return {
|
||||
task: {
|
||||
...task,
|
||||
...parseScopeSnapshot(task)
|
||||
},
|
||||
participant
|
||||
}
|
||||
}
|
||||
|
||||
export const getParticipantItems = async (taskId: number, participantId: number) => {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { createHash, randomBytes, randomUUID } from 'crypto'
|
||||
import type { Prisma, PrintJob, PrintTerminal } from '@prisma/client'
|
||||
import prisma from '../utils/prisma'
|
||||
|
||||
@ -60,11 +62,24 @@ export interface AssetLabelPayloadItem {
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AssetLabelRenderedImagePayload {
|
||||
equipment_id: number
|
||||
image_data_url: string
|
||||
}
|
||||
|
||||
export interface AssetLabelStoredRenderFilePayload {
|
||||
equipment_id: number
|
||||
storage_key: string
|
||||
content_type: 'image/png'
|
||||
}
|
||||
|
||||
export interface AssetLabelJobPayload {
|
||||
job_type: 'asset_label'
|
||||
generated_at: string
|
||||
equipment_ids: number[]
|
||||
items: AssetLabelPayloadItem[]
|
||||
rendered_labels?: AssetLabelRenderedImagePayload[]
|
||||
rendered_label_files?: AssetLabelStoredRenderFilePayload[]
|
||||
}
|
||||
|
||||
export interface PairPrintTerminalInput {
|
||||
@ -129,6 +144,9 @@ const PRINT_PAIR_CODE_TTL_MINUTES = 10
|
||||
const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50
|
||||
const MAX_RETRY_ATTEMPTS = 3
|
||||
const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
|
||||
const PNG_DATA_URL_PATTERN = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/i
|
||||
const PRINT_JOB_RENDER_STORAGE_ROOT = path.resolve(__dirname, '..', '..', 'uploads', 'print-jobs')
|
||||
|
||||
type ParsedTerminalData = {
|
||||
labelProfile: PrintLabelProfileInput | null
|
||||
@ -351,6 +369,213 @@ const sanitizeEquipmentIds = (value: unknown) => {
|
||||
return uniqueIds
|
||||
}
|
||||
|
||||
const decodePngDataUrl = (value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
if (!PNG_DATA_URL_PATTERN.test(normalized) || !normalized.startsWith(PNG_DATA_URL_PREFIX)) {
|
||||
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
|
||||
}
|
||||
|
||||
const base64 = normalized.slice(PNG_DATA_URL_PREFIX.length)
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
if (buffer.length === 0) {
|
||||
throw new Error('empty image')
|
||||
}
|
||||
return buffer
|
||||
} catch {
|
||||
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
|
||||
}
|
||||
}
|
||||
|
||||
export const sanitizeRenderedLabelImages = (
|
||||
value: unknown,
|
||||
equipmentIds: number[]
|
||||
): AssetLabelRenderedImagePayload[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new PrintServiceError('当前页面缺少网页成品标签图,请刷新后重新发送打印', 422)
|
||||
}
|
||||
|
||||
const imageMap = new Map<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 = (
|
||||
terminal: Pick<PrintTerminalReadinessInput, 'status' | 'last_seen_at'>,
|
||||
now: Date = new Date()
|
||||
@ -382,6 +607,13 @@ export const resolveEffectiveTerminalStatus = (
|
||||
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) => {
|
||||
if (status === PRINT_TERMINAL_STATUS.disabled) {
|
||||
return PRINT_TERMINAL_STATUS.disabled
|
||||
@ -496,8 +728,14 @@ const buildPrintJobSummary = (payload: AssetLabelJobPayload | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const mapPrintJobRecord = (job: PrintJobListRecord) => {
|
||||
const mapPrintJobRecord = async (
|
||||
job: PrintJobListRecord,
|
||||
options: {
|
||||
includeRenderedLabels?: boolean
|
||||
} = {}
|
||||
) => {
|
||||
const payload = parseAssetLabelJobPayload(job.payload_json)
|
||||
const responsePayload = await resolvePrintJobPayloadForResponse(payload, options)
|
||||
const targetParsed = parseTerminalData(job.targetTerminal)
|
||||
|
||||
return {
|
||||
@ -511,7 +749,7 @@ const mapPrintJobRecord = (job: PrintJobListRecord) => {
|
||||
completed_at: job.completed_at,
|
||||
created_at: job.created_at,
|
||||
updated_at: job.updated_at,
|
||||
payload,
|
||||
payload: responsePayload,
|
||||
summary: buildPrintJobSummary(payload),
|
||||
target_terminal: {
|
||||
id: job.targetTerminal.id,
|
||||
@ -778,8 +1016,9 @@ export const updatePrintTerminalAdminStatus = async (
|
||||
throw new PrintServiceError('打印终端不存在', 404)
|
||||
}
|
||||
|
||||
if (nextStatus === PRINT_TERMINAL_STATUS.disabled && existing.is_default) {
|
||||
throw new PrintServiceError('默认打印电脑不能直接停用,请先切换默认终端', 422)
|
||||
const statusChangeError = getPrintTerminalAdminStatusChangeError(existing, nextStatus)
|
||||
if (statusChangeError) {
|
||||
throw new PrintServiceError(statusChangeError, 422)
|
||||
}
|
||||
|
||||
const updated = await prisma.printTerminal.update({
|
||||
@ -796,53 +1035,75 @@ export const updatePrintTerminalAdminStatus = async (
|
||||
})
|
||||
}
|
||||
|
||||
export const createAssetLabelPrintJob = async (equipmentIdsInput: unknown, requestedBy: number) => {
|
||||
const equipmentIds = sanitizeEquipmentIds(equipmentIdsInput)
|
||||
const [targetTerminal, payload] = await Promise.all([
|
||||
export const createAssetLabelPrintJob = async (input: unknown, requestedBy: number) => {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new PrintServiceError('当前页面不是最新版本,请刷新后重试', 422)
|
||||
}
|
||||
|
||||
const equipmentIds = sanitizeEquipmentIds((input as { equipment_ids?: unknown }).equipment_ids)
|
||||
const renderedLabels = sanitizeRenderedLabelImages(
|
||||
(input as { rendered_labels?: unknown }).rendered_labels,
|
||||
equipmentIds
|
||||
)
|
||||
const [targetTerminal, basePayload] = await Promise.all([
|
||||
getDefaultTargetTerminal(),
|
||||
buildAssetLabelPayload(equipmentIds)
|
||||
])
|
||||
|
||||
const job = await prisma.printJob.create({
|
||||
data: {
|
||||
job_type: PRINT_JOB_TYPE.assetLabel,
|
||||
status: PRINT_JOB_STATUS.queued,
|
||||
target_terminal_id: targetTerminal.id,
|
||||
requested_by: requestedBy,
|
||||
payload_json: stringifyJson(payload)
|
||||
},
|
||||
include: {
|
||||
targetTerminal: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
selected_printer_name: true,
|
||||
last_seen_at: true,
|
||||
label_profile_json: true,
|
||||
printer_snapshot_json: true
|
||||
}
|
||||
const storedRenderBatch = await storeRenderedLabelFiles(renderedLabels)
|
||||
|
||||
let job: PrintJobListRecord
|
||||
|
||||
try {
|
||||
const payload: AssetLabelJobPayload = {
|
||||
...basePayload,
|
||||
rendered_label_files: storedRenderBatch.storedFiles
|
||||
}
|
||||
|
||||
job = await prisma.printJob.create({
|
||||
data: {
|
||||
job_type: PRINT_JOB_TYPE.assetLabel,
|
||||
status: PRINT_JOB_STATUS.queued,
|
||||
target_terminal_id: targetTerminal.id,
|
||||
requested_by: requestedBy,
|
||||
payload_json: stringifyJson(payload)
|
||||
},
|
||||
claimedByTerminal: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
requester: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true
|
||||
include: {
|
||||
targetTerminal: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
selected_printer_name: true,
|
||||
last_seen_at: true,
|
||||
label_profile_json: true,
|
||||
printer_snapshot_json: true
|
||||
}
|
||||
},
|
||||
claimedByTerminal: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
requester: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
await removeStoredRenderBatch(storedRenderBatch.batchDirectory)
|
||||
throw error
|
||||
}
|
||||
|
||||
const parsedTarget = parseTerminalData(targetTerminal)
|
||||
|
||||
return {
|
||||
job: mapPrintJobRecord(job),
|
||||
job: await mapPrintJobRecord(job),
|
||||
queue_notice:
|
||||
parsedTarget.readiness.reason === 'ready'
|
||||
? '打印任务已入队,等待默认打印电脑领取'
|
||||
@ -909,7 +1170,7 @@ export const listPrintJobs = async (
|
||||
])
|
||||
|
||||
return {
|
||||
list: jobs.map((job) => mapPrintJobRecord(job)),
|
||||
list: await Promise.all(jobs.map((job) => mapPrintJobRecord(job))),
|
||||
pagination: {
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
@ -938,6 +1199,8 @@ export const cancelQueuedPrintJob = async (jobId: number) => {
|
||||
status: PRINT_JOB_STATUS.cancelled
|
||||
}
|
||||
})
|
||||
|
||||
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json))
|
||||
}
|
||||
|
||||
export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => {
|
||||
@ -1191,10 +1454,35 @@ export const claimNextPrintJob = async (terminalId: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
terminal: terminalView,
|
||||
job: mapPrintJobRecord(claimedJob),
|
||||
message: '领取成功'
|
||||
try {
|
||||
return {
|
||||
terminal: terminalView,
|
||||
job: await mapPrintJobRecord(claimedJob, {
|
||||
includeRenderedLabels: true
|
||||
}),
|
||||
message: '领取成功'
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message
|
||||
? `任务成品标签图读取失败,请在系统里重新发送打印:${error.message}`
|
||||
: '任务成品标签图读取失败,请在系统里重新发送打印'
|
||||
|
||||
await prisma.printJob.update({
|
||||
where: {
|
||||
id: claimedJob.id
|
||||
},
|
||||
data: {
|
||||
status: PRINT_JOB_STATUS.failed,
|
||||
error_message: errorMessage
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
terminal: terminalView,
|
||||
job: null,
|
||||
message: errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1314,8 +1602,12 @@ export const updateClaimedPrintJobStatus = async (
|
||||
}
|
||||
})
|
||||
|
||||
if (updated.status === PRINT_JOB_STATUS.completed || updated.status === PRINT_JOB_STATUS.failed) {
|
||||
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(updated.payload_json))
|
||||
}
|
||||
|
||||
return {
|
||||
job: mapPrintJobRecord(updated),
|
||||
job: await mapPrintJobRecord(updated),
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
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_SCHEDULES: ${HR_SYNC_SCHEDULES:-}
|
||||
HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-3}
|
||||
HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5}
|
||||
HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-2,5}
|
||||
HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流}
|
||||
HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-}
|
||||
DOWNLOADS_DIR: /app/downloads
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"published_at": "2026-03-25T17:45:51+08:00",
|
||||
"filename": "print-agent-setup.exe",
|
||||
"notes": "打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。",
|
||||
"source_type": "server_release",
|
||||
"relative_path": "downloads/print-agent/latest/print-agent-setup.exe",
|
||||
"relativePath": "downloads/print-agent/latest/print-agent-setup.exe"
|
||||
}
|
||||
"version": "0.2.5",
|
||||
"published_at": "2026-03-26T21:07:29+08:00",
|
||||
"filename": "print-agent-setup.exe",
|
||||
"notes": "打印助手最新稳定版,当前为安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。",
|
||||
"source_type": "server_release",
|
||||
"relative_path": "downloads/print-agent/latest/print-agent-setup.exe",
|
||||
"relativePath": "downloads/print-agent/latest/print-agent-setup.exe"
|
||||
}
|
||||
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
|
||||
} from '@/types'
|
||||
|
||||
export type BorrowDepartmentAction = 'keep' | 'clear' | 'set'
|
||||
|
||||
export interface ReturnDepartmentPayload {
|
||||
department_action?: BorrowDepartmentAction
|
||||
department_id?: number | null
|
||||
}
|
||||
|
||||
export interface ScanBorrowResult {
|
||||
id: number
|
||||
equipment_code: string
|
||||
@ -19,6 +26,25 @@ export interface ScanBorrowResult {
|
||||
storageLocation?: StorageLocation | null
|
||||
}
|
||||
|
||||
export interface ReturnSearchAssetCandidate {
|
||||
equipment_id: number
|
||||
equipment_code: string
|
||||
equipment_name: string
|
||||
equipment_model?: string | null
|
||||
equipment_type?: string | null
|
||||
storage_location_id?: number | null
|
||||
storageLocation?: StorageLocation | null
|
||||
active_borrow_count: number
|
||||
borrower_names?: string[]
|
||||
borrower_identifiers?: string[]
|
||||
borrower_departments?: string[]
|
||||
can_return?: boolean
|
||||
can_handle_borrow_anomaly?: boolean
|
||||
borrow_anomaly_blocked_reason?: string | null
|
||||
search_state?: 'returnable' | 'borrow_record_missing'
|
||||
state_message?: string
|
||||
}
|
||||
|
||||
// 借用设备
|
||||
export const borrowEquipment = async (data: {
|
||||
equipment_id?: number
|
||||
@ -73,7 +99,7 @@ export const returnEquipment = async (data: {
|
||||
return_condition?: string
|
||||
notes?: string
|
||||
confirm_children_diff?: boolean
|
||||
}) => {
|
||||
} & ReturnDepartmentPayload) => {
|
||||
const response = await api.post<ApiResponse<BorrowRecord>>('/return', data)
|
||||
return response.data
|
||||
}
|
||||
@ -86,7 +112,7 @@ export const scanReturn = async (data: {
|
||||
person_id?: number
|
||||
borrow_record_id?: number
|
||||
confirm_children_diff?: boolean
|
||||
}) => {
|
||||
} & ReturnDepartmentPayload) => {
|
||||
const response = await api.post<
|
||||
ApiResponse<{
|
||||
id: number
|
||||
@ -139,6 +165,44 @@ export const getBorrowRecordByCode = async (equipmentCode: string) => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const searchReturnAssets = async (params: { keyword?: string }) => {
|
||||
const response = await api.get<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: {
|
||||
page?: number
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import { resolveApiBaseUrl, resolveAuthMode } from '@/utils/authMode'
|
||||
|
||||
// 创建axios实例
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
|
||||
baseURL: resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL),
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -28,7 +29,46 @@ api.interceptors.request.use(
|
||||
}
|
||||
)
|
||||
|
||||
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
||||
const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
|
||||
|
||||
const getResponseErrorMessage = (error: any) => {
|
||||
const responseData = error?.response?.data
|
||||
if (responseData && typeof responseData === 'object') {
|
||||
if (typeof responseData.message === 'string' && responseData.message.trim()) {
|
||||
return responseData.message.trim()
|
||||
}
|
||||
|
||||
if (
|
||||
responseData.data &&
|
||||
typeof responseData.data === 'object' &&
|
||||
typeof responseData.data.message === 'string' &&
|
||||
responseData.data.message.trim()
|
||||
) {
|
||||
return responseData.data.message.trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error?.message === 'string' && error.message.trim()) {
|
||||
return error.message.trim()
|
||||
}
|
||||
|
||||
return '请求失败'
|
||||
}
|
||||
|
||||
const createApiRequestError = (error: any) => {
|
||||
const normalizedError = new Error(getResponseErrorMessage(error)) as Error & {
|
||||
status?: number
|
||||
response?: any
|
||||
originalError?: any
|
||||
}
|
||||
|
||||
normalizedError.name = 'ApiRequestError'
|
||||
normalizedError.status = error?.response?.status
|
||||
normalizedError.response = error?.response
|
||||
normalizedError.originalError = error
|
||||
|
||||
return normalizedError
|
||||
}
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
@ -76,7 +116,7 @@ api.interceptors.response.use(
|
||||
console.error('请求失败')
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
return Promise.reject(createApiRequestError(error))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -10,7 +10,11 @@ import type {
|
||||
|
||||
export interface InventoryTaskPreviewPayload {
|
||||
department_id?: number | null
|
||||
department_ids?: number[]
|
||||
asset_type_id?: number | null
|
||||
asset_type_ids?: number[]
|
||||
storage_location_id?: number | null
|
||||
storage_location_ids?: number[]
|
||||
}
|
||||
|
||||
export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload {
|
||||
@ -48,6 +52,11 @@ export const closeInventoryTask = async (id: number) => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const deleteInventoryTask = async (id: number) => {
|
||||
const response = await api.delete<ApiResponse<null>>(`/inventory/tasks/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getInventoryTaskUnassignedItems = async (id: number) => {
|
||||
const response = await api.get<ApiResponse<InventoryTaskItem[]>>(`/inventory/tasks/${id}/unassigned`)
|
||||
return response.data
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import api from './index'
|
||||
import { resolveApiBaseUrl } from '@/utils/authMode'
|
||||
import type {
|
||||
ApiResponse,
|
||||
CreateAssetLabelPrintJobPayload,
|
||||
@ -20,7 +21,7 @@ import type {
|
||||
UpdatePrintTerminalStatusPayload
|
||||
} from '@/types'
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
|
||||
const apiBaseUrl = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
|
||||
const downloadBaseUrl = apiBaseUrl.replace(/\/api\/?$/, '')
|
||||
|
||||
const getAuthHeaders = () => {
|
||||
@ -325,8 +326,9 @@ export const getPrintTerminals = async () => {
|
||||
return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal)
|
||||
}
|
||||
|
||||
export const createAssetLabelPrintJob = async (equipmentIds: number[]): Promise<CreateAssetLabelPrintJobResult> => {
|
||||
const payload: CreateAssetLabelPrintJobPayload = { equipment_ids: equipmentIds }
|
||||
export const createAssetLabelPrintJob = async (
|
||||
payload: CreateAssetLabelPrintJobPayload
|
||||
): Promise<CreateAssetLabelPrintJobResult> => {
|
||||
const response = await api.post<ApiResponse<unknown>>('/print-jobs/asset-labels', payload)
|
||||
return {
|
||||
job: normalizePrintJob(response.data.data),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import { resolveApiBaseUrl } from '@/utils/authMode'
|
||||
import type {
|
||||
ApiResponse,
|
||||
InventoryTaskItem,
|
||||
@ -10,7 +11,7 @@ import type {
|
||||
} from '@/types'
|
||||
|
||||
const resolvePublicInventoryBaseUrl = () => {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
|
||||
const baseUrl = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
|
||||
return `${baseUrl.replace(/\/$/, '')}/public/inventory`
|
||||
}
|
||||
|
||||
|
||||
@ -21,7 +21,9 @@ export const previewHrSync = 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
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Keycloak from 'keycloak-js'
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8080',
|
||||
url: import.meta.env.VITE_KEYCLOAK_URL || '',
|
||||
realm: import.meta.env.VITE_KEYCLOAK_REALM || 'equipment-management',
|
||||
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'equipment-client'
|
||||
})
|
||||
|
||||
@ -137,7 +137,7 @@ const LABEL_PRESET = {
|
||||
headerHeightPx: 82,
|
||||
qrPanelWidthPx: 324,
|
||||
qrCodeSizePx: 286,
|
||||
companyName: '常州简尚家具有限公司'
|
||||
companyName: '常州简尚家居有限公司'
|
||||
} as const
|
||||
|
||||
const equipmentList = ref<EquipmentBarcode[]>([])
|
||||
@ -724,7 +724,20 @@ const handleRemotePrint = async () => {
|
||||
|
||||
remoteSubmitting.value = true
|
||||
try {
|
||||
const result = await createAssetLabelPrintJob(props.equipmentIds)
|
||||
const images = await ensureImagesGenerated()
|
||||
if (images.length !== equipmentList.value.length) {
|
||||
throw new Error('标签图片生成不完整,请稍后重试')
|
||||
}
|
||||
|
||||
const renderedLabels = equipmentList.value.map((equipment, index) => ({
|
||||
equipment_id: equipment.id,
|
||||
image_data_url: images[index]
|
||||
}))
|
||||
const equipmentIds = equipmentList.value.map((equipment) => equipment.id)
|
||||
const result = await createAssetLabelPrintJob({
|
||||
equipment_ids: equipmentIds,
|
||||
rendered_labels: renderedLabels
|
||||
})
|
||||
|
||||
alert(result.queue_notice || `标签已发送到 ${defaultTerminal.value.name}`)
|
||||
|
||||
|
||||
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 api from './api'
|
||||
import type { User } from './types'
|
||||
import { resolveAuthMode } from './utils/authMode'
|
||||
|
||||
const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/'
|
||||
|
||||
@ -18,7 +19,7 @@ const bootstrap = async () => {
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
||||
const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
|
||||
const isPublicInventoryRoute = window.location.pathname.startsWith(PUBLIC_INVENTORY_ROUTE_PREFIX)
|
||||
|
||||
if (authMode === 'local') {
|
||||
|
||||
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import keycloak from '@/auth/keycloak'
|
||||
import { resolveAuthMode } from '@/utils/authMode'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@ -111,7 +112,7 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
||||
const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
|
||||
const isAuthenticated =
|
||||
authMode === 'local'
|
||||
? authStore.isAuthenticated
|
||||
|
||||
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import type { User } from '@/types'
|
||||
import keycloak from '@/auth/keycloak'
|
||||
import api from '@/api'
|
||||
import { resolveAuthMode } from '@/utils/authMode'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
@ -34,7 +35,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.isAuthenticated = false
|
||||
localStorage.removeItem('token')
|
||||
try {
|
||||
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
||||
const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
|
||||
if (authMode === 'local') {
|
||||
await api.post('/auth/logout')
|
||||
} else if (keycloak.authenticated) {
|
||||
@ -46,4 +47,3 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -4,6 +4,15 @@ export type EquipmentType = string
|
||||
// 资产状态
|
||||
export type EquipmentStatus = '可用' | '已借出' | '维护中' | '已损坏' | '待报废' | '已报废'
|
||||
|
||||
export interface BorrowAnomalyInfo {
|
||||
state: 'none' | 'returnable' | 'borrow_record_missing'
|
||||
can_handle?: boolean
|
||||
canHandle?: boolean
|
||||
message?: string | null
|
||||
blocked_reason?: string | null
|
||||
blockedReason?: string | null
|
||||
}
|
||||
|
||||
// 编码生成方式
|
||||
export type CodeGenerationMode = 'auto' | 'manual'
|
||||
|
||||
@ -140,6 +149,7 @@ export interface Equipment {
|
||||
unit_price?: number
|
||||
} | null
|
||||
current_borrow?: EquipmentCurrentBorrow | null
|
||||
borrow_anomaly?: BorrowAnomalyInfo | null
|
||||
children?: Array<{
|
||||
id: number
|
||||
equipment_code: string
|
||||
@ -239,6 +249,9 @@ export interface InventoryScopeEntity {
|
||||
export interface InventoryTaskScopeSnapshot {
|
||||
scope_department: InventoryScopeEntity | null
|
||||
scope_asset_type: InventoryScopeEntity | null
|
||||
scope_departments: InventoryScopeEntity[]
|
||||
scope_asset_types: InventoryScopeEntity[]
|
||||
scope_storage_locations: InventoryScopeEntity[]
|
||||
participants_count: number
|
||||
items_count: number
|
||||
unassigned_count: number
|
||||
@ -288,6 +301,9 @@ export interface InventoryPreviewItemSample {
|
||||
export interface InventoryTaskPreview {
|
||||
scope_department: InventoryScopeEntity | null
|
||||
scope_asset_type: InventoryScopeEntity | null
|
||||
scope_departments: InventoryScopeEntity[]
|
||||
scope_asset_types: InventoryScopeEntity[]
|
||||
scope_storage_locations: InventoryScopeEntity[]
|
||||
participants_count: number
|
||||
items_count: number
|
||||
unassigned_count: number
|
||||
@ -317,6 +333,10 @@ export interface InventoryTaskListItem {
|
||||
closed_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
scope_snapshot?: InventoryTaskScopeSnapshot | null
|
||||
scope_departments?: InventoryScopeEntity[]
|
||||
scope_asset_types?: InventoryScopeEntity[]
|
||||
scope_storage_locations?: InventoryScopeEntity[]
|
||||
}
|
||||
|
||||
export interface InventoryTaskParticipant {
|
||||
@ -415,6 +435,10 @@ export interface PublicInventoryTaskInfo {
|
||||
unassigned_count: number
|
||||
created_at: string
|
||||
closed_at?: string | null
|
||||
scope_snapshot?: InventoryTaskScopeSnapshot | null
|
||||
scope_departments?: InventoryScopeEntity[]
|
||||
scope_asset_types?: InventoryScopeEntity[]
|
||||
scope_storage_locations?: InventoryScopeEntity[]
|
||||
}
|
||||
|
||||
export interface PublicInventoryParticipantOption {
|
||||
@ -433,6 +457,10 @@ export interface PublicInventorySessionTask {
|
||||
status: InventoryTaskStatus | string
|
||||
scope_department_name?: string | null
|
||||
scope_asset_type_name?: string | null
|
||||
scope_snapshot?: InventoryTaskScopeSnapshot | null
|
||||
scope_departments?: InventoryScopeEntity[]
|
||||
scope_asset_types?: InventoryScopeEntity[]
|
||||
scope_storage_locations?: InventoryScopeEntity[]
|
||||
completed_count: number
|
||||
exception_count: number
|
||||
items_count: number
|
||||
@ -595,6 +623,7 @@ export interface HrSyncStatus {
|
||||
complete: boolean
|
||||
missingFields: string[]
|
||||
scheduleTimes: string[]
|
||||
scheduleTimezone?: string
|
||||
}
|
||||
|
||||
export interface HrSyncResult {
|
||||
@ -657,6 +686,20 @@ export interface HrSyncChangeItem {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface HrSyncBorrowedAssetPreview {
|
||||
equipmentCode: string
|
||||
equipmentName: string
|
||||
equipmentId?: number | null
|
||||
borrowRecordId?: number | null
|
||||
personId?: number | null
|
||||
}
|
||||
|
||||
export interface HrSyncPersonAfterSnapshot {
|
||||
displayName?: string | null
|
||||
assets?: HrSyncBorrowedAssetPreview[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface HrSyncJobDetail extends HrSyncJobHistoryItem {
|
||||
changes: HrSyncChangeItem[]
|
||||
}
|
||||
@ -775,6 +818,11 @@ export interface PrintJobPayload {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface AssetLabelRenderedImage {
|
||||
equipment_id: number
|
||||
image_data_url: string
|
||||
}
|
||||
|
||||
export interface PrintJob {
|
||||
id: number
|
||||
job_type: PrintJobType | string
|
||||
@ -806,6 +854,7 @@ export interface PrintJobListData extends PaginatedResponse<PrintJob> {}
|
||||
|
||||
export interface CreateAssetLabelPrintJobPayload {
|
||||
equipment_ids: number[]
|
||||
rendered_labels: AssetLabelRenderedImage[]
|
||||
}
|
||||
|
||||
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 type { User } from '@/types'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { resolveAuthMode } from '@/utils/authMode'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@ -94,7 +95,7 @@ const password = ref('')
|
||||
const errorText = ref('')
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
const authMode = (import.meta.env as any).VITE_AUTH_MODE || 'keycloak'
|
||||
const authMode = resolveAuthMode((import.meta.env as any).VITE_AUTH_MODE)
|
||||
const isLocalAuth = computed(() => authMode === 'local')
|
||||
const siteName = computed(() => siteStore.siteName || '固定资产管理系统')
|
||||
|
||||
|
||||
@ -45,21 +45,69 @@
|
||||
<h4 class="form-panel__title">手动输入</h4>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">资产编码</label>
|
||||
<label class="field-label">资产搜索</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
ref="scanInputRef"
|
||||
v-model="scanCode"
|
||||
v-model.trim="manualKeyword"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="输入编码后回车"
|
||||
@keyup.enter="handleScan"
|
||||
@focus="handleFocus"
|
||||
placeholder="输入资产编码 / 名称 / 型号 / 地址搜索"
|
||||
@keyup.enter="handleManualSubmit"
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@ -123,25 +171,10 @@
|
||||
</p>
|
||||
</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>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
@ -165,42 +198,45 @@
|
||||
v-model.trim="personKeyword"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="输入花名 / 姓名 / 证件号进行搜索"
|
||||
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
|
||||
@keyup.enter="handlePersonKeywordSubmit"
|
||||
/>
|
||||
<select
|
||||
v-model.number="selectedPersonId"
|
||||
class="select hidden md:block"
|
||||
>
|
||||
<option value="" disabled>请选择使用者</option>
|
||||
<option
|
||||
v-for="person in filteredPersons"
|
||||
:key="person.id"
|
||||
:value="person.id"
|
||||
>
|
||||
{{ person.nickname }} · {{ person.real_name }}{{ person.department ? `(${person.department})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="touch-option-list md:hidden">
|
||||
<button
|
||||
v-for="person in topFilteredPersons"
|
||||
:key="person.id"
|
||||
type="button"
|
||||
class="touch-option"
|
||||
:class="{ 'is-active': selectedPersonId === person.id }"
|
||||
@click="selectedPersonId = person.id"
|
||||
>
|
||||
<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>
|
||||
</p>
|
||||
<p class="text-xs text-text-tertiary">
|
||||
输入后会直接搜索人员,点选即可;如果只匹配到 1 人,按回车也能直接选中。
|
||||
</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">
|
||||
{{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="topFilteredPersons.length > 0" class="touch-option-list">
|
||||
<button
|
||||
v-for="person in topFilteredPersons"
|
||||
: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>
|
||||
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
|
||||
</div>
|
||||
</button>
|
||||
<p v-if="topFilteredPersons.length === 0" class="text-center text-sm text-text-tertiary py-4">
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="personSearchLoading" 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>
|
||||
</div>
|
||||
@ -216,28 +252,6 @@
|
||||
</p>
|
||||
</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="flex items-center justify-between">
|
||||
<label class="field-label">预计归还时间</label>
|
||||
@ -304,7 +318,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { scanEquipment } from '@/api/equipment'
|
||||
import { getEquipmentList, scanEquipment } from '@/api/equipment'
|
||||
import { scanBorrow } from '@/api/borrow'
|
||||
import { getPersonList } from '@/api/person'
|
||||
import { getChildren } from '@/api/mount'
|
||||
@ -312,6 +326,18 @@ import type { Equipment, Person } from '@/types'
|
||||
import type { ChildEquipment } from '@/api/mount'
|
||||
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
|
||||
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 scanInputRef = ref<HTMLInputElement>()
|
||||
@ -319,7 +345,6 @@ const route = useRoute()
|
||||
const showCameraScanner = ref(false)
|
||||
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
|
||||
const equipmentInfo = ref<Equipment | null>(null)
|
||||
const equipmentConfirmed = ref(false)
|
||||
const mountedChildren = ref<ChildEquipment[]>([])
|
||||
const mountedChildrenValue = ref(0)
|
||||
const successInfo = ref<{
|
||||
@ -336,33 +361,28 @@ const borrowForm = ref({
|
||||
is_long_term: false
|
||||
})
|
||||
|
||||
const manualKeyword = ref('')
|
||||
const manualSearchResults = ref<Equipment[]>([])
|
||||
const manualSearchLoading = ref(false)
|
||||
const personKeyword = ref('')
|
||||
const selectedPersonId = ref<number | null>(null)
|
||||
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
|
||||
const personSearchLoading = ref(false)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const hasEquipment = Boolean(equipmentInfo.value)
|
||||
const personId = selectedPersonId.value
|
||||
const hasPerson = personId !== null && !Number.isNaN(personId)
|
||||
return hasEquipment && hasPerson && equipmentConfirmed.value && !loading.value
|
||||
return canSubmitBorrowScan({
|
||||
hasEquipment: Boolean(equipmentInfo.value),
|
||||
selectedPersonId: selectedPersonId.value,
|
||||
loading: loading.value
|
||||
})
|
||||
})
|
||||
|
||||
const filteredPersons = computed(() => {
|
||||
if (!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)))
|
||||
)
|
||||
})
|
||||
return filterManualBorrowPersons(persons.value, personKeyword.value)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const showPersonSearchResults = computed(() =>
|
||||
shouldShowManualBorrowPersonResults(selectedPerson.value, personKeyword.value)
|
||||
)
|
||||
|
||||
const getStatusBadgeClass = (status: string) => {
|
||||
switch (status) {
|
||||
case '可用':
|
||||
@ -387,35 +411,46 @@ const getStatusBadgeClass = (status: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
scanCode.value = ''
|
||||
const resetBorrowDraftState = () => {
|
||||
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) => {
|
||||
scanCode.value = value.trim()
|
||||
await handleScan()
|
||||
await handleScan(value)
|
||||
}
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!scanCode.value) {
|
||||
const handleScan = async (rawCode = scanCode.value) => {
|
||||
const normalizedCode = rawCode.trim().toUpperCase()
|
||||
if (!normalizedCode) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await scanEquipment(scanCode.value)
|
||||
const response = await scanEquipment(normalizedCode)
|
||||
if (response.data) {
|
||||
equipmentInfo.value = response.data
|
||||
equipmentConfirmed.value = false
|
||||
successInfo.value = null
|
||||
mountedChildren.value = []
|
||||
mountedChildrenValue.value = 0
|
||||
selectedPersonId.value = null
|
||||
personKeyword.value = ''
|
||||
borrowForm.value = {
|
||||
expected_return_time: '',
|
||||
notes: '',
|
||||
is_long_term: false
|
||||
}
|
||||
resetBorrowDraftState()
|
||||
playBeepSound()
|
||||
|
||||
// 获取已挂载的子资产
|
||||
@ -431,9 +466,8 @@ const handleScan = async () => {
|
||||
|
||||
// 清空扫码框,准备下次扫码
|
||||
scanCode.value = ''
|
||||
nextTick(() => {
|
||||
scanInputRef.value?.focus()
|
||||
})
|
||||
clearManualSearch()
|
||||
focusManualInput()
|
||||
}
|
||||
} catch (error: any) {
|
||||
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 () => {
|
||||
if (!equipmentInfo.value || selectedPersonId.value === null || Number.isNaN(selectedPersonId.value)) {
|
||||
return
|
||||
@ -480,19 +599,9 @@ const handleSubmit = async () => {
|
||||
equipmentInfo.value.status = '已借出'
|
||||
}
|
||||
equipmentInfo.value = null
|
||||
mountedChildren.value = []
|
||||
mountedChildrenValue.value = 0
|
||||
selectedPersonId.value = null
|
||||
personKeyword.value = ''
|
||||
equipmentConfirmed.value = false
|
||||
borrowForm.value = {
|
||||
expected_return_time: '',
|
||||
notes: '',
|
||||
is_long_term: false
|
||||
}
|
||||
nextTick(() => {
|
||||
scanInputRef.value?.focus()
|
||||
})
|
||||
resetBorrowDraftState()
|
||||
clearManualSearch()
|
||||
focusManualInput()
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.message || '出库失败')
|
||||
@ -509,40 +618,73 @@ const loadEquipmentFromRoute = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
scanCode.value = routeEquipmentCode
|
||||
await handleScan()
|
||||
await handleScan(routeEquipmentCode)
|
||||
}
|
||||
|
||||
const fetchPersons = async (keyword = '') => {
|
||||
const requestId = ++latestPersonRequestId
|
||||
personSearchLoading.value = true
|
||||
try {
|
||||
const response = await getPersonList({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
pageSize: keyword ? 20 : 100,
|
||||
keyword: keyword || undefined,
|
||||
status: 'active'
|
||||
})
|
||||
if (response.data && requestId === latestPersonRequestId) {
|
||||
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) {
|
||||
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(
|
||||
() => personKeyword.value,
|
||||
(value) => {
|
||||
if (searchTimer) {
|
||||
window.clearTimeout(searchTimer)
|
||||
const matchedSelectedPerson = selectedPerson.value
|
||||
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)
|
||||
}, 300)
|
||||
}
|
||||
@ -557,24 +699,18 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => equipmentInfo.value?.equipment_code,
|
||||
() => {
|
||||
equipmentConfirmed.value = false
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchPersons()
|
||||
nextTick(() => {
|
||||
scanInputRef.value?.focus()
|
||||
})
|
||||
focusManualInput()
|
||||
loadEquipmentFromRoute()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (searchTimer) {
|
||||
window.clearTimeout(searchTimer)
|
||||
if (equipmentSearchTimer) {
|
||||
window.clearTimeout(equipmentSearchTimer)
|
||||
}
|
||||
if (personSearchTimer) {
|
||||
window.clearTimeout(personSearchTimer)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面头部 -->
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">Quick Return</p>
|
||||
@ -8,7 +7,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scan Input Area -->
|
||||
<div class="scanner-hero">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
@ -61,25 +59,78 @@
|
||||
<h4 class="form-panel__title">手动输入</h4>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">资产编码</label>
|
||||
<label class="field-label">资产搜索</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
ref="scanInputRef"
|
||||
v-model="scanCode"
|
||||
v-model.trim="manualAssetKeyword"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="输入编码后回车"
|
||||
@keyup.enter="handleScan"
|
||||
placeholder="输入资产编码 / 名称 / 型号 / 地址搜索"
|
||||
@keyup.enter="handleManualAssetSubmit"
|
||||
@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>
|
||||
</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>
|
||||
|
||||
<!-- Equipment and Borrow Record Info -->
|
||||
<section v-if="borrowRecordInfo" class="card mt-6">
|
||||
<div class="list-header">
|
||||
<div class="flex items-center gap-3">
|
||||
@ -122,88 +173,68 @@
|
||||
<h3 class="section-title mb-4">借用记录</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">选择归还使用者</label>
|
||||
<select v-model="selectedBorrowRecordId" class="select hidden md:block" @change="handleRecordSelect">
|
||||
<option value="" disabled>请选择归还使用者</option>
|
||||
<option
|
||||
v-for="record in borrowRecordInfo.borrow_records"
|
||||
:key="record.id"
|
||||
:value="String(record.id)"
|
||||
>
|
||||
{{ record.person_nickname }} · {{ record.person_name }}
|
||||
(借用时间:{{ formatDateTime(record.borrow_time) }})
|
||||
</option>
|
||||
</select>
|
||||
<div class="mobile-card-list md:hidden">
|
||||
<article
|
||||
v-for="record in borrowRecordInfo.borrow_records"
|
||||
:key="record.id"
|
||||
class="mobile-card"
|
||||
:class="{ 'border-accent-primary/30': selectedBorrowRecordId === String(record.id) }"
|
||||
@click="selectBorrowRecord(record.id)"
|
||||
>
|
||||
<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>
|
||||
<label class="field-label">归还使用者 *</label>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
v-model.trim="recordKeyword"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
|
||||
@keyup.enter="handleRecordKeywordSubmit"
|
||||
/>
|
||||
<p class="text-xs text-text-tertiary">
|
||||
{{ borrowRecordInfo.borrow_records.length > 1
|
||||
? '这台资产存在多条未归还记录,请搜索并点选正确使用者。'
|
||||
: '这台资产当前只有 1 条未归还记录,系统已自动帮你选中。' }}
|
||||
</p>
|
||||
<div v-if="showRecordSearchResults" 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">
|
||||
共 {{ filteredReturnRecords.length }} 条
|
||||
</p>
|
||||
</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>
|
||||
<p
|
||||
v-if="borrowRecordInfo.borrow_records.length > 1"
|
||||
class="mt-2 text-xs text-text-tertiary"
|
||||
>
|
||||
该资产有多位使用者,请选择正确归还人。
|
||||
该资产有多位使用者,请确认选中的是本次实际归还人。
|
||||
</p>
|
||||
</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">
|
||||
<h4 class="mb-2 text-sm font-medium text-text-primary">配件对比</h4>
|
||||
|
||||
<!-- 借出时配件 -->
|
||||
|
||||
<div v-if="childrenSnapshot.length > 0" class="mb-3">
|
||||
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">借出时配件:</p>
|
||||
<div class="space-y-1.5">
|
||||
@ -225,8 +256,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前配件 -->
|
||||
|
||||
<div v-if="currentChildren.length > 0">
|
||||
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">当前配件:</p>
|
||||
<div class="space-y-1.5">
|
||||
@ -248,8 +278,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 差异提示 -->
|
||||
|
||||
<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">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -272,7 +301,12 @@
|
||||
@detected="handleCameraDetected"
|
||||
/>
|
||||
|
||||
<!-- Return Info Form -->
|
||||
<BorrowAnomalyHandler
|
||||
v-model="showBorrowAnomalyHandler"
|
||||
:asset="borrowAnomalyTarget"
|
||||
@handled="handleBorrowAnomalyHandled"
|
||||
/>
|
||||
|
||||
<section v-if="borrowRecordInfo" class="card">
|
||||
<h3 class="section-title mb-4">归还信息</h3>
|
||||
<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">
|
||||
<button
|
||||
@click="handleSubmit"
|
||||
:disabled="loading || !selectedBorrowRecord"
|
||||
:disabled="!canSubmit"
|
||||
class="btn-primary w-full text-sm"
|
||||
>
|
||||
{{ loading ? '提交中...' : '确认入库' }}
|
||||
@ -308,14 +342,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, computed } from 'vue'
|
||||
import { getBorrowRecordByCode } from '@/api/borrow'
|
||||
import { scanReturn } from '@/api/borrow'
|
||||
import { ref, onMounted, nextTick, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getBorrowRecordByCode, scanReturn, searchReturnAssets } from '@/api/borrow'
|
||||
import { getChildren } from '@/api/mount'
|
||||
import { formatCurrency, formatDateTime, playBeepSound, playErrorSound } from '@/utils/format'
|
||||
import type { BorrowRecord, StorageLocation } from '@/types'
|
||||
import type { ChildEquipment } from '@/api/mount'
|
||||
import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.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 {
|
||||
id: number
|
||||
@ -338,8 +386,8 @@ interface BorrowRecordInfo {
|
||||
message: string
|
||||
}
|
||||
|
||||
const scanCode = ref('')
|
||||
const scanInputRef = ref<HTMLInputElement>()
|
||||
const route = useRoute()
|
||||
const showCameraScanner = ref(false)
|
||||
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
|
||||
const borrowRecordInfo = ref<BorrowRecordInfo | null>(null)
|
||||
@ -348,29 +396,58 @@ const childrenSnapshot = ref<ChildSnapshot[]>([])
|
||||
const currentChildren = ref<ChildEquipment[]>([])
|
||||
const pageNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
|
||||
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({
|
||||
return_condition: '正常',
|
||||
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(() => {
|
||||
if (
|
||||
!borrowRecordInfo.value ||
|
||||
!selectedBorrowRecordId.value ||
|
||||
Number.isNaN(Number(selectedBorrowRecordId.value))
|
||||
) {
|
||||
if (!borrowRecordInfo.value || selectedBorrowRecordId.value === null) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
borrowRecordInfo.value.borrow_records.find(
|
||||
(record) => record.id === Number(selectedBorrowRecordId.value)
|
||||
(record) => record.id === selectedBorrowRecordId.value
|
||||
) || null
|
||||
)
|
||||
})
|
||||
|
||||
const filteredReturnRecords = computed(() =>
|
||||
filterManualReturnRecordCandidates(borrowRecordInfo.value?.borrow_records || [], recordKeyword.value)
|
||||
)
|
||||
|
||||
const showRecordSearchResults = computed(() =>
|
||||
shouldShowManualReturnRecordResults(selectedBorrowRecord.value, recordKeyword.value)
|
||||
)
|
||||
|
||||
const hasChildrenDiff = computed(() => {
|
||||
if (childrenSnapshot.value.length === 0 && currentChildren.value.length === 0) return false
|
||||
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)
|
||||
}
|
||||
|
||||
const getStatusBadgeClass = (status: string) => {
|
||||
switch (status) {
|
||||
case '已借出':
|
||||
return 'badge-warning'
|
||||
case '已归还':
|
||||
return 'badge-success'
|
||||
case '逾期':
|
||||
return 'badge-error'
|
||||
default:
|
||||
return 'badge-neutral'
|
||||
}
|
||||
}
|
||||
const isBorrowRecordMissingAsset = (asset: ManualReturnAssetCandidate) =>
|
||||
asset.search_state === 'borrow_record_missing' || asset.can_return === false
|
||||
|
||||
const handleRecordSelect = () => {
|
||||
// 解析选中记录的配件快照
|
||||
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 getManualAssetBadgeClass = (asset: ManualReturnAssetCandidate) =>
|
||||
isBorrowRecordMissingAsset(asset) ? 'badge-error' : 'badge-warning'
|
||||
|
||||
const selectBorrowRecord = (recordId: number) => {
|
||||
selectedBorrowRecordId.value = String(recordId)
|
||||
handleRecordSelect()
|
||||
const getManualAssetBadgeText = (asset: ManualReturnAssetCandidate) =>
|
||||
isBorrowRecordMissingAsset(asset) ? '借用单缺失' : `待归还 ${asset.active_borrow_count}`
|
||||
|
||||
const selectBorrowRecord = (record: BorrowRecord) => {
|
||||
selectedBorrowRecordId.value = record.id
|
||||
recordKeyword.value = buildManualReturnRecordDisplay(record)
|
||||
}
|
||||
|
||||
const focusScanInput = () => {
|
||||
@ -436,37 +496,199 @@ const resetReturnForm = () => {
|
||||
|
||||
const resetCurrentFlow = () => {
|
||||
borrowRecordInfo.value = null
|
||||
selectedBorrowRecordId.value = ''
|
||||
selectedBorrowRecordId.value = null
|
||||
childrenSnapshot.value = []
|
||||
currentChildren.value = []
|
||||
scanCode.value = ''
|
||||
recordKeyword.value = ''
|
||||
resetReturnForm()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
scanCode.value = ''
|
||||
pageNotice.value = null
|
||||
}
|
||||
|
||||
const handleCameraDetected = async (value: string) => {
|
||||
scanCode.value = value.trim()
|
||||
await handleScan()
|
||||
await handleScan(value.trim())
|
||||
}
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!scanCode.value) {
|
||||
const clearManualAssetSearch = () => {
|
||||
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
|
||||
}
|
||||
|
||||
const requestId = ++latestAssetRequestId
|
||||
manualAssetSearchLoading.value = true
|
||||
|
||||
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) {
|
||||
borrowRecordInfo.value = response.data
|
||||
childrenSnapshot.value = []
|
||||
currentChildren.value = []
|
||||
selectedBorrowRecordId.value = null
|
||||
recordKeyword.value = ''
|
||||
resetReturnForm()
|
||||
playBeepSound()
|
||||
|
||||
// 从借用记录中获取 equipment_id 来查询当前挂载的子资产
|
||||
const equipmentId = response.data.borrow_records?.[0]?.equipment_id
|
||||
if (equipmentId) {
|
||||
try {
|
||||
@ -475,37 +697,72 @@ const handleScan = async () => {
|
||||
currentChildren.value = childrenResponse.data.children
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
// 忽略查询当前子资产失败
|
||||
}
|
||||
}
|
||||
|
||||
if (response.data.borrow_records.length === 1) {
|
||||
selectedBorrowRecordId.value = String(response.data.borrow_records[0].id)
|
||||
handleRecordSelect()
|
||||
selectBorrowRecord(response.data.borrow_records[0])
|
||||
pageNotice.value = {
|
||||
type: 'success',
|
||||
message: `已读取 ${response.data.equipment_name} 的借用记录,可以直接确认归还。`
|
||||
}
|
||||
} else {
|
||||
selectedBorrowRecordId.value = ''
|
||||
pageNotice.value = {
|
||||
type: 'warning',
|
||||
message: `已读取 ${response.data.equipment_name},请先选择正确的归还使用者。`
|
||||
}
|
||||
}
|
||||
|
||||
scanCode.value = ''
|
||||
clearManualAssetSearch()
|
||||
focusScanInput()
|
||||
return true
|
||||
}
|
||||
} catch (error: any) {
|
||||
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 = {
|
||||
type: 'error',
|
||||
message: error.message || '扫码失败,请检查资产编码是否正确'
|
||||
}
|
||||
scanCode.value = ''
|
||||
focusScanInput()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@ -567,6 +824,7 @@ const handleSubmit = async () => {
|
||||
message: `${borrowRecordInfo.value.equipment_name} 已完成归还,可以继续扫码下一件。`
|
||||
}
|
||||
resetCurrentFlow()
|
||||
clearManualAssetSearch()
|
||||
focusScanInput()
|
||||
} catch (error: any) {
|
||||
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(() => {
|
||||
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">
|
||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||
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 { getAssetTypeOptions } from '@/api/assetType'
|
||||
import { getDepartmentOptions } from '@/api/department'
|
||||
@ -830,8 +830,20 @@ const handleSubmit = async () => {
|
||||
const handleCancel = () => router.push('/equipment')
|
||||
|
||||
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 = () => {
|
||||
|
||||
@ -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 class="text-sm text-text-secondary">已选 {{ selectedIds.length }} 项</div>
|
||||
<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="showBatchReturnModal = 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">
|
||||
{{ expandedIds.has(equipment.id) ? '收起配件' : hasMountedChildren(equipment) ? `管理配件(${equipment.children?.length || 0})` : '管理配件' }}
|
||||
</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="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>
|
||||
@ -242,6 +250,13 @@
|
||||
<button v-if="canManageMountAsset(equipment)" @click.stop="toggleExpand(equipment.id)" class="table-action table-action--primary">
|
||||
{{ expandedIds.has(equipment.id) ? '收起配件' : '管理配件' }}
|
||||
</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="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</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" />
|
||||
|
||||
<!-- 条码打印弹窗 -->
|
||||
<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="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-text-primary">打印条码</h3>
|
||||
<button @click="showBarcodePrint = false" class="text-text-tertiary hover:text-text-primary text-xl">✕</button>
|
||||
<h3 class="text-xl font-semibold text-text-primary">{{ barcodePrintTitle }}</h3>
|
||||
<button @click="closeBarcodePrint" class="text-text-tertiary hover:text-text-primary text-xl">✕</button>
|
||||
</div>
|
||||
<BarcodePrint :equipment-ids="[selectedEquipmentId]" />
|
||||
<BarcodePrint :equipment-ids="barcodePrintIds" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -366,6 +381,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BorrowAnomalyHandler
|
||||
v-model="showBorrowAnomalyHandler"
|
||||
:asset="borrowAnomalyTarget"
|
||||
@handled="handleBorrowAnomalyHandled"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -383,7 +404,9 @@ import { batchBorrow, batchReturn, batchTransfer } from '@/api/batch'
|
||||
import { exportEquipment, downloadFile } from '@/api/export'
|
||||
import { batchImportEquipments } from '@/api/batchImport'
|
||||
import { formatCurrencyNoFraction, formatNumber } from '@/utils/format'
|
||||
import { canHandleBorrowAnomaly } from '@/utils/borrowAnomaly'
|
||||
import BarcodePrint from '@/components/BarcodePrint.vue'
|
||||
import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
|
||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||
import MountHistory from '@/components/MountHistory.vue'
|
||||
import MountManager from '@/components/MountManager.vue'
|
||||
@ -427,18 +450,29 @@ const isAllSelected = computed(() => selectableIds.value.length > 0 && selectabl
|
||||
|
||||
const showBarcodePrint = ref(false)
|
||||
const showAssetScanner = ref(false)
|
||||
const selectedEquipmentId = ref<number | null>(null)
|
||||
const barcodePrintIds = ref<number[]>([])
|
||||
const showBatchBorrowModal = ref(false)
|
||||
const showBatchReturnModal = ref(false)
|
||||
const showBatchTransferModal = ref(false)
|
||||
const showMoreActions = 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 batchReturnForm = ref({ return_condition: '正常', notes: '' })
|
||||
const batchTransferForm = ref({ department_id: '' })
|
||||
|
||||
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 params = {
|
||||
@ -493,10 +527,44 @@ const handleDelete = async (id: number) => {
|
||||
}
|
||||
|
||||
const handlePrintBarcode = (id: number) => {
|
||||
selectedEquipmentId.value = id
|
||||
barcodePrintIds.value = [id]
|
||||
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) => {
|
||||
try {
|
||||
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 currentBorrow = equipment.current_borrow
|
||||
if (!currentBorrow) {
|
||||
return equipment.status === '已借出' ? '已借出,待核对' : '未借出'
|
||||
return equipment.status === '已借出' ? '借用单缺失' : '未借出'
|
||||
}
|
||||
|
||||
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 || 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
|
||||
if (!currentBorrow) {
|
||||
return equipment.status === '已借出'
|
||||
? '资产状态为已借出,但系统中没有对应借用记录'
|
||||
: '暂无借用记录'
|
||||
}
|
||||
|
||||
if (!currentBorrow?.borrow_time) {
|
||||
return '暂无借用记录'
|
||||
return '借出时间待补充'
|
||||
}
|
||||
|
||||
const borrowDate = currentBorrow.borrow_time.includes('T')
|
||||
|
||||
@ -19,12 +19,12 @@
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">当前范围</p>
|
||||
<p class="page-meta__value text-base">{{ currentScopeLabel }}</p>
|
||||
<p class="page-meta__subvalue">部门/类型留空表示全量</p>
|
||||
<p class="page-meta__subvalue">同维度多选并集,跨维度自动取交集</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">预估参与人</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 class="page-meta">
|
||||
<p class="page-meta__label">预估资产数</p>
|
||||
@ -47,27 +47,9 @@
|
||||
v-model.trim="form.title"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="例如:2026 年 3 月办公区资产盘点"
|
||||
placeholder="例如:2026 年 3 月在用资产盘点"
|
||||
/>
|
||||
</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">
|
||||
<label class="field-label">任务说明</label>
|
||||
<textarea
|
||||
@ -80,12 +62,176 @@
|
||||
</div>
|
||||
</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">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">范围预览</h3>
|
||||
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
|
||||
{{ previewLoading ? '刷新中...' : '刷新' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
|
||||
{{ previewLoading ? '刷新中...' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="previewLoading" class="empty-state">正在根据当前范围生成预览...</div>
|
||||
@ -95,17 +241,17 @@
|
||||
<div class="panel">
|
||||
<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-xs text-text-secondary">自动生成名单</p>
|
||||
<p class="mt-1 text-xs text-text-secondary">只纳入有明确当前使用人的资产</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<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-xs text-text-secondary">按当前快照生成</p>
|
||||
<p class="mt-1 text-xs text-text-secondary">按当前借用快照生成</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<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-xs text-text-secondary">创建后尽快分配</p>
|
||||
<p class="mt-1 text-xs text-text-secondary">新规则下应为 0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -185,13 +331,11 @@
|
||||
<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-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>
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge" :class="item.is_unassigned ? 'badge-warning' : 'badge-neutral'">
|
||||
{{ item.is_unassigned ? '待分配' : '已归属' }}
|
||||
</span>
|
||||
<span class="badge badge-neutral">已纳入</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -210,13 +354,16 @@ import { useRouter } from 'vue-router'
|
||||
import { getAssetTypeOptions } from '@/api/assetType'
|
||||
import { getDepartmentOptions } from '@/api/department'
|
||||
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 { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const departments = ref<Department[]>([])
|
||||
const assetTypes = ref<AssetType[]>([])
|
||||
const storageLocations = ref<StorageLocation[]>([])
|
||||
const preview = ref<InventoryTaskPreview | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const creating = ref(false)
|
||||
@ -225,41 +372,78 @@ const previewDirty = ref(true)
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
department_id: '',
|
||||
asset_type_id: ''
|
||||
department_ids: [] as number[],
|
||||
asset_type_ids: [] as number[],
|
||||
storage_location_ids: [] as number[]
|
||||
})
|
||||
|
||||
const selectedDepartment = computed(
|
||||
() => departments.value.find((department) => String(department.id) === form.department_id) || null
|
||||
const selectedDepartments = computed(() =>
|
||||
departments.value.filter((department) => form.department_ids.includes(department.id))
|
||||
)
|
||||
const selectedAssetType = computed(
|
||||
() => assetTypes.value.find((assetType) => String(assetType.id) === form.asset_type_id) || null
|
||||
const selectedAssetTypes = computed(() =>
|
||||
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 buildPreviewPayload = () => ({
|
||||
department_id: form.department_id ? Number(form.department_id) : null,
|
||||
asset_type_id: form.asset_type_id ? Number(form.asset_type_id) : null
|
||||
department_ids: [...form.department_ids],
|
||||
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 () => {
|
||||
try {
|
||||
const [departmentResponse, assetTypeResponse] = await Promise.all([
|
||||
const [departmentResponse, assetTypeResponse, storageLocationResponse] = await Promise.all([
|
||||
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) {
|
||||
alert(error?.message || '加载盘点范围选项失败')
|
||||
}
|
||||
@ -316,7 +500,7 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -22,7 +22,15 @@
|
||||
:disabled="closing"
|
||||
@click="handleCloseTask"
|
||||
>
|
||||
{{ closing ? '关闭中...' : '关闭任务' }}
|
||||
{{ closing ? '结束中...' : '结束任务' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm text-status-error"
|
||||
:disabled="deleting"
|
||||
@click="handleDeleteTask"
|
||||
>
|
||||
{{ deleting ? '删除中...' : '删除任务' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -435,6 +443,7 @@ import { getPersonOptions } from '@/api/person'
|
||||
import {
|
||||
assignInventoryTaskUnassignedItem,
|
||||
closeInventoryTask,
|
||||
deleteInventoryTask,
|
||||
getInventoryTaskDetail,
|
||||
getInventoryTaskUnassignedItems
|
||||
} from '@/api/inventory'
|
||||
@ -446,6 +455,7 @@ import type {
|
||||
Person
|
||||
} from '@/types'
|
||||
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
||||
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||||
|
||||
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
|
||||
|
||||
@ -464,6 +474,7 @@ const unassignedItems = ref<InventoryTaskItem[]>([])
|
||||
const personOptions = ref<Person[]>([])
|
||||
const loading = ref(false)
|
||||
const closing = ref(false)
|
||||
const deleting = ref(false)
|
||||
const assigningItemId = ref<number | null>(null)
|
||||
const itemFilter = ref<ItemFilter>('all')
|
||||
const participantFilter = ref('all')
|
||||
@ -474,17 +485,7 @@ const taskId = computed(() => Number(route.params.id))
|
||||
const publicLink = computed(() =>
|
||||
task.value ? `${window.location.origin}/inventory/task/${task.value.public_token}` : ''
|
||||
)
|
||||
const scopeLabel = computed(() => {
|
||||
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 scopeLabel = computed(() => (task.value ? buildInventoryScopeLabel(task.value) : '-'))
|
||||
const taskProgressText = computed(() => {
|
||||
if (!task.value || task.value.items_count === 0) return '暂无资产'
|
||||
return `${formatPercent((task.value.completed_count / task.value.items_count) * 100)}`
|
||||
@ -688,21 +689,39 @@ const openPublicLink = () => {
|
||||
|
||||
const handleCloseTask = async () => {
|
||||
if (!task.value) return
|
||||
const shouldClose = window.confirm('关闭后手机端将不能继续提交盘点结果,确定关闭吗?')
|
||||
const shouldClose = window.confirm('结束后手机端将不能继续提交盘点结果,确定结束吗?')
|
||||
if (!shouldClose) return
|
||||
|
||||
closing.value = true
|
||||
try {
|
||||
await closeInventoryTask(task.value.id)
|
||||
alert('盘点任务已关闭')
|
||||
alert('盘点任务已结束')
|
||||
loadTask()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '关闭盘点任务失败')
|
||||
alert(error?.message || '结束盘点任务失败')
|
||||
} finally {
|
||||
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) => {
|
||||
if (!task.value) return
|
||||
const personId = Number(assignPersonIdMap[itemId])
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">当前页进行中</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 class="page-meta">
|
||||
<p class="page-meta__label">当前页完成率</p>
|
||||
@ -87,12 +87,26 @@
|
||||
</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>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button type="button" class="btn-secondary text-xs" @click="copyTaskLink(task)">复制手机链接</button>
|
||||
<button type="button" class="btn-secondary text-xs" @click="openTaskLink(task)">打开手机页</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary w-full text-sm"
|
||||
: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>
|
||||
</article>
|
||||
|
||||
@ -150,13 +164,29 @@
|
||||
<td>
|
||||
<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 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>
|
||||
<div class="table-action-group">
|
||||
<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="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>
|
||||
</td>
|
||||
</tr>
|
||||
@ -183,15 +213,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getInventoryTaskList } from '@/api/inventory'
|
||||
import { closeInventoryTask, deleteInventoryTask, getInventoryTaskList } from '@/api/inventory'
|
||||
import type { InventoryTaskListItem } from '@/types'
|
||||
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
|
||||
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||||
|
||||
const tasks = ref<InventoryTaskListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
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 activeCount = computed(() => tasks.value.filter((task) => task.status === 'active').length)
|
||||
@ -226,18 +259,9 @@ const fetchTasks = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveScopeLabel = (task: InventoryTaskListItem) => {
|
||||
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 resolveScopeLabel = (task: InventoryTaskListItem) => buildInventoryScopeLabel(task)
|
||||
|
||||
const getTaskStatusText = (status: string) => (status === 'closed' ? '已关闭' : '进行中')
|
||||
const getTaskStatusText = (status: string) => (status === 'closed' ? '已结束' : '进行中')
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
currentPage.value = 1
|
||||
fetchTasks()
|
||||
|
||||
@ -433,6 +433,7 @@ import type {
|
||||
PublicInventoryTaskInfo
|
||||
} from '@/types'
|
||||
import { formatDateTime, formatNumber, formatPercent, playBeepSound, playErrorSound } from '@/utils/format'
|
||||
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
|
||||
|
||||
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
|
||||
type FeedbackType = 'success' | 'error' | 'info'
|
||||
@ -492,14 +493,7 @@ const displayedTask = computed(() => {
|
||||
const isTaskClosed = computed(() => displayedTask.value?.status === 'closed')
|
||||
const scopeLabel = computed(() => {
|
||||
if (!displayedTask.value) return '等待加载任务信息'
|
||||
const segments = []
|
||||
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(' / ') : '全量资产范围'
|
||||
return buildInventoryScopeLabel(displayedTask.value)
|
||||
})
|
||||
const myProgressPercent = computed(() => {
|
||||
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 }}
|
||||
</span>
|
||||
</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 class="flex shrink-0 flex-wrap gap-3 lg:w-[220px] lg:flex-col">
|
||||
@ -163,31 +147,413 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
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 {
|
||||
getNotificationList,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead
|
||||
} 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 {
|
||||
buildNotificationQuickReturnAssetKey,
|
||||
findNotificationQuickReturnRecord
|
||||
} from './notificationQuickReturn'
|
||||
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const activeDepartments = ref<Department[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 12
|
||||
const statusFilter = ref<'all' | 'unread'>('all')
|
||||
const loading = 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> = {
|
||||
person_created: '新人入职',
|
||||
@ -204,7 +570,48 @@ const CHANGE_TYPE_LABELS: Record<string, string> = {
|
||||
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 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) => {
|
||||
if (level === 'error') return '异常'
|
||||
@ -218,8 +625,122 @@ const getLevelBadgeClass = (level?: string) => {
|
||||
return 'badge-neutral'
|
||||
}
|
||||
|
||||
const summaryPairs = (item: NotificationItem) =>
|
||||
Object.entries(item.metadata?.counts || {})
|
||||
const normalizeChangeProcessingStatus = (
|
||||
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)
|
||||
.map(([key, count]) => ({
|
||||
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) => {
|
||||
if (!item.is_read) {
|
||||
await handleMarkRead(item)
|
||||
}
|
||||
|
||||
if (item.related_sync_job_id) {
|
||||
await router.push({
|
||||
path: '/settings',
|
||||
query: {
|
||||
tab: 'sync',
|
||||
syncJobId: String(item.related_sync_job_id)
|
||||
}
|
||||
})
|
||||
await openSyncDetail(item.related_sync_job_id)
|
||||
return
|
||||
}
|
||||
|
||||
await router.push(item.action_url || '/settings?tab=sync')
|
||||
window.location.href = item.action_url || '/settings?tab=sync'
|
||||
}
|
||||
|
||||
const handlePrevPage = () => {
|
||||
@ -310,7 +1036,31 @@ const handleNextPage = () => {
|
||||
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 () => {
|
||||
await loadDepartmentOptions()
|
||||
await notificationStore.fetchUnreadCount()
|
||||
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 scheduleTimes = syncStatus.value?.scheduleTimes || []
|
||||
const scheduleTimezone = syncStatus.value?.scheduleTimezone || 'Asia/Shanghai'
|
||||
if (!syncStatus.value?.enabled) {
|
||||
return '建议先确认源库连通后,再决定是否开启自动同步。'
|
||||
}
|
||||
return scheduleTimes.length > 0
|
||||
? `计划时间:${scheduleTimes.join('、')}`
|
||||
? `计划时间(${scheduleTimezone}):${scheduleTimes.join('、')}`
|
||||
: '已启用定时计划,但还没有读取到具体时间。'
|
||||
})
|
||||
|
||||
@ -729,7 +730,8 @@ const fetchStatus = async () => {
|
||||
enabled: false,
|
||||
complete: false,
|
||||
missingFields: [],
|
||||
scheduleTimes: []
|
||||
scheduleTimes: [],
|
||||
scheduleTimezone: 'Asia/Shanghai'
|
||||
}
|
||||
previewErrorMessage.value = resolveErrorMessage(error, '读取同步状态失败')
|
||||
} finally {
|
||||
|
||||
@ -150,7 +150,7 @@
|
||||
:disabled="busyTerminalId === terminal.id"
|
||||
@click="handleToggleStatus(terminal)"
|
||||
>
|
||||
{{ busyTerminalId === terminal.id ? '处理中...' : terminal.status === 'disabled' ? '重新启用' : '停用' }}
|
||||
{{ busyTerminalId === terminal.id ? '处理中...' : getToggleStatusText(terminal) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!terminal.is_default"
|
||||
@ -483,6 +483,14 @@ const getDispatchNotice = () => {
|
||||
return '默认终端离线时,系统只排队不改派,等它重新上线后继续自动打印。'
|
||||
}
|
||||
|
||||
const getToggleStatusText = (terminal: PrintTerminal) => {
|
||||
if (terminal.status === 'disabled') {
|
||||
return '重新启用'
|
||||
}
|
||||
|
||||
return '停用'
|
||||
}
|
||||
|
||||
const loadTerminals = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -570,7 +578,9 @@ const handleToggleStatus = async (terminal: PrintTerminal) => {
|
||||
const nextStatus = terminal.status === 'disabled' ? 'offline' : 'disabled'
|
||||
const confirmText =
|
||||
nextStatus === 'disabled'
|
||||
? '停用后新的远程打印任务不会再发给这台电脑,确定继续吗?'
|
||||
? terminal.is_default
|
||||
? '这台电脑当前是默认打印终端。停用后远程标签会暂停自动派发,直到你重新启用它或改设其他默认电脑。确定继续吗?'
|
||||
: '停用后新的远程打印任务不会再发给这台电脑,确定继续吗?'
|
||||
: '重新启用后,这台电脑会在助手下一次心跳后恢复接单状态,确定继续吗?'
|
||||
|
||||
if (!window.confirm(confirmText)) {
|
||||
|
||||
@ -6,9 +6,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.2.0</Version>
|
||||
<AssemblyVersion>0.2.0.0</AssemblyVersion>
|
||||
<FileVersion>0.2.0.0</FileVersion>
|
||||
<Version>0.2.5</Version>
|
||||
<AssemblyVersion>0.2.5.0</AssemblyVersion>
|
||||
<FileVersion>0.2.5.0</FileVersion>
|
||||
<Company>常州简尚家具有限公司</Company>
|
||||
<Product>固定资产打印助手</Product>
|
||||
<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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var bitmap = new Bitmap(CanvasWidth, CanvasHeight);
|
||||
@ -303,7 +349,13 @@ internal sealed class LabelPrintService
|
||||
|
||||
graphics.PageUnit = GraphicsUnit.Display;
|
||||
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;
|
||||
eventArgs.HasMorePages = pageIndex < pages.Count;
|
||||
};
|
||||
@ -311,6 +363,25 @@ internal sealed class LabelPrintService
|
||||
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)
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<AssetLabelItem> Items { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("rendered_labels")]
|
||||
public List<AssetLabelRenderedImage> RenderedLabels { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PrintJobDto
|
||||
|
||||
@ -324,7 +324,7 @@ internal sealed class PrintAgentRuntime : IDisposable
|
||||
}
|
||||
|
||||
var payload = ResolvePayload(job);
|
||||
if (payload?.Items is null || payload.Items.Count == 0)
|
||||
if (payload is null)
|
||||
{
|
||||
const string message = "当前打印任务缺少可用的资产标签数据,已阻止实际出纸。";
|
||||
await _apiClient.ReportJobStatusAsync(
|
||||
@ -342,11 +342,29 @@ internal sealed class PrintAgentRuntime : IDisposable
|
||||
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
|
||||
{
|
||||
var printedCount = await _labelPrintService.PrintAssetLabelsAsync(
|
||||
var printedCount = await _labelPrintService.PrintRenderedLabelImagesAsync(
|
||||
CurrentState.Configuration.SelectedPrinterName,
|
||||
payload.Items);
|
||||
payload.RenderedLabels);
|
||||
|
||||
await _apiClient.ReportJobStatusAsync(
|
||||
CurrentState.Configuration.ServerUrl,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
param(
|
||||
[string]$Version = '0.2.0',
|
||||
param(
|
||||
[string]$Version = '0.2.5',
|
||||
[string]$PublishedAt = '',
|
||||
[string]$DotnetPath = '',
|
||||
[string]$IsccPath = ''
|
||||
@ -39,11 +39,19 @@ $publishDir = Join-Path $scriptRoot 'dist\publish\win-x64'
|
||||
$installerDir = Join-Path $scriptRoot 'dist\installer'
|
||||
$releaseDir = Join-Path $repoRoot "downloads\print-agent\releases\$Version"
|
||||
$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
|
||||
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 `
|
||||
(Join-Path $scriptRoot 'FixedAssetPrintAgent\FixedAssetPrintAgent.csproj') `
|
||||
$projectFile `
|
||||
-c Release `
|
||||
-r win-x64 `
|
||||
--self-contained true `
|
||||
@ -51,7 +59,7 @@ New-Item -ItemType Directory -Force -Path $publishDir, $installerDir, $releaseDi
|
||||
-p:EnableCompressionInSingleFile=true `
|
||||
-o $publishDir
|
||||
|
||||
& $IsccPath (Join-Path $scriptRoot 'installer\FixedAssetPrintAgent.iss')
|
||||
& $IsccPath "/DMyAppVersion=$Version" $installerScript
|
||||
|
||||
$installerSource = Join-Path $installerDir "print-agent-setup-$Version.exe"
|
||||
if (-not (Test-Path $installerSource)) {
|
||||
@ -68,7 +76,7 @@ $releaseManifest = [ordered]@{
|
||||
version = $Version
|
||||
published_at = $PublishedAt
|
||||
filename = "print-agent-setup-$Version.exe"
|
||||
notes = '打印助手 v0.2.0,已切换为 .NET 8 原生 Windows 托盘版,带正式安装器、控制面板卸载入口、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。'
|
||||
notes = "打印助手 v$Version,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。"
|
||||
source_type = 'server_release'
|
||||
relative_path = "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
|
||||
published_at = $PublishedAt
|
||||
filename = 'print-agent-setup.exe'
|
||||
notes = '打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。'
|
||||
notes = '打印助手最新稳定版,当前为安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。'
|
||||
source_type = 'server_release'
|
||||
relative_path = 'downloads/print-agent/latest/print-agent-setup.exe'
|
||||
relativePath = 'downloads/print-agent/latest/print-agent-setup.exe'
|
||||
} | ConvertTo-Json -Depth 5
|
||||
|
||||
Set-Content -Path (Join-Path $releaseDir 'manifest.json') -Value $releaseManifest -Encoding UTF8
|
||||
Set-Content -Path (Join-Path $latestDir 'manifest.json') -Value $latestManifest -Encoding UTF8
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[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)
|
||||
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 MyAppVersion "0.2.0"
|
||||
#define MyAppName "固定资产打印助手"
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.2.5"
|
||||
#endif
|
||||
#define MyAppPublisher "常州简尚家具有限公司"
|
||||
#define MyAppExeName "FixedAssetPrintAgent.exe"
|
||||
#define MyAppId "com.jsgdzc.printagent.dotnet"
|
||||
@ -22,11 +24,18 @@ WizardStyle=modern
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
AppMutex=Jsgdzc.FixedAssetPrintAgent
|
||||
CloseApplications=yes
|
||||
CloseApplicationsFilter={#MyAppExeName}
|
||||
RestartApplications=no
|
||||
OutputDir={#MyAppOutputDir}
|
||||
OutputBaseFilename=print-agent-setup-{#MyAppVersion}
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimp"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "chinesesimp"; MessagesFile: "compiler:Default.isl,ChineseSimplified.isl"
|
||||
|
||||
[CustomMessages]
|
||||
chinesesimp.InstallationRunningHint=如果看到“打印助手正在运行”的提示,保持“自动关闭应用程序”即可,安装程序会先关闭旧版助手,再继续覆盖安装。
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
|
||||
@ -41,3 +50,11 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de
|
||||
|
||||
[Run]
|
||||
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