feat(资产流程): 完善工号借还盘点与打印闭环

This commit is contained in:
WuYongJie 2026-07-10 11:30:35 +08:00
parent d2a780a20e
commit f280380720
71 changed files with 5001 additions and 636 deletions

View File

@ -0,0 +1,5 @@
ALTER TABLE `person`
ADD COLUMN `job_number` VARCHAR(50) NULL;
CREATE UNIQUE INDEX `person_job_number_key`
ON `person`(`job_number`);

View File

@ -268,6 +268,7 @@ model Person {
id Int @id @default(autoincrement())
nickname String @db.VarChar(50)
real_name String @db.VarChar(50)
job_number String? @unique @db.VarChar(50)
id_number String? @unique @db.VarChar(50)
department String? @db.VarChar(100)
main_department_id Int?

View File

@ -0,0 +1,58 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const {
buildAssignableExistingSheet2PublicEquipmentWhere,
PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES,
buildPublicAssetBorrowState,
isSheet2PublicEquipmentDescription
} = require('../sheet2PublicAssetImportUtils')
test('isSheet2PublicEquipmentDescription 只识别总表待导入 Sheet2 公共设备描述', () => {
assert.equal(
isSheet2PublicEquipmentDescription(
'source_workbook=docs/总表待导入.xlsx; source_sheet=Sheet2; source_row=66'
),
true
)
assert.equal(
isSheet2PublicEquipmentDescription(
'source_workbook=docs/8.xlsx; source_sheet=Sheet1; source_row=66'
),
false
)
assert.equal(isSheet2PublicEquipmentDescription(''), false)
})
test('buildPublicAssetBorrowState 应把公共资产挂名状态统一转成已借出', () => {
assert.deepEqual(buildPublicAssetBorrowState(0), {
equipmentStatus: '已借出',
remainingQuantity: 0,
totalValue: 0,
borrowedValue: 0,
availableValue: 0
})
assert.deepEqual(buildPublicAssetBorrowState('1280.50'), {
equipmentStatus: '已借出',
remainingQuantity: 0,
totalValue: 1280.5,
borrowedValue: 1280.5,
availableValue: 0
})
})
test('PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES 只允许当前可用的既有公共设备自动挂名', () => {
assert.deepEqual(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES, ['可用'])
})
test('buildAssignableExistingSheet2PublicEquipmentWhere 只筛可用且未在借的 Sheet2 公共设备', () => {
assert.deepEqual(buildAssignableExistingSheet2PublicEquipmentWhere(), {
AND: [
{ description: { contains: 'source_workbook=docs/总表待导入.xlsx' } },
{ description: { contains: 'source_sheet=Sheet2' } }
],
status: { in: ['可用'] },
borrow_records: { none: { status: '已借出' } }
})
})

View File

@ -0,0 +1,113 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const {
mergeCandidateRowsWithSourceCodes,
resolveSheet2LocationMatch
} = require('../sheet2PublicSupplementUtils')
const buildLocationByName = (names) =>
new Map(
names.map((name, index) => [
String(name).trim().toUpperCase(),
{
id: index + 1,
name
}
])
)
test('mergeCandidateRowsWithSourceCodes 应补回候选集中漏掉的显式编码且不重复已有记录', () => {
const candidateRows = [
{
来源工作表: 'Sheet2',
资产编码: 'JS-DZ-0172',
资产名称: '打印机'
}
]
const sourceRows = [
{
来源工作表: 'Sheet2',
资产编码: 'JS-DZ-0168',
资产名称: '打印机-喷墨'
},
{
来源工作表: 'Sheet2',
资产编码: 'JS-DZ-0172',
资产名称: '打印机'
}
]
const result = mergeCandidateRowsWithSourceCodes(candidateRows, sourceRows, [
'JS-DZ-0168',
'JS-DZ-0172',
'JS-DZ-9999'
])
assert.deepEqual(
result.rows.map((row) => row.资产编码),
['JS-DZ-0172', 'JS-DZ-0168']
)
assert.deepEqual(result.missingCodes, ['JS-DZ-9999'])
assert.deepEqual(result.duplicateSourceCodes, [])
})
test('mergeCandidateRowsWithSourceCodes 遇到源表同编码多行冲突时不应自动选第一条', () => {
const result = mergeCandidateRowsWithSourceCodes(
[],
[
{
来源工作表: 'Sheet2',
资产编码: 'JS-DQ-0373',
资产名称: '冰箱',
地址: '佛山顺德仓'
},
{
来源工作表: 'Sheet2',
资产编码: 'JS-DQ-0373',
资产名称: '空调',
地址: '天安'
}
],
['JS-DQ-0373']
)
assert.deepEqual(result.rows, [])
assert.deepEqual(result.missingCodes, [])
assert.deepEqual(result.duplicateSourceCodes.map((item) => item.code), ['JS-DQ-0373'])
assert.equal(result.duplicateSourceCodes[0].rows.length, 2)
})
test('resolveSheet2LocationMatch 应识别带仓储前缀的苏北仓地址', () => {
const result = resolveSheet2LocationMatch(
'仓储一部 苏北仓',
buildLocationByName(['苏北仓库', '常州-仓库'])
)
assert.equal(result.normalizedValue, '苏北仓库')
assert.equal(result.match?.name, '苏北仓库')
assert.equal(result.rule, 'sheet2-prefix-override')
})
test('resolveSheet2LocationMatch 应识别带仓储前缀的崔桥工厂仓地址', () => {
const result = resolveSheet2LocationMatch(
'仓储二部 崔桥工厂仓',
buildLocationByName(['常州-仓库'])
)
assert.equal(result.normalizedValue, '常州-仓库')
assert.equal(result.match?.name, '常州-仓库')
assert.equal(result.rule, 'sheet2-prefix-alias')
})
test('resolveSheet2LocationMatch 对未明确确认的地址不应猜测映射', () => {
const result = resolveSheet2LocationMatch(
'仓储二部 山东仓',
buildLocationByName(['济南', '常州-仓库'])
)
assert.equal(result.match, null)
assert.equal(result.normalizedValue, '山东仓')
assert.equal(result.rule, 'sheet2-prefix-stripped')
})

View File

@ -0,0 +1,80 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const prepareScript = require('../prepareSheet2SupplementDryRun')
const importScript = require('../importSheet2PublicAssetsToLive')
const explicitConnectionArgs = [
'--plink-path',
'plink-test.exe',
'--ssh-host',
'deploy@example.invalid',
'--ssh-hostkey',
'SHA256:test-host-key',
'--ssh-password',
'test-secret-value'
]
test('Sheet2 导入脚本默认只能 preview,只有显式 --apply 才允许写入', () => {
assert.deepEqual(importScript.parseArgs([]), {
inputDir: '',
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
})
const applyOptions = importScript.parseArgs(['--apply'])
assert.equal(applyOptions.apply, true)
assert.equal(applyOptions.dryRun, false)
const previewOptions = importScript.parseArgs(['--dry-run'])
assert.equal(previewOptions.apply, false)
assert.equal(previewOptions.dryRun, true)
})
test('Sheet2 导入脚本拒绝冲突或拼错的写入参数', () => {
assert.throws(
() => importScript.parseArgs(['--apply', '--dry-run']),
/不能同时使用/
)
assert.throws(() => importScript.parseArgs(['--aply']), /不支持的参数/)
assert.throws(() => prepareScript.parseArgs(['--ssh-host']), /缺少参数值/)
})
test('两个 Sheet2 脚本缺少连接参数时应在联网前明确失败且不包含秘密', () => {
for (const script of [prepareScript, importScript]) {
const options = script.parseArgs(['--ssh-password', 'test-secret-value'])
assert.throws(
() => script.resolveRemoteConnection(options, {}),
(error) => {
assert.match(error.message, /缺少固定资产远程连接参数/)
assert.match(error.message, /FIXED_ASSET_SSH_HOST/)
assert.match(error.message, /未发起网络连接/)
assert.doesNotMatch(error.message, /test-secret-value/)
return true
}
)
}
})
test('两个 Sheet2 脚本支持显式连接参数且 CLI 优先于环境变量', () => {
const env = {
FIXED_ASSET_PLINK_PATH: 'env-plink.exe',
FIXED_ASSET_SSH_HOST: 'env@example.invalid',
FIXED_ASSET_SSH_HOSTKEY: 'SHA256:env-key',
FIXED_ASSET_SSH_PASSWORD: 'env-secret'
}
for (const script of [prepareScript, importScript]) {
const options = script.parseArgs(explicitConnectionArgs)
assert.deepEqual(script.resolveRemoteConnection(options, env), {
plinkPath: 'plink-test.exe',
sshHost: 'deploy@example.invalid',
sshHostkey: 'SHA256:test-host-key',
sshPassword: 'test-secret-value'
})
}
})

View File

@ -0,0 +1,34 @@
-- 仅用于“100% 可确定”的历史借用记录工号回填。
-- 执行前必须先确认:
-- 1. person.job_number 已由可信 HR 同步写入
-- 2. 本次只允许回填当前 person_identifier 为空的记录
-- 3. 只处理 source_system = 'ding_hr' 且 source_userid 非空的可信同步人员
SELECT
br.id,
br.equipment_code,
br.person_id,
br.person_name,
br.person_nickname,
br.person_identifier AS current_identifier,
p.job_number AS target_job_number,
p.source_userid,
p.source_system
FROM borrow_record br
JOIN person p ON p.id = br.person_id
WHERE (br.person_identifier IS NULL OR TRIM(br.person_identifier) = '')
AND p.job_number IS NOT NULL
AND TRIM(p.job_number) <> ''
AND p.source_system = 'ding_hr'
AND p.source_userid IS NOT NULL
ORDER BY br.id;
-- 确认上面的候选数据无误后,再手动执行下面的 UPDATE。
-- UPDATE borrow_record br
-- JOIN person p ON p.id = br.person_id
-- SET br.person_identifier = p.job_number
-- WHERE (br.person_identifier IS NULL OR TRIM(br.person_identifier) = '')
-- AND p.job_number IS NOT NULL
-- AND TRIM(p.job_number) <> ''
-- AND p.source_system = 'ding_hr'
-- AND p.source_userid IS NOT NULL;

View File

@ -0,0 +1,613 @@
let PrismaClient
try {
;({ PrismaClient } = require('@prisma/client'))
} catch {
;({ PrismaClient } = require('/app/node_modules/@prisma/client'))
}
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const {
buildAssignableExistingSheet2PublicEquipmentWhere,
PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES,
buildPublicAssetBorrowState,
isSheet2PublicEquipmentDescription,
normalizeText
} = require('./sheet2PublicAssetImportUtils')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node'
const DEFAULT_PUBLIC_PERSON = {
nickname: '公共资产',
real_name: '公共资产',
job_number: null,
id_number: null,
department: null,
main_department_id: null,
position: '系统保留',
phone: null,
email: null,
status: 'active',
remarks: '系统保留人员:用于承接公共设备,不参与 HR 组织同步。',
source_userid: '__public_asset__',
source_system: 'system_reserved',
source_main_department_id: null,
source_dept_ids: null,
source_department_name: null,
source_main_department_name: null,
department_sync_bucket: null,
manager_source_userid: null,
source_employee_status: null,
last_synced_at: null
}
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
inputDir: '',
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
let explicitDryRun = false
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--dry-run') {
explicitDryRun = true
} else if (arg === '--apply') {
options.apply = true
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
if (options.apply && explicitDryRun) {
throw new Error('--apply 与 --dry-run 不能同时使用')
}
options.dryRun = !options.apply
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const ensureOutputDir = (prefix) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const resolveLatestSheet2Dir = () => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('sheet2_public_dry_run_'))
.map((entry) => entry.name)
.sort()
if (!candidates.length) {
throw new Error(`未找到 Sheet2 公共设备 dry-run 输出目录: ${OUTPUT_ROOT}`)
}
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const resolveInputDir = (inputDirArg) => {
if (!inputDirArg) {
return resolveLatestSheet2Dir()
}
const resolved = path.isAbsolute(inputDirArg)
? inputDirArg
: path.resolve(process.cwd(), inputDirArg)
if (!fs.existsSync(resolved)) {
throw new Error(`指定的输入目录不存在: ${resolved}`)
}
return resolved
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))
const runRemoteNodeScript = (script, connection) => {
const result = spawnSync(
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
maxBuffer: 64 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('远程脚本返回为空')
}
try {
return JSON.parse(stdout)
} catch {
throw new Error('远程脚本返回格式无效')
}
}
const buildRemotePreviewScript = (payload) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
const seedBase64 = Buffer.from(JSON.stringify(DEFAULT_PUBLIC_PERSON), 'utf8').toString('base64')
const assignableExistingWhereBase64 = Buffer.from(
JSON.stringify(
buildAssignableExistingSheet2PublicEquipmentWhere(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES)
),
'utf8'
).toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const publicSeed = JSON.parse(Buffer.from('${seedBase64}', 'base64').toString('utf8'))
const assignableExistingWhere = JSON.parse(Buffer.from('${assignableExistingWhereBase64}', 'base64').toString('utf8'))
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\\r/g, ' ')
.replace(/\\n/g, ' ')
.replace(/\\s+/g, ' ')
.trim()
;(async () => {
const payloadCodes = payload.map((item) => normalizeText(item.legacyCode).toUpperCase()).filter(Boolean)
const [existingCodes, assignableExisting, publicAssetPerson] = await Promise.all([
prisma.equipment.findMany({
where: { equipment_code: { in: payloadCodes } },
select: { equipment_code: true, equipment_name: true, status: true },
orderBy: { equipment_code: 'asc' }
}),
prisma.equipment.findMany({
where: assignableExistingWhere,
select: { id: true, equipment_code: true, equipment_name: true, status: true },
orderBy: { equipment_code: 'asc' }
}),
prisma.person.findFirst({
where: { source_userid: publicSeed.source_userid },
select: { id: true, nickname: true, status: true, source_system: true }
})
])
console.log(JSON.stringify({
payloadCount: payload.length,
existingPayloadCodeCount: existingCodes.length,
existingPayloadCodes: existingCodes,
assignableExistingCount: assignableExisting.length,
assignableExistingSample: assignableExisting.slice(0, 30),
publicAssetPerson
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const buildRemoteApplyScript = (payload) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
const seedBase64 = Buffer.from(JSON.stringify(DEFAULT_PUBLIC_PERSON), 'utf8').toString('base64')
const assignableExistingWhereBase64 = Buffer.from(
JSON.stringify(
buildAssignableExistingSheet2PublicEquipmentWhere(PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES)
),
'utf8'
).toString('base64')
return `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const publicSeed = JSON.parse(Buffer.from('${seedBase64}', 'base64').toString('utf8'))
const assignableExistingWhere = JSON.parse(Buffer.from('${assignableExistingWhereBase64}', 'base64').toString('utf8'))
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\\r/g, ' ')
.replace(/\\n/g, ' ')
.replace(/\\s+/g, ' ')
.trim()
const buildBorrowState = (unitPrice) => {
const normalizedUnitPrice = Number(unitPrice || 0)
return {
equipmentStatus: '已借出',
remainingQuantity: 0,
totalValue: normalizedUnitPrice,
borrowedValue: normalizedUnitPrice,
availableValue: 0
}
}
const importResults = []
const skippedExistingImports = []
const assignedExisting = []
;(async () => {
const publicAssetPerson = await prisma.$transaction(async (tx) => {
const person = await tx.person.upsert({
where: { source_userid: publicSeed.source_userid },
create: publicSeed,
update: {
nickname: publicSeed.nickname,
real_name: publicSeed.real_name,
department: null,
main_department_id: null,
position: publicSeed.position,
status: 'active',
remarks: publicSeed.remarks,
source_system: publicSeed.source_system,
source_main_department_id: null,
source_dept_ids: null,
source_department_name: null,
source_main_department_name: null,
department_sync_bucket: null,
manager_source_userid: null,
source_employee_status: null,
last_synced_at: null
}
})
await tx.personDepartment.deleteMany({
where: { person_id: person.id }
})
return person
})
for (const item of payload) {
const legacyCode = normalizeText(item.legacyCode).toUpperCase()
if (!legacyCode) {
continue
}
const existing = await prisma.equipment.findUnique({
where: { equipment_code: legacyCode },
select: { id: true, equipment_code: true, equipment_name: true, status: true }
})
if (existing) {
skippedExistingImports.push(existing)
continue
}
const [assetType, department, location] = await Promise.all([
prisma.assetType.findFirst({
where: { name: normalizeText(item.equipmentType), status: 'active' },
select: { id: true, name: true }
}),
normalizeText(item.departmentName)
? prisma.department.findFirst({
where: { name: normalizeText(item.departmentName), status: 'active' },
select: { id: true, name: true }
})
: Promise.resolve(null),
normalizeText(item.storageLocationName)
? prisma.storageLocation.findFirst({
where: { name: normalizeText(item.storageLocationName), status: 'active' },
select: { id: true, name: true }
})
: Promise.resolve(null)
])
if (!assetType) {
throw new Error('资产类型不存在或未启用: ' + normalizeText(item.equipmentType) + ' code=' + legacyCode)
}
if (normalizeText(item.departmentName) && !department) {
throw new Error('部门不存在或未启用: ' + normalizeText(item.departmentName) + ' code=' + legacyCode)
}
if (normalizeText(item.storageLocationName) && !location) {
throw new Error('地址不存在或未启用: ' + normalizeText(item.storageLocationName) + ' code=' + legacyCode)
}
const unitPrice = Number(item.unitPrice || 0)
const purchaseDate = normalizeText(item.purchaseDate) ? new Date(item.purchaseDate) : null
const borrowTime = purchaseDate || new Date()
const borrowState = buildBorrowState(unitPrice)
const created = await prisma.$transaction(async (tx) => {
const equipment = await tx.equipment.create({
data: {
equipment_code: legacyCode,
equipment_name: normalizeText(item.equipmentName),
equipment_model: normalizeText(item.equipmentModel) || null,
equipment_type: assetType.name,
code_generation_mode: 'manual',
status: borrowState.equipmentStatus,
total_quantity: 1,
remaining_quantity: borrowState.remainingQuantity,
unit_price: unitPrice,
total_value: borrowState.totalValue,
borrowed_value: borrowState.borrowedValue,
available_value: borrowState.availableValue,
total_value_with_children: borrowState.totalValue,
description: normalizeText(item.description) || null,
purchase_date: purchaseDate,
supplier: normalizeText(item.supplier) || null,
storage_location_id: location?.id || null,
asset_type_id: assetType.id,
department_id: department?.id || null,
mount_status: 'none'
}
})
await tx.borrowRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
person_id: publicAssetPerson.id,
person_name: publicAssetPerson.real_name,
person_nickname: publicAssetPerson.nickname,
person_identifier: publicAssetPerson.job_number || null,
borrow_time: borrowTime,
expected_return_time: null,
is_long_term: true,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: '总表待导入 Sheet2 公共设备补导;系统挂名到公共资产'
}
})
return {
id: equipment.id,
equipment_code: equipment.equipment_code,
equipment_name: equipment.equipment_name,
status: equipment.status
}
})
importResults.push(created)
}
const existingCandidates = await prisma.equipment.findMany({
where: assignableExistingWhere,
select: {
id: true,
equipment_code: true,
equipment_name: true,
unit_price: true,
purchase_date: true,
status: true
},
orderBy: { equipment_code: 'asc' }
})
for (const equipment of existingCandidates) {
const borrowState = buildBorrowState(equipment.unit_price)
const borrowTime = equipment.purchase_date || new Date()
const assigned = await prisma.$transaction(async (tx) => {
const updatedEquipment = await tx.equipment.update({
where: { id: equipment.id },
data: {
status: borrowState.equipmentStatus,
remaining_quantity: borrowState.remainingQuantity,
total_value: borrowState.totalValue,
borrowed_value: borrowState.borrowedValue,
available_value: borrowState.availableValue,
total_value_with_children: borrowState.totalValue
}
})
await tx.borrowRecord.create({
data: {
equipment_id: updatedEquipment.id,
equipment_code: updatedEquipment.equipment_code,
person_id: publicAssetPerson.id,
person_name: publicAssetPerson.real_name,
person_nickname: publicAssetPerson.nickname,
person_identifier: publicAssetPerson.job_number || null,
borrow_time: borrowTime,
expected_return_time: null,
is_long_term: true,
return_time: null,
status: '已借出',
return_condition: '正常',
notes: '总表待导入 Sheet2 公共设备补挂;系统挂名到公共资产'
}
})
return {
id: updatedEquipment.id,
equipment_code: updatedEquipment.equipment_code,
equipment_name: updatedEquipment.equipment_name,
status: updatedEquipment.status
}
})
assignedExisting.push(assigned)
}
const verification = {
publicAssetPerson: {
id: publicAssetPerson.id,
nickname: publicAssetPerson.nickname,
realName: publicAssetPerson.real_name
},
importedCount: importResults.length,
skippedExistingImportCount: skippedExistingImports.length,
assignedExistingCount: assignedExisting.length,
sheet2PublicEquipmentCount: await prisma.equipment.count({
where: {
AND: [
{ description: { contains: 'source_workbook=docs/总表待导入.xlsx' } },
{ description: { contains: 'source_sheet=Sheet2' } }
]
}
}),
activePublicBorrowCount: await prisma.borrowRecord.count({
where: {
person_id: publicAssetPerson.id,
status: '已借出',
equipment: {
AND: [
{ description: { contains: 'source_workbook=docs/总表待导入.xlsx' } },
{ description: { contains: 'source_sheet=Sheet2' } }
]
}
}
})
}
console.log(JSON.stringify({
publicAssetPerson: verification.publicAssetPerson,
importedCount: importResults.length,
skippedExistingImportCount: skippedExistingImports.length,
assignedExistingCount: assignedExisting.length,
importedSample: importResults.slice(0, 30),
assignedExistingSample: assignedExisting.slice(0, 30),
skippedExistingImportSample: skippedExistingImports.slice(0, 30),
verification
}))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
}
const run = () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const inputDir = resolveInputDir(options.inputDir)
const payloadPath = path.join(inputDir, 'sheet2_public_ready_payload.json')
if (!fs.existsSync(payloadPath)) {
throw new Error(`未找到 sheet2_public_ready_payload.json: ${payloadPath}`)
}
const payloadDocument = readJson(payloadPath)
const payload = Array.isArray(payloadDocument.payload) ? payloadDocument.payload : []
if (!payload.length) {
throw new Error('可导入 payload 为空')
}
const backupDir = ensureOutputDir(options.dryRun ? 'sheet2_public_preview' : 'sheet2_public_apply')
writeJson(path.join(backupDir, 'planned_payload.json'), {
inputDir,
payloadCount: payload.length,
payload
})
const preview = runRemoteNodeScript(buildRemotePreviewScript(payload), connection)
writeJson(path.join(backupDir, 'preview_result.json'), preview)
if (options.dryRun) {
console.log(JSON.stringify({ backupDir, inputDir, dryRun: true, preview }, null, 2))
return
}
const applyResult = runRemoteNodeScript(buildRemoteApplyScript(payload), connection)
writeJson(path.join(backupDir, 'apply_result.json'), applyResult)
console.log(
JSON.stringify(
{
backupDir,
inputDir,
preview,
applyResult
},
null,
2
)
)
}
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -4,7 +4,12 @@ const { spawnSync } = require('child_process')
const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs'))
const { resolveStorageLocationMatch } = require('./locationNormalization')
const {
mergeCandidateRowsWithSourceCodes,
normalizeLookupKey,
normalizeText,
resolveSheet2LocationMatch
} = require('./sheet2PublicSupplementUtils')
const DEFAULT_SOURCE_WORKBOOK = path.resolve(__dirname, '../../docs/总表待导入.xlsx')
const DEFAULT_SOURCE_SHEET = 'Sheet2'
@ -16,11 +21,6 @@ const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DOCS_ROOT = path.resolve(__dirname, '../../docs')
const DEFAULT_OUTPUT_WORKBOOK_BASENAME = '总表待导入-Sheet2_公共设备补导分流-2026-03-27.xlsx'
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || 'jsjj8888'
const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node'
const DEPARTMENT_ALIASES = {
@ -64,95 +64,91 @@ const PUBLIC_STATUS_MAP = {
闲置: '可用'
}
const SHEET2_LOCATION_OVERRIDES = {
山东: '济南',
运营中心: '常州-天安',
客服中心: '常州-天安',
人力行政中心: '常州-天安',
庙西宿舍: '常州-仓库',
财务中心: '常州-天安',
天安17楼: '常州-天安',
运营中心B: '常州-天安',
供应链中心: '常州-天安',
'2楼': '常州-天安',
湖塘摄影棚: '影棚',
睢宁仓: '睢宁仓库',
横林二仓仓库: '常州-仓库',
横山桥: '常州-仓库',
客服二部: '常州-天安',
苏北仓: '苏北仓库',
仓储中心: '常州-仓库',
横林二仓二楼会议室: '常州-仓库',
横林二仓二楼张总办公室: '常州-仓库',
红星美凯龙: '常州-美凯龙',
天安三楼: '常州-天安',
'1楼 客服四部': '常州-天安',
'3楼 智管信息中心': '常州-天安',
常州运c: '常州-天安',
二楼: '常州-天安',
分销外贸: '常州-天安',
佛山顺德仓: '佛山-顺德仓库',
河北仓: '河北-仓库',
横林二仓采购办公室: '常州-仓库',
横林二仓仓库办公室: '常州-仓库',
横林二仓二楼顾总办公室: '常州-仓库',
横林二仓二楼贾总办公室: '常州-仓库',
横林二仓二楼朱总办公室: '常州-仓库',
横林二仓一楼仓储办公室: '常州-仓库',
客服六部: '常州-天安',
三楼: '常州-天安',
山东曹县: '山东-曹县',
事业一部: '常州-天安',
天安10号楼三楼机房: '常州-天安',
天安10号楼四楼厨房: '常州-天安',
天安10号楼一楼机房: '常州-天安',
天安四楼会议室: '常州-天安',
运营八部: '常州-天安',
运营十部: '常州-天安',
总经理室: '常州-天安'
}
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const buildTimestampLabel = () =>
new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const parseArgs = () => {
const args = process.argv.slice(2)
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
sourceWorkbook: DEFAULT_SOURCE_WORKBOOK,
sourceSheet: DEFAULT_SOURCE_SHEET,
candidateWorkbook: DEFAULT_CANDIDATE_WORKBOOK,
outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME)
outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME),
assetCodes: [],
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--source-workbook') {
options.sourceWorkbook = args[index + 1] || options.sourceWorkbook
options.sourceWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--source-sheet') {
options.sourceSheet = args[index + 1] || options.sourceSheet
options.sourceSheet = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--candidate-workbook') {
options.candidateWorkbook = args[index + 1] || options.candidateWorkbook
options.candidateWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--output-workbook') {
options.outputWorkbook = args[index + 1] || options.outputWorkbook
options.outputWorkbook = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--asset-codes') {
options.assetCodes = String(requireArgValue(args, index, arg))
.split(/[,\s]+/)
.map((item) => normalizeText(item).toUpperCase())
.filter(Boolean)
index += 1
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const resolvePathArg = (value) => {
const normalized = normalizeText(value)
if (!normalized) {
@ -255,21 +251,26 @@ const loadSourceRowMap = (sourceWorkbookPath, sourceSheetName) => {
return rowMap
}
const loadSourceRows = (sourceWorkbookPath, sourceSheetName) => {
const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false })
const sheet = workbook.Sheets[sourceSheetName]
if (!sheet) {
throw new Error(`未找到源工作表: ${sourceSheetName}`)
}
return XLSX.utils.sheet_to_json(sheet, { defval: '' }).map((row) => ({
...row,
来源工作表: normalizeText(row.来源工作表) || sourceSheetName
}))
}
const loadCandidateRows = (candidateWorkbookPath) => {
const workbook = XLSX.readFile(candidateWorkbookPath, { raw: false })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet, { defval: '' })
}
const fetchLiveRefs = () => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 SSH 密码,无法读取线上固定资产基础数据')
}
const fetchLiveRefs = (connection) => {
const script = `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
@ -303,8 +304,17 @@ const prisma = new PrismaClient()
`
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
@ -314,11 +324,11 @@ const prisma = new PrismaClient()
)
if (result.error) {
throw result.error
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error(`读取线上基础数据失败: ${result.stderr || result.stdout}`)
throw new Error('读取线上基础数据失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
@ -326,7 +336,11 @@ const prisma = new PrismaClient()
throw new Error('线上基础数据返回为空')
}
return JSON.parse(stdout)
try {
return JSON.parse(stdout)
} catch {
throw new Error('线上基础数据返回格式无效')
}
}
const buildImportDescription = (row, sourceRow) => {
@ -343,23 +357,6 @@ const buildImportDescription = (row, sourceRow) => {
return parts.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`).join('; ')
}
const resolveSheet2LocationMatch = (rawValue, locationByName) => {
const normalized = normalizeText(rawValue)
const override = SHEET2_LOCATION_OVERRIDES[normalized]
if (override) {
const match = locationByName.get(normalizeLookupKey(override)) || null
return {
rawValue: normalized,
normalizedValue: override,
match,
rule: 'sheet2-confirmed-override'
}
}
return resolveStorageLocationMatch(rawValue, locationByName)
}
const buildReadyImportPayload = (rows) =>
rows.map((row) => ({
legacyCode: row.资产编码,
@ -375,7 +372,7 @@ const buildReadyImportPayload = (rows) =>
description: row.导入备注
}))
const classifyRows = (candidateRows, sourceRowMap, liveRefs) => {
const createRowContext = (liveRefs) => {
const departmentByName = new Map(
liveRefs.departments.map((item) => [normalizeLookupKey(item.name), item])
)
@ -383,52 +380,65 @@ const classifyRows = (candidateRows, sourceRowMap, liveRefs) => {
liveRefs.locations.map((item) => [normalizeLookupKey(item.name), item])
)
return { departmentByName, locationByName }
}
const classifySingleRow = (rawRow, sourceRowMap, rowContext) => {
const { departmentByName, locationByName } = rowContext
const assetCode = normalizeText(rawRow['资产编码']).toUpperCase()
const assetName = normalizeText(rawRow['资产名称'])
const rawDepartment = normalizeText(rawRow['部门'])
const resolvedDepartment = DEPARTMENT_ALIASES[rawDepartment] || rawDepartment
const departmentMatch = resolvedDepartment
? departmentByName.get(normalizeLookupKey(resolvedDepartment)) || null
: null
const rawLocation = normalizeText(rawRow['地址'])
const locationMatch = resolveSheet2LocationMatch(rawLocation, locationByName)
const rawStatus = normalizeText(rawRow['当前状态'])
const systemStatus = PUBLIC_STATUS_MAP[rawStatus] || ''
const sourceRow = sourceRowMap.get(assetCode) || ''
const baseRow = {
来源工作表: normalizeText(rawRow['来源工作表']) || 'Sheet2',
源数据行号: sourceRow,
资产编码: assetCode,
资产名称: assetName,
当前状态: rawStatus,
系统建议状态: systemStatus,
资产分类: normalizeText(rawRow['资产分类']),
部门: rawDepartment,
系统归并部门: departmentMatch?.name || resolvedDepartment,
地址: rawLocation,
系统归并地址: locationMatch.match?.name || locationMatch.normalizedValue,
地址归并规则: locationMatch.rule || '',
规格型号: normalizeText(rawRow['规格型号']),
导入备注: buildImportDescription(rawRow, sourceRow)
}
const reasons = []
if (!departmentMatch) {
reasons.push(`部门未匹配:${rawDepartment || '(空)'}`)
}
if (!locationMatch.match) {
reasons.push(`地址未匹配:${rawLocation || '(空)'}`)
}
if (!systemStatus) {
reasons.push(`状态无法自动映射:${rawStatus || '(空)'}`)
}
return { baseRow, reasons }
}
const classifyRows = (candidateRows, sourceRowMap, liveRefs) => {
const rowContext = createRowContext(liveRefs)
const readyRows = []
const pendingRows = []
for (const rawRow of candidateRows) {
const assetCode = normalizeText(rawRow['资产编码']).toUpperCase()
const assetName = normalizeText(rawRow['资产名称'])
const rawDepartment = normalizeText(rawRow['部门'])
const resolvedDepartment = DEPARTMENT_ALIASES[rawDepartment] || rawDepartment
const departmentMatch = resolvedDepartment
? departmentByName.get(normalizeLookupKey(resolvedDepartment)) || null
: null
const rawLocation = normalizeText(rawRow['地址'])
const locationMatch = resolveSheet2LocationMatch(rawLocation, locationByName)
const rawStatus = normalizeText(rawRow['当前状态'])
const systemStatus = PUBLIC_STATUS_MAP[rawStatus] || ''
const sourceRow = sourceRowMap.get(assetCode) || ''
const baseRow = {
来源工作表: normalizeText(rawRow['来源工作表']) || 'Sheet2',
源数据行号: sourceRow,
资产编码: assetCode,
资产名称: assetName,
当前状态: rawStatus,
系统建议状态: systemStatus,
资产分类: normalizeText(rawRow['资产分类']),
部门: rawDepartment,
系统归并部门: departmentMatch?.name || resolvedDepartment,
地址: rawLocation,
系统归并地址: locationMatch.match?.name || locationMatch.normalizedValue,
地址归并规则: locationMatch.rule || '',
规格型号: normalizeText(rawRow['规格型号']),
导入备注: buildImportDescription(rawRow, sourceRow)
}
const reasons = []
if (!departmentMatch) {
reasons.push(`部门未匹配:${rawDepartment || '(空)'}`)
}
if (!locationMatch.match) {
reasons.push(`地址未匹配:${rawLocation || '(空)'}`)
}
if (!systemStatus) {
reasons.push(`状态无法自动映射:${rawStatus || '(空)'}`)
}
const { baseRow, reasons } = classifySingleRow(rawRow, sourceRowMap, rowContext)
if (!reasons.length) {
readyRows.push(baseRow)
@ -447,12 +457,39 @@ const classifyRows = (candidateRows, sourceRowMap, liveRefs) => {
return { readyRows, pendingRows }
}
const buildSummaryRows = ({ total, ready, pending, addressPending, otherPending, liveRefs }) => [
const buildDuplicatePendingRows = (duplicateSourceCodes, sourceRowMap, liveRefs) => {
const rowContext = createRowContext(liveRefs)
return duplicateSourceCodes.map((item) => {
const firstRow = item.rows[0] || {}
const { baseRow } = classifySingleRow(firstRow, sourceRowMap, rowContext)
return {
...baseRow,
待核对原因: '同一资产编码在 Sheet2 内对应了不同设备信息,不能自动判断。',
建议处理: '同一编码对应了多条不同设备信息,先确认哪条才是真实记录'
}
})
}
const buildSummaryRows = ({
total,
ready,
pending,
addressPending,
otherPending,
liveRefs,
explicitCodeCount,
explicitMissingCodeCount,
duplicatePendingCount
}) => [
{ 项目: '候选总数', 说明: total },
{ 项目: '可直接补录', 说明: ready },
{ 项目: '待人工核对', 说明: pending },
{ 项目: '其中地址待核对', 说明: addressPending },
{ 项目: '其中其他待核对', 说明: otherPending },
{ 项目: '本次显式补回编码数', 说明: explicitCodeCount },
{ 项目: '显式编码仍未在源表找到', 说明: explicitMissingCodeCount },
{ 项目: '显式编码重复冲突数', 说明: duplicatePendingCount },
{ 项目: '状态映射规则', 说明: '在用/闲置 -> 系统“可用”;其余状态不自动导入' },
{ 项目: '人员处理规则', 说明: '本批当前使用员工全部为空,按公共设备处理,不自动建立借出记录' },
{ 项目: '线上部门数', 说明: liveRefs.departments.length },
@ -462,6 +499,7 @@ const buildSummaryRows = ({ total, ready, pending, addressPending, otherPending,
const run = async () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const sourceWorkbookPath = resolvePathArg(options.sourceWorkbook)
const candidateWorkbookPath = resolvePathArg(options.candidateWorkbook)
@ -469,19 +507,29 @@ const run = async () => {
throw new Error(`源工作簿不存在: ${sourceWorkbookPath}`)
}
if (!fs.existsSync(candidateWorkbookPath)) {
if (!fs.existsSync(candidateWorkbookPath) && options.assetCodes.length === 0) {
throw new Error(`候选工作簿不存在: ${candidateWorkbookPath}`)
}
const outputDir = ensureOutputDir()
const workbookPath = resolveAvailableWorkbookPath(options.outputWorkbook)
const sourceRowMap = loadSourceRowMap(sourceWorkbookPath, options.sourceSheet)
const candidateRows = loadCandidateRows(candidateWorkbookPath)
const liveRefs = fetchLiveRefs()
const sourceRows = loadSourceRows(sourceWorkbookPath, options.sourceSheet)
const baseCandidateRows = fs.existsSync(candidateWorkbookPath)
? loadCandidateRows(candidateWorkbookPath)
: []
const {
rows: candidateRows,
missingCodes: missingSourceCodes,
duplicateSourceCodes
} = mergeCandidateRowsWithSourceCodes(baseCandidateRows, sourceRows, options.assetCodes)
const liveRefs = fetchLiveRefs(connection)
const { readyRows, pendingRows } = classifyRows(candidateRows, sourceRowMap, liveRefs)
const duplicatePendingRows = buildDuplicatePendingRows(duplicateSourceCodes, sourceRowMap, liveRefs)
const allPendingRows = [...pendingRows, ...duplicatePendingRows]
const addressPendingRows = pendingRows.filter((row) => row.待核对原因.includes('地址未匹配'))
const otherPendingRows = pendingRows.filter((row) => !row.待核对原因.includes('地址未匹配'))
const addressPendingRows = allPendingRows.filter((row) => row.待核对原因.includes('地址未匹配'))
const otherPendingRows = allPendingRows.filter((row) => !row.待核对原因.includes('地址未匹配'))
const readyPayload = buildReadyImportPayload(readyRows)
const manifest = {
@ -489,9 +537,12 @@ const run = async () => {
sourceWorkbook: sourceWorkbookPath,
sourceSheet: options.sourceSheet,
candidateWorkbook: candidateWorkbookPath,
totalCandidates: candidateRows.length,
explicitAssetCodes: options.assetCodes,
explicitMissingCodes: missingSourceCodes,
duplicateSourceCodes: duplicateSourceCodes.map((item) => item.code),
totalCandidates: candidateRows.length + duplicatePendingRows.length,
readyCount: readyRows.length,
pendingCount: pendingRows.length,
pendingCount: allPendingRows.length,
addressPendingCount: addressPendingRows.length,
otherPendingCount: otherPendingRows.length,
rulesVersion: 'sheet2-public-supplement-v1',
@ -506,16 +557,19 @@ const run = async () => {
{
name: '汇总说明',
rows: buildSummaryRows({
total: candidateRows.length,
total: candidateRows.length + duplicatePendingRows.length,
ready: readyRows.length,
pending: pendingRows.length,
pending: allPendingRows.length,
addressPending: addressPendingRows.length,
otherPending: otherPendingRows.length,
liveRefs
liveRefs,
explicitCodeCount: options.assetCodes.length,
explicitMissingCodeCount: missingSourceCodes.length,
duplicatePendingCount: duplicatePendingRows.length
})
},
{ name: '可直接补录', rows: readyRows },
{ name: '待人工核对', rows: pendingRows },
{ name: '待人工核对', rows: allPendingRows },
{ name: '地址待核对', rows: addressPendingRows },
{ name: '其他待核对', rows: otherPendingRows.length ? otherPendingRows : [{ 说明: '当前无记录' }] }
])
@ -526,8 +580,8 @@ const run = async () => {
payload: readyPayload
})
writeJson(path.join(outputDir, 'sheet2_public_pending_rows.json'), {
pendingCount: pendingRows.length,
rows: pendingRows
pendingCount: allPendingRows.length,
rows: allPendingRows
})
writeJson(path.join(outputDir, 'sheet2_live_refs.json'), liveRefs)
@ -544,7 +598,14 @@ const run = async () => {
)
}
run().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
})
if (require.main === module) {
run().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
})
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -0,0 +1,51 @@
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES = ['可用']
const isSheet2PublicEquipmentDescription = (description) => {
const normalized = normalizeText(description)
if (!normalized) {
return false
}
return (
normalized.includes('source_workbook=docs/总表待导入.xlsx') &&
normalized.includes('source_sheet=Sheet2')
)
}
const buildPublicAssetBorrowState = (unitPrice) => {
const normalizedUnitPrice = Number(unitPrice || 0)
return {
equipmentStatus: '已借出',
remainingQuantity: 0,
totalValue: normalizedUnitPrice,
borrowedValue: normalizedUnitPrice,
availableValue: 0
}
}
const buildAssignableExistingSheet2PublicEquipmentWhere = (
assignableStatuses = PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES
) => ({
AND: [
{ description: { contains: 'source_workbook=docs/总表待导入.xlsx' } },
{ description: { contains: 'source_sheet=Sheet2' } }
],
status: { in: assignableStatuses },
borrow_records: { none: { status: '已借出' } }
})
module.exports = {
buildAssignableExistingSheet2PublicEquipmentWhere,
PUBLIC_ASSET_ASSIGNABLE_EXISTING_STATUSES,
buildPublicAssetBorrowState,
isSheet2PublicEquipmentDescription,
normalizeText
}

View File

@ -0,0 +1,231 @@
const { resolveStorageLocationMatch } = require('./locationNormalization')
const SHEET2_LOCATION_OVERRIDES = {
山东: '济南',
运营中心: '常州-天安',
客服中心: '常州-天安',
人力行政中心: '常州-天安',
庙西宿舍: '常州-仓库',
财务中心: '常州-天安',
天安17楼: '常州-天安',
运营中心B: '常州-天安',
供应链中心: '常州-天安',
'2楼': '常州-天安',
湖塘摄影棚: '影棚',
睢宁仓: '苏北仓库',
横林二仓仓库: '常州-仓库',
横山桥: '常州-仓库',
客服二部: '常州-天安',
苏北仓: '苏北仓库',
仓储中心: '常州-仓库',
横林二仓二楼会议室: '常州-仓库',
横林二仓二楼张总办公室: '常州-仓库',
红星美凯龙: '常州-美凯龙',
天安三楼: '常州-天安',
'1楼 客服四部': '常州-天安',
'3楼 智管信息中心': '常州-天安',
常州运c: '常州-天安',
二楼: '常州-天安',
分销外贸: '常州-天安',
佛山顺德仓: '佛山-顺德仓库',
河北仓: '河北-仓库',
横林二仓采购办公室: '常州-仓库',
横林二仓仓库办公室: '常州-仓库',
横林二仓二楼顾总办公室: '常州-仓库',
横林二仓二楼贾总办公室: '常州-仓库',
横林二仓二楼朱总办公室: '常州-仓库',
横林二仓一楼仓储办公室: '常州-仓库',
客服六部: '常州-天安',
三楼: '常州-天安',
山东曹县: '山东-曹县',
事业一部: '常州-天安',
天安10号楼三楼机房: '常州-天安',
天安10号楼四楼厨房: '常州-天安',
天安10号楼一楼机房: '常州-天安',
天安四楼会议室: '常州-天安',
运营八部: '常州-天安',
运营十部: '常州-天安',
总经理室: '常州-天安'
}
const SHEET2_LOCATION_PREFIXES = ['仓储一部', '仓储二部', '仓储三部']
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const stripSheet2LocationPrefix = (rawValue) => {
const normalized = normalizeText(rawValue)
if (!normalized) {
return {
rawValue: '',
strippedValue: '',
prefix: '',
changed: false
}
}
for (const prefix of SHEET2_LOCATION_PREFIXES) {
const marker = `${prefix} `
if (normalized.startsWith(marker)) {
const strippedValue = normalizeText(normalized.slice(marker.length))
return {
rawValue: normalized,
strippedValue,
prefix,
changed: strippedValue !== normalized
}
}
}
return {
rawValue: normalized,
strippedValue: normalized,
prefix: '',
changed: false
}
}
const resolveSheet2LocationMatch = (rawValue, locationByName) => {
const normalized = normalizeText(rawValue)
if (!normalized) {
return {
rawValue: '',
normalizedValue: '',
match: null,
rule: ''
}
}
const directOverride = SHEET2_LOCATION_OVERRIDES[normalized]
if (directOverride) {
const match = locationByName.get(normalizeLookupKey(directOverride)) || null
return {
rawValue: normalized,
normalizedValue: directOverride,
match,
rule: 'sheet2-confirmed-override'
}
}
const directMatch = resolveStorageLocationMatch(normalized, locationByName)
if (directMatch.match) {
return directMatch
}
const stripped = stripSheet2LocationPrefix(normalized)
if (!stripped.changed) {
return directMatch
}
const strippedOverride = SHEET2_LOCATION_OVERRIDES[stripped.strippedValue]
if (strippedOverride) {
const match = locationByName.get(normalizeLookupKey(strippedOverride)) || null
return {
rawValue: normalized,
normalizedValue: strippedOverride,
match,
rule: 'sheet2-prefix-override'
}
}
const strippedMatch = resolveStorageLocationMatch(stripped.strippedValue, locationByName)
if (strippedMatch.match) {
return {
rawValue: normalized,
normalizedValue: strippedMatch.normalizedValue,
match: strippedMatch.match,
rule: 'sheet2-prefix-alias'
}
}
return {
rawValue: normalized,
normalizedValue: stripped.strippedValue,
match: null,
rule: 'sheet2-prefix-stripped'
}
}
const mergeCandidateRowsWithSourceCodes = (candidateRows, sourceRows, assetCodes) => {
const normalizedCodes = [...new Set((assetCodes || []).map((code) => normalizeText(code).toUpperCase()).filter(Boolean))]
if (!normalizedCodes.length) {
return {
rows: [...candidateRows],
missingCodes: [],
duplicateSourceCodes: []
}
}
const sourceRowsByCode = new Map()
for (const row of sourceRows || []) {
const code = normalizeText(row.资产编码).toUpperCase()
if (!code) {
continue
}
if (!sourceRowsByCode.has(code)) {
sourceRowsByCode.set(code, [])
}
sourceRowsByCode.get(code).push(row)
}
const existingCodes = new Set(
(candidateRows || []).map((row) => normalizeText(row.资产编码).toUpperCase()).filter(Boolean)
)
const rows = [...candidateRows]
const missingCodes = []
const duplicateSourceCodes = []
for (const code of normalizedCodes) {
if (existingCodes.has(code)) {
continue
}
const sourceCodeRows = sourceRowsByCode.get(code) || []
if (!sourceCodeRows.length) {
missingCodes.push(code)
continue
}
if (sourceCodeRows.length > 1) {
duplicateSourceCodes.push({
code,
rows: sourceCodeRows.map((row) => ({
...row,
来源工作表: normalizeText(row.来源工作表) || 'Sheet2'
}))
})
continue
}
const sourceRow = sourceCodeRows[0]
rows.push({
...sourceRow,
来源工作表: normalizeText(sourceRow.来源工作表) || 'Sheet2'
})
existingCodes.add(code)
}
return {
rows,
missingCodes,
duplicateSourceCodes
}
}
module.exports = {
SHEET2_LOCATION_OVERRIDES,
SHEET2_LOCATION_PREFIXES,
mergeCandidateRowsWithSourceCodes,
normalizeText,
normalizeLookupKey,
resolveSheet2LocationMatch,
stripSheet2LocationPrefix
}

View File

@ -0,0 +1,167 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { getEquipmentList } from '../equipmentController'
const createMockResponse = () => {
const responseBody: { statusCode: number; body: any } = {
statusCode: 200,
body: null
}
const res = {
status(code: number) {
responseBody.statusCode = code
return this
},
json(payload: any) {
responseBody.body = payload
return this
}
}
return { res, responseBody }
}
test('资产列表搜索不应再按借用人身份证号或历史 person_identifier 命中结果', async () => {
const originalFindMany = prisma.equipment.findMany
const originalCount = prisma.equipment.count
let capturedWhere: any = null
;(prisma.equipment.findMany as any) = async (args: any) => {
capturedWhere = args.where
return []
}
;(prisma.equipment.count as any) = async () => 0
try {
const { res, responseBody } = createMockResponse()
await getEquipmentList(
{
query: {
keyword: '2227',
page: '1',
pageSize: '10'
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.ok(capturedWhere)
const serializedWhere = JSON.stringify(capturedWhere)
assert.equal(
serializedWhere.includes('id_number'),
false,
'资产列表搜索不应包含 person.id_number 的模糊搜索'
)
assert.equal(
serializedWhere.includes('person_identifier'),
false,
'资产列表搜索不应继续使用 borrow_record.person_identifier,避免历史身份证数据再次命中'
)
assert.equal(
serializedWhere.includes('job_number'),
true,
'需要保留按人员真实工号搜索借用资产的能力'
)
} finally {
;(prisma.equipment.findMany as any) = originalFindMany
;(prisma.equipment.count as any) = originalCount
}
})
test('资产列表最近变动排序会把有最新挂载记录的父资产排在前面', async () => {
const originalFindMany = prisma.equipment.findMany
const originalCount = prisma.equipment.count
const originalGroupBy = prisma.mountHistory.groupBy
const olderParent = {
id: 1,
equipment_code: 'JS-PC-0001',
equipment_name: '旧主机',
equipment_type: '主机',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 0,
updated_at: new Date('2026-03-01T00:00:00Z'),
children: [],
borrow_records: []
}
const recentlyMountedParent = {
id: 2,
equipment_code: 'JS-PC-0002',
equipment_name: '刚换内存的主机',
equipment_type: '主机',
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 0,
updated_at: new Date('2026-03-01T00:00:00Z'),
children: [],
borrow_records: []
}
const candidates = [
{ id: olderParent.id, updated_at: olderParent.updated_at, children: [] },
{ id: recentlyMountedParent.id, updated_at: recentlyMountedParent.updated_at, children: [] }
]
const equipmentById = new Map([
[olderParent.id, olderParent],
[recentlyMountedParent.id, recentlyMountedParent]
])
;(prisma.equipment.findMany as any) = async (args: any) => {
if (args.select) {
return candidates
}
const ids = args.where.id.in as number[]
return ids.map((id) => equipmentById.get(id))
}
;(prisma.equipment.count as any) = async () => candidates.length
;(prisma.mountHistory.groupBy as any) = async () => [
{
parent_id: recentlyMountedParent.id,
_max: {
operated_at: new Date('2026-04-01T00:00:00Z')
}
}
]
try {
const { res, responseBody } = createMockResponse()
await getEquipmentList(
{
query: {
sort: 'recent_change',
page: '1',
pageSize: '10'
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.equal(responseBody.body.data.total, 2)
assert.equal(responseBody.body.data.list[0].id, recentlyMountedParent.id)
assert.equal(
responseBody.body.data.list[0].recent_change_at.getTime(),
new Date('2026-04-01T00:00:00Z').getTime()
)
} finally {
;(prisma.equipment.findMany as any) = originalFindMany
;(prisma.equipment.count as any) = originalCount
;(prisma.mountHistory.groupBy as any) = originalGroupBy
}
})

View File

@ -0,0 +1,102 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { updateEquipment } from '../equipmentController'
const createMockResponse = () => {
const responseBody: { statusCode: number; body: any } = {
statusCode: 200,
body: null
}
const res = {
status(code: number) {
responseBody.statusCode = code
return this
},
json(payload: any) {
responseBody.body = payload
return this
}
}
return { res, responseBody }
}
test('普通编辑资产时不应把待报废资产冲回可用', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalUpdate = prisma.equipment.update
const originalCount = prisma.equipment.count
const currentEquipment = {
id: 903,
equipment_code: 'JS-PC-0119',
equipment_name: '主机',
equipment_model: '',
equipment_type: '主机',
unit_price: 0,
total_quantity: 1,
remaining_quantity: 1,
total_value: 0,
borrowed_value: 0,
available_value: 0,
purchase_date: null,
supplier: '',
description: '',
status: '待报废',
storage_location_id: null,
department_id: null,
asset_type_id: null,
parent_id: null
}
let findUniqueCallCount = 0
let capturedUpdateData: any = null
;(prisma.equipment.findUnique as any) = async () => {
findUniqueCallCount += 1
return currentEquipment
}
;(prisma.equipment.update as any) = async (args: any) => {
capturedUpdateData = args.data
return {
...currentEquipment,
...args.data
}
}
;(prisma.equipment.count as any) = async () => 0
try {
const { res, responseBody } = createMockResponse()
await updateEquipment(
{
params: { id: '903' },
body: {
equipment_name: '主机',
equipment_model: '',
equipment_type: '主机',
unit_price: 0,
total_quantity: 1,
status: '可用',
purchase_date: '',
supplier: '',
description: ''
}
} as any,
res as any
)
assert.equal(responseBody.statusCode, 200)
assert.ok(capturedUpdateData, '应当调用 equipment.update')
assert.equal(findUniqueCallCount >= 1, true)
assert.equal(capturedUpdateData.status, '待报废')
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.equipment.update as any) = originalUpdate
;(prisma.equipment.count as any) = originalCount
}
})

View File

@ -48,7 +48,7 @@ const batchBorrowTransaction = async (
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.id_number,
person_identifier: person.job_number,
expected_return_time: isLongTerm ? null : (expectedReturnTime ? new Date(expectedReturnTime) : null),
is_long_term: Boolean(isLongTerm),
notes: finalNotes,

View File

@ -8,6 +8,7 @@ import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
interface PersonImportData {
nickname: string
real_name: string
job_number?: string
id_number?: string
department?: string
position?: string
@ -66,12 +67,13 @@ export const batchImportPersons = async (req: Request, res: Response) => {
}
// 检查工号是否重复
if (person.id_number) {
const normalizedJobNumber = normalizeOptionalText(person.job_number)
if (normalizedJobNumber) {
const existing = await prisma.person.findUnique({
where: { id_number: person.id_number }
where: { job_number: normalizedJobNumber }
})
if (existing) {
results.errors.push({ row: rowNum, message: `工号 ${person.id_number} 已存在` })
results.errors.push({ row: rowNum, message: `工号 ${normalizedJobNumber} 已存在` })
results.failed++
continue
}
@ -83,11 +85,22 @@ export const batchImportPersons = async (req: Request, res: Response) => {
: null
await prisma.$transaction(async (tx) => {
const normalizedIdNumber = normalizeOptionalText(person.id_number)
if (normalizedIdNumber) {
const existingByIdNumber = await tx.person.findUnique({
where: { id_number: normalizedIdNumber }
})
if (existingByIdNumber) {
throw new Error(`身份证号 ${normalizedIdNumber} 已存在`)
}
}
const created = await tx.person.create({
data: {
nickname: person.nickname,
real_name: person.real_name,
id_number: person.id_number || null,
job_number: normalizedJobNumber,
id_number: normalizedIdNumber,
department: normalizedDepartment,
main_department_id: matchedDepartmentId,
position: person.position || null,

View File

@ -11,6 +11,10 @@ import {
normalizeReturnDepartmentInput,
resolveBorrowDepartmentUpdate
} from '../utils/borrowDepartment'
import {
buildParentAssetFirstOrderBy,
sortParentAssetsFirst
} from '../utils/equipmentSearchPriority'
import { buildBusinessDepartmentWhere } from '../utils/organization'
import { logOperation } from './operationLogController'
@ -137,12 +141,20 @@ const ensureBorrowAnomalyHandleable = (
return anomalyMeta
}
const ensurePersonAvailable = async (personId?: number) => {
const ensurePersonAvailable = async (
personId?: number,
options: {
allowInactive?: boolean
} = {}
) => {
if (!personId) {
throw new Error('使用者ID不能为空')
}
const person = await prisma.person.findUnique({ where: { id: personId } })
if (!person || person.status !== 'active') {
if (!person) {
throw new Error('使用者不存在')
}
if (!options.allowInactive && person.status !== 'active') {
throw new Error('使用者不存在或已禁用')
}
return person
@ -252,7 +264,7 @@ const borrowEquipmentInTransaction = async (
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.id_number,
person_identifier: person.job_number,
expected_return_time: expectedReturn,
is_long_term: Boolean(isLongTerm),
notes: combinedNotes,
@ -493,7 +505,7 @@ const registerBorrowAnomalyInTransaction = async (
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.id_number,
person_identifier: person.job_number,
borrow_time: borrowTime,
notes: notes?.trim() || null,
status: '已借出',
@ -536,6 +548,8 @@ const mapBorrowRecord = (record: any) => ({
id: record.person.id,
nickname: record.person.nickname,
real_name: record.person.real_name,
job_number: record.person.job_number,
id_number: record.person.id_number,
department: record.person.mainDepartment?.name || record.person.department,
main_department: record.person.mainDepartment || null,
phone: record.person.phone,
@ -1223,7 +1237,7 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
: [])
]
},
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
orderBy: buildParentAssetFirstOrderBy([{ updated_at: 'desc' }, { id: 'desc' }]),
select: {
id: true,
equipment_code: true,
@ -1275,7 +1289,7 @@ export const searchReturnAssets = async (req: Request, res: Response) => {
}
}
const list = equipmentList.map((equipment) => {
const list = sortParentAssetsFirst(equipmentList).map((equipment) => {
const borrowerNames: string[] = []
const borrowerIdentifiers: string[] = []
const borrowerDepartments: string[] = []
@ -1375,7 +1389,8 @@ export const registerBorrowAnomaly = async (req: Request, res: Response) => {
const { person_id, borrow_time, notes } = req.body as Record<string, any>
let person
try {
person = await ensurePersonAvailable(parseNumber(person_id))
// 异常借出补登记需要支持历史离职/停用人员,便于行政处理名下资产。
person = await ensurePersonAvailable(parseNumber(person_id), { allowInactive: true })
} catch (error: any) {
return errorResponse(res, error.message || '使用者验证失败', 422)
}

View File

@ -84,6 +84,7 @@ const activeBorrowSelect = {
id: true,
nickname: true,
real_name: true,
job_number: true,
id_number: true
}
}
@ -91,6 +92,8 @@ const activeBorrowSelect = {
}
}
const EQUIPMENT_LIST_SORT_MODES = new Set(['created_desc', 'updated_desc', 'recent_change'])
const includeEquipmentRelations = {
...includeStorageLocation,
parent: {
@ -143,12 +146,23 @@ const mapCurrentBorrow = (borrowRecords: any[] | undefined) => {
id: record.person.id,
nickname: record.person.nickname,
real_name: record.person.real_name,
job_number: record.person.job_number,
id_number: record.person.id_number
}
: null
}
}
const getLatestTimestamp = (...values: Array<Date | string | null | undefined>) => {
return values.reduce<Date | null>((latest, value) => {
if (!value) return latest
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return latest
if (!latest || date.getTime() > latest.getTime()) return date
return latest
}, null)
}
const serializeEquipmentListItem = (equipment: any) => {
const children = Array.isArray(equipment.children)
? equipment.children.map((child: any) => {
@ -161,10 +175,22 @@ const serializeEquipmentListItem = (equipment: any) => {
: []
const { borrow_records, ...restEquipment } = equipment
const latestMountChangeAt = getLatestTimestamp(
equipment.latest_parent_mount_change_at,
equipment.latest_child_mount_change_at
)
const recentChangeAt = getLatestTimestamp(
equipment.recent_change_at,
restEquipment.updated_at,
...children.map((child: any) => child.updated_at),
latestMountChangeAt
)
return {
...restEquipment,
children,
latest_mount_change_at: latestMountChangeAt,
recent_change_at: recentChangeAt,
current_borrow: mapCurrentBorrow(borrow_records),
borrow_anomaly: getBorrowAnomalyMeta({
status: restEquipment.status,
@ -180,6 +206,34 @@ const normalizeEntryMode = (value: unknown): EquipmentEntryMode => {
return 'single'
}
const EDIT_LOCKED_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏'])
const resolveEditableEquipmentStatus = ({
currentStatus,
requestedStatus,
remainingQuantity
}: {
currentStatus: string
requestedStatus?: unknown
remainingQuantity: number
}) => {
const normalizedRequestedStatus =
typeof requestedStatus === 'string' && requestedStatus.trim()
? requestedStatus.trim()
: undefined
const autoDerivedStatus = remainingQuantity > 0 ? '可用' : '已借出'
if (EDIT_LOCKED_STATUSES.has(currentStatus)) {
return currentStatus
}
if (normalizedRequestedStatus && EDIT_LOCKED_STATUSES.has(normalizedRequestedStatus)) {
return autoDerivedStatus
}
return normalizedRequestedStatus || autoDerivedStatus
}
const ensureLocationExists = async (client: any, locationId?: number | null) => {
if (!locationId) {
throw new Error('地址信息不能为空')
@ -361,10 +415,13 @@ const createEquipmentRecord = async (
// 获取设备列表
export const getEquipmentList = async (req: Request, res: Response) => {
try {
const { page = 1, pageSize = 10, equipmentType, status, keyword, storage_location_id, department_id, asset_type_id, mount_status, parent_id } = req.query
const { page = 1, pageSize = 10, equipmentType, status, keyword, storage_location_id, department_id, asset_type_id, mount_status, parent_id, sort } = req.query
const where: any = {}
const hasParentFilter = !Number.isNaN(Number(parent_id)) && Boolean(parent_id)
const sortMode = typeof sort === 'string' && EQUIPMENT_LIST_SORT_MODES.has(sort)
? sort
: 'created_desc'
if (equipmentType) {
where.equipment_type = equipmentType
@ -401,7 +458,6 @@ export const getEquipmentList = async (req: Request, res: Response) => {
OR: [
{ person_nickname: { contains: normalizedKeyword } },
{ person_name: { contains: normalizedKeyword } },
{ person_identifier: { contains: normalizedKeyword } },
{
person: {
is: {
@ -419,7 +475,7 @@ export const getEquipmentList = async (req: Request, res: Response) => {
{
person: {
is: {
id_number: { contains: normalizedKeyword }
job_number: { contains: normalizedKeyword }
}
}
}
@ -482,40 +538,133 @@ export const getEquipmentList = async (req: Request, res: Response) => {
}
const skip = (Number(page) - 1) * Number(pageSize)
const take = Number(pageSize)
const listInclude = {
...includeStorageLocation,
...activeBorrowSelect,
parent: {
select: {
id: true,
equipment_code: true,
equipment_name: true
}
},
children: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true,
status: true,
mount_status: true,
updated_at: true,
...activeBorrowSelect
}
}
}
const [list, total] = await Promise.all([
prisma.equipment.findMany({
let list: any[] = []
let total = 0
if (sortMode === 'recent_change') {
const candidates = await prisma.equipment.findMany({
where,
skip,
take: Number(pageSize),
orderBy: { created_at: 'desc' },
include: {
...includeStorageLocation,
...activeBorrowSelect,
parent: {
select: {
id: true,
equipment_code: true,
equipment_name: true
}
},
select: {
id: true,
updated_at: true,
children: {
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
unit_price: true,
status: true,
mount_status: true,
...activeBorrowSelect
updated_at: true
}
}
}
}),
})
total = candidates.length
const candidateIds = candidates.map((item) => item.id)
const candidateLatestChange = new Map<number, Date | null>()
for (const candidate of candidates) {
candidateLatestChange.set(
candidate.id,
getLatestTimestamp(
candidate.updated_at,
...candidate.children.map((child) => child.updated_at)
)
)
}
if (candidateIds.length > 0) {
const groupedHistory = hasParentFilter
? await prisma.mountHistory.groupBy({
by: ['child_id'],
where: {
child_id: { in: candidateIds }
},
_max: {
operated_at: true
}
})
: await prisma.mountHistory.groupBy({
by: ['parent_id'],
where: {
parent_id: { in: candidateIds }
},
_max: {
operated_at: true
}
})
for (const item of groupedHistory) {
const targetId = hasParentFilter ? (item as any).child_id : (item as any).parent_id
if (!targetId) continue
candidateLatestChange.set(
targetId,
getLatestTimestamp(candidateLatestChange.get(targetId), item._max.operated_at)
)
}
}
const pageIds = [...candidateIds]
.sort((leftId, rightId) => {
const leftTime = candidateLatestChange.get(leftId)?.getTime() || 0
const rightTime = candidateLatestChange.get(rightId)?.getTime() || 0
return rightTime - leftTime || rightId - leftId
})
.slice(skip, skip + take)
if (pageIds.length > 0) {
const pageIdOrder = new Map(pageIds.map((id, index) => [id, index]))
const pageList = await prisma.equipment.findMany({
where: {
id: { in: pageIds }
},
include: listInclude
})
list = pageList
.map((item) => ({
...item,
recent_change_at: candidateLatestChange.get(item.id) || null
}))
.sort((left, right) => (pageIdOrder.get(left.id) || 0) - (pageIdOrder.get(right.id) || 0))
}
} else {
const orderBy = sortMode === 'updated_desc'
? [{ updated_at: 'desc' as const }, { id: 'desc' as const }]
: [{ created_at: 'desc' as const }, { id: 'desc' as const }]
;[list, total] = await Promise.all([
prisma.equipment.findMany({
where,
skip,
take,
orderBy,
include: listInclude
}),
prisma.equipment.count({ where })
])
])
}
successResponse(
res,
@ -777,12 +926,11 @@ export const updateEquipment = async (req: Request, res: Response) => {
total_quantity !== undefined
? Math.max(finalTotalQuantity - borrowedQuantity, 0)
: currentEquipment.remaining_quantity
const resolvedStatus = status
?? (
['维护中', '待报废', '已报废', '已损坏'].includes(currentEquipment.status)
? currentEquipment.status
: (newRemainingQuantity > 0 ? '可用' : '已借出')
)
const resolvedStatus = resolveEditableEquipmentStatus({
currentStatus: currentEquipment.status,
requestedStatus: status,
remainingQuantity: newRemainingQuantity
})
const values = calculateEquipmentValues({
unitPrice: finalUnitPrice,
totalQuantity: finalTotalQuantity,

View File

@ -289,7 +289,8 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
if (person_name) {
where.OR = [
{ person_name: { contains: String(person_name) } },
{ person_nickname: { contains: String(person_name) } }
{ person_nickname: { contains: String(person_name) } },
{ person_identifier: { contains: String(person_name) } }
]
}
if (start_date || end_date) {
@ -306,6 +307,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
select: {
nickname: true,
real_name: true,
job_number: true,
department: true,
mainDepartment: {
select: { name: true }
@ -324,6 +326,8 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
{ header: '资产名称', key: 'equipment_name', width: 25 },
{ header: '使用者', key: 'person_name', width: 15 },
{ header: '使用者昵称', key: 'person_nickname', width: 15 },
{ header: '借用时工号', key: 'person_identifier', width: 18 },
{ header: '当前工号', key: 'current_job_number', width: 18 },
{ header: '部门', key: 'department', width: 15 },
{ header: '借用时间', key: 'borrow_time', width: 20 },
{ header: '预计归还', key: 'expected_return_time', width: 20 },
@ -343,6 +347,8 @@ export const exportBorrowRecords = async (req: Request, res: Response) => {
equipment_name: record.equipment?.equipment_name || '',
person_name: record.person_name,
person_nickname: record.person_nickname,
person_identifier: record.person_identifier || '',
current_job_number: record.person?.job_number || '',
department: record.person?.mainDepartment?.name || record.person?.department || '',
borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '',
expected_return_time: record.is_long_term ? '长期' : (record.expected_return_time ? new Date(record.expected_return_time).toLocaleDateString('zh-CN') : ''),
@ -393,6 +399,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
{ header: '昵称 *', key: 'nickname', width: 15 },
{ header: '真实姓名 *', key: 'real_name', width: 15 },
{ header: '工号', key: 'employee_id', width: 15 },
{ header: '身份证号', key: 'id_number', width: 22 },
{ header: '部门', key: 'department', width: 18 },
{ header: '职位', key: 'position', width: 15 },
{ header: '电话', key: 'phone', width: 15 },
@ -411,6 +418,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
nickname: '示例:张三',
real_name: '张三',
employee_id: 'EMP001',
id_number: '320000199001011234',
department: departmentNames[0] || '技术部',
position: '工程师',
phone: '13800138000',
@ -435,10 +443,10 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
// 预设100行的下拉选项
const maxRows = 100
// 部门下拉(D列)
// 部门下拉(E列)
if (departmentNames.length > 0) {
for (let row = 2; row <= maxRows; row++) {
mainSheet.getCell(`D${row}`).dataValidation = {
mainSheet.getCell(`E${row}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: [`数据源!$A$2:$A$${departmentNames.length + 1}`],
@ -464,6 +472,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => {
{ field: '昵称', required: '是', description: '使用者的昵称或常用名' },
{ field: '真实姓名', required: '是', description: '使用者的真实姓名' },
{ field: '工号', required: '否', description: '员工工号' },
{ field: '身份证号', required: '否', description: '身份证或其他证件号码' },
{ field: '部门', required: '否', description: '从下拉列表选择,当前可选:' + (departmentNames.join('、') || '暂无') },
{ field: '职位', required: '否', description: '职位名称,如"工程师"、"经理"等' },
{ field: '电话', required: '否', description: '联系电话' },

View File

@ -8,7 +8,8 @@ import {
getInventoryTaskDetail,
getTaskUnassignedItems,
listInventoryTasks,
previewInventoryTask
previewInventoryTask,
submitAdminInventoryScan
} from '../services/inventoryService'
import { normalizeInventoryScopeInput } from '../utils/inventoryScope'
@ -166,3 +167,33 @@ export const assignInventoryTaskUnassignedController = async (req: Request, res:
errorResponse(res, message, message.includes('不存在') ? 404 : 422)
}
}
export const submitAdminInventoryScanController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
const currentUserId = Number(res.locals.user?.id)
const equipmentCode = String(req.body?.equipment_code || '').trim()
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
if (!Number.isInteger(currentUserId) || currentUserId <= 0) {
return errorResponse(res, '未获取到当前登录用户', 401)
}
if (!equipmentCode) {
return errorResponse(res, '资产编码不能为空', 422)
}
const item = await submitAdminInventoryScan(taskId, equipmentCode, currentUserId)
successResponse(res, item, '管理员代扫成功')
} catch (error: any) {
const message = error.message || '管理员扫码盘点失败'
errorResponse(
res,
message,
message.includes('不存在') ? 404 : message.includes('关闭') ? 403 : 422
)
}
}

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import { isSystemReservedPerson } from '../utils/systemReservedPerson'
const departmentSelect = {
id: true,
@ -57,6 +58,7 @@ const sanitizePerson = (person: any) => {
id: person.id,
nickname: person.nickname,
real_name: person.real_name,
job_number: person.job_number,
id_number: person.id_number,
department: mainDepartment?.name || person.department || null,
main_department_id: person.main_department_id,
@ -114,7 +116,7 @@ const buildPersonWhere = (keyword?: unknown, status?: unknown) => {
where.OR = [
{ nickname: { contains: kw } },
{ real_name: { contains: kw } },
{ id_number: { contains: kw } },
{ job_number: { contains: kw } },
{ department: { contains: kw } },
{
mainDepartment: {
@ -232,6 +234,7 @@ export const createPerson = async (req: Request, res: Response) => {
const {
nickname,
real_name,
job_number,
id_number,
department,
position,
@ -245,11 +248,19 @@ export const createPerson = async (req: Request, res: Response) => {
return errorResponse(res, '花名与真实姓名为必填项', 422)
}
const normalizedJobNumber = normalizeOptionalText(job_number)
if (normalizedJobNumber) {
const exists = await prisma.person.findUnique({ where: { job_number: normalizedJobNumber } })
if (exists) {
return errorResponse(res, '工号已存在', 409)
}
}
const normalizedIdNumber = normalizeOptionalText(id_number)
if (normalizedIdNumber) {
const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } })
if (exists) {
return errorResponse(res, '工号已存在', 409)
return errorResponse(res, '身份证号已存在', 409)
}
}
@ -261,6 +272,7 @@ export const createPerson = async (req: Request, res: Response) => {
data: {
nickname: String(nickname).trim(),
real_name: String(real_name).trim(),
job_number: normalizedJobNumber,
id_number: normalizedIdNumber,
department: normalizedDepartment,
main_department_id: matchedDepartment?.id || null,
@ -293,6 +305,7 @@ export const updatePerson = async (req: Request, res: Response) => {
const {
nickname,
real_name,
job_number,
id_number,
department,
position,
@ -309,12 +322,23 @@ export const updatePerson = async (req: Request, res: Response) => {
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
if (isSystemReservedPerson(person)) {
return errorResponse(res, '系统保留人员不允许修改', 422)
}
const normalizedJobNumber = normalizeOptionalText(job_number)
if (normalizedJobNumber && normalizedJobNumber !== person.job_number) {
const exists = await prisma.person.findUnique({ where: { job_number: normalizedJobNumber } })
if (exists) {
return errorResponse(res, '工号已存在', 409)
}
}
const normalizedIdNumber = normalizeOptionalText(id_number)
if (normalizedIdNumber && normalizedIdNumber !== person.id_number) {
const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } })
if (exists) {
return errorResponse(res, '工号已存在', 409)
return errorResponse(res, '身份证号已存在', 409)
}
}
@ -332,6 +356,7 @@ export const updatePerson = async (req: Request, res: Response) => {
data: {
nickname: nickname !== undefined ? String(nickname).trim() : person.nickname,
real_name: real_name !== undefined ? String(real_name).trim() : person.real_name,
job_number: job_number !== undefined ? normalizedJobNumber : person.job_number,
id_number: id_number !== undefined ? normalizedIdNumber : person.id_number,
department: hasDepartmentInput ? normalizedDepartment : person.department,
main_department_id: hasDepartmentInput
@ -379,6 +404,9 @@ export const updatePersonStatus = async (req: Request, res: Response) => {
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
if (isSystemReservedPerson(person)) {
return errorResponse(res, '系统保留人员不允许停用', 422)
}
const updated = await prisma.person.update({
where: { id: Number(id) },
@ -401,6 +429,9 @@ export const deletePerson = async (req: Request, res: Response) => {
if (!person) {
return errorResponse(res, '使用者不存在', 404)
}
if (isSystemReservedPerson(person)) {
return errorResponse(res, '系统保留人员不允许删除', 422)
}
const activeBorrowCount = await prisma.borrowRecord.count({
where: {
@ -431,11 +462,14 @@ export const deletePerson = async (req: Request, res: Response) => {
}
}
export const getPersonOptions = async (_req: Request, res: Response) => {
export const getPersonOptions = async (req: Request, res: Response) => {
try {
const includeInactive = String(req.query.include_inactive || '').trim().toLowerCase() === 'true'
const persons = await prisma.person.findMany({
where: { status: 'active' },
orderBy: [{ nickname: 'asc' }, { real_name: 'asc' }],
where: includeInactive ? undefined : { status: 'active' },
orderBy: includeInactive
? [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }]
: [{ nickname: 'asc' }, { real_name: 'asc' }],
include: personInclude
})

View File

@ -1,6 +1,10 @@
import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import {
buildParentAssetFirstOrderBy,
sortParentAssetsFirst
} from '../utils/equipmentSearchPriority'
// 全局搜索
export const globalSearch = async (req: Request, res: Response) => {
@ -26,14 +30,15 @@ export const globalSearch = async (req: Request, res: Response) => {
equipment_code: true,
equipment_name: true,
equipment_type: true,
parent_id: true,
status: true,
assetType: { select: { name: true } }
},
take: 10,
orderBy: { updated_at: 'desc' }
orderBy: buildParentAssetFirstOrderBy([{ updated_at: 'desc' }, { id: 'desc' }])
})
const results = equipments.map(eq => ({
const results = sortParentAssetsFirst(equipments).map(eq => ({
id: eq.id,
code: eq.equipment_code,
name: eq.equipment_name,

View File

@ -34,6 +34,7 @@ import printDownloadRoutes from './routes/printDownloads'
import { keycloak, memoryStore } from './utils/keycloak'
import { protect, requireAdmin } from './middleware/auth'
import { startHrSyncScheduler } from './services/hrSyncService'
import { ensurePublicAssetPersonExists } from './services/systemReservedPersonService'
dotenv.config()
@ -43,8 +44,8 @@ const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secre
// Middleware
app.use(cors({ origin: true, credentials: true }))
app.use(express.json({ limit: '30mb' }))
app.use(express.urlencoded({ extended: true }))
app.use(express.json({ limit: '50mb' }))
app.use(express.urlencoded({ extended: true, limit: '50mb' }))
app.use(
session({
secret: SESSION_SECRET,
@ -113,4 +114,8 @@ app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`)
})
ensurePublicAssetPersonExists().catch((error) => {
console.error('初始化系统保留人员失败:', error)
})
startHrSyncScheduler()

View File

@ -7,7 +7,8 @@ import {
getInventoryTaskDetailController,
getInventoryTaskListController,
getInventoryTaskUnassignedController,
previewInventoryTaskController
previewInventoryTaskController,
submitAdminInventoryScanController
} from '../controllers/inventoryController'
import { protect, requireAdmin } from '../middleware/auth'
@ -23,5 +24,6 @@ router.post('/tasks/:id/close', closeInventoryTaskController)
router.delete('/tasks/:id', deleteInventoryTaskController)
router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController)
router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController)
router.post('/tasks/:id/admin-scan', submitAdminInventoryScanController)
export default router

View File

@ -5,10 +5,28 @@ import {
buildHrSyncPreviewLine,
formatBorrowedAssetSummary,
resolveHrSyncPersonDisplayName,
resolveSourcePersonStatus
resolveSourcePersonStatus,
shouldUseSourceDepartmentBinding
} from '../hrSyncHelpers'
test('resolvePersonStatus 在状态值 2 被配置为停用时应返回 inactive', () => {
test('resolvePersonStatus 默认应将状态值 2 视为 active,仅将状态值 5 视为 inactive', () => {
const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES
delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES
try {
assert.equal(resolveSourcePersonStatus('2'), 'active')
assert.equal(resolveSourcePersonStatus('5'), 'inactive')
assert.equal(resolveSourcePersonStatus('3'), 'active')
} finally {
if (previous === undefined) {
delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES
} else {
process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous
}
}
})
test('resolvePersonStatus 在显式配置状态值 2 为停用时仍应尊重配置', () => {
const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES
process.env.HR_SYNC_INACTIVE_STATUS_VALUES = '2,5'
@ -17,7 +35,28 @@ test('resolvePersonStatus 在状态值 2 被配置为停用时应返回 inactive
assert.equal(resolveSourcePersonStatus('5'), 'inactive')
assert.equal(resolveSourcePersonStatus('3'), 'active')
} finally {
process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous
if (previous === undefined) {
delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES
} else {
process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous
}
}
})
test('shouldUseSourceDepartmentBinding 默认应允许状态值 2 和 3 保留源部门', () => {
const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES
delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES
try {
assert.equal(shouldUseSourceDepartmentBinding('2'), true)
assert.equal(shouldUseSourceDepartmentBinding('3'), true)
assert.equal(shouldUseSourceDepartmentBinding('5'), false)
} finally {
if (previous === undefined) {
delete process.env.HR_SYNC_INACTIVE_STATUS_VALUES
} else {
process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous
}
}
})

View File

@ -1,10 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
getPrintTerminalAdminStatusChangeError,
resolveEffectiveTerminalStatus,
normalizeLabelProfile,
planPrintTerminalAdminStatusChange,
pickNextClaimablePrintJob,
resolveEffectiveTerminalStatus,
resolvePrintJobTargetTerminal,
resolvePreferredDefaultTerminal,
resolveTerminalReadiness,
sanitizeRenderedLabelImages,
stripRenderedLabelData
@ -146,15 +148,157 @@ test('resolveEffectiveTerminalStatus 对手动设为 offline 的终端保持 off
assert.equal(status, 'offline')
})
test('getPrintTerminalAdminStatusChangeError 允许默认终端被直接停用', () => {
const error = getPrintTerminalAdminStatusChangeError(
test('resolvePreferredDefaultTerminal 会优先选择最新在线默认终端,忽略已停用默认记录', () => {
const terminal = resolvePreferredDefaultTerminal([
{
is_default: true
id: 1,
status: 'disabled',
is_default: true,
updated_at: '2026-03-26T10:00:00.000Z'
},
{
id: 2,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:00:00.000Z'
},
{
id: 3,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:05:00.000Z'
}
])
assert.equal(terminal?.id, 3)
})
test('resolvePreferredDefaultTerminal 在只剩停用默认终端时会回退到最新停用记录', () => {
const terminal = resolvePreferredDefaultTerminal([
{
id: 1,
status: 'disabled',
is_default: true,
updated_at: '2026-03-26T10:00:00.000Z'
},
{
id: 2,
status: 'disabled',
is_default: true,
updated_at: '2026-03-27T10:00:00.000Z'
}
])
assert.equal(terminal?.id, 2)
})
test('resolvePrintJobTargetTerminal 会优先使用前端当时展示的默认终端', () => {
const result = resolvePrintJobTargetTerminal(
[
{
id: 2,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:10:00.000Z'
},
{
id: 3,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:12:00.000Z'
}
],
2
)
assert.equal(result.terminal.id, 2)
assert.deepEqual(result.staleDefaultIdsToClear, [3])
})
test('resolvePrintJobTargetTerminal 在前端默认终端已变化时会要求刷新页面', () => {
assert.throws(
() =>
resolvePrintJobTargetTerminal(
[
{
id: 3,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:12:00.000Z'
}
],
2
),
/默认打印电脑状态已变化/
)
})
test('planPrintTerminalAdminStatusChange 在停用当前默认终端时会保留自身默认并清掉其他脏默认', () => {
const plan = planPrintTerminalAdminStatusChange(
[
{
id: 1,
status: 'disabled',
is_default: true,
updated_at: '2026-03-26T10:00:00.000Z'
},
{
id: 2,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:00:00.000Z'
},
{
id: 3,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:05:00.000Z'
}
],
{
id: 3,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:05:00.000Z'
},
'disabled'
)
assert.equal(error, null)
assert.deepEqual(plan, {
keepCurrentDefaultFlag: true,
staleDefaultIdsToClear: [1, 2]
})
})
test('planPrintTerminalAdminStatusChange 在处理旧脏默认时会移除它的默认标记', () => {
const plan = planPrintTerminalAdminStatusChange(
[
{
id: 1,
status: 'disabled',
is_default: true,
updated_at: '2026-03-26T10:00:00.000Z'
},
{
id: 2,
status: 'online',
is_default: true,
updated_at: '2026-03-27T10:05:00.000Z'
}
],
{
id: 1,
status: 'disabled',
is_default: true,
updated_at: '2026-03-26T10:00:00.000Z'
},
'offline'
)
assert.deepEqual(plan, {
keepCurrentDefaultFlag: false,
staleDefaultIdsToClear: []
})
})
test('sanitizeRenderedLabelImages 会按 equipment_ids 顺序重排网页生成的 PNG 标签', () => {

View File

@ -29,7 +29,7 @@ const getConfiguredSet = (value: string, defaultValue: string) =>
)
const getInactiveStatusValues = () =>
getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '2,5')
getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '5')
const getInactiveStatusKeywords = () =>
Array.from(
@ -59,6 +59,9 @@ export const resolveSourcePersonStatus = (employeeStatus: unknown): 'active' | '
return 'active'
}
export const shouldUseSourceDepartmentBinding = (employeeStatus: unknown) =>
resolveSourcePersonStatus(employeeStatus) === 'active'
export const formatBorrowedAssetSummary = (assets: OpenBorrowAssetPreview[], limit = 3) => {
if (assets.length === 0) {
return '暂无待处理资产'

View File

@ -8,6 +8,7 @@ import {
type NormalizeInventoryScopeInputParams,
type NormalizedInventoryScopeInput
} from '../utils/inventoryScope'
import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft'
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
type ScopeEntity = {
@ -351,52 +352,26 @@ export const buildInventoryScope = async (
const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap)
const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || []
const activeBorrowRecord = resolveUniqueActiveBorrowRecord(activeBorrowRecords)
const itemDraft = buildInventoryScopeItemDraft({
equipment,
activeBorrowRecord
})
if (!activeBorrowRecord?.person) {
continue
if (activeBorrowRecord?.person && itemDraft.participant_person_id) {
participantMap.set(itemDraft.participant_person_id, {
person_id: activeBorrowRecord.person.id,
person_name: activeBorrowRecord.person.real_name,
person_nickname: activeBorrowRecord.person.nickname,
person_source_userid: activeBorrowRecord.person.source_userid || null,
department_id: activeBorrowRecord.person.mainDepartment?.id || null,
department_name:
activeBorrowRecord.person.mainDepartment?.name ||
activeBorrowRecord.person.department ||
null
})
}
const participantPersonId = activeBorrowRecord.person.id
const snapshotDepartmentId =
activeBorrowRecord?.person?.mainDepartment?.id ||
equipment.department?.id ||
null
const snapshotDepartmentName =
activeBorrowRecord?.person?.mainDepartment?.name ||
activeBorrowRecord?.person?.department ||
equipment.department?.name ||
null
participantMap.set(participantPersonId, {
person_id: activeBorrowRecord.person.id,
person_name: activeBorrowRecord.person.real_name,
person_nickname: activeBorrowRecord.person.nickname,
person_source_userid: activeBorrowRecord.person.source_userid || null,
department_id: activeBorrowRecord.person.mainDepartment?.id || null,
department_name:
activeBorrowRecord.person.mainDepartment?.name ||
activeBorrowRecord.person.department ||
null
})
items.push({
participant_person_id: participantPersonId,
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
equipment_name: equipment.equipment_name,
equipment_model: equipment.equipment_model,
equipment_type: equipment.equipment_type,
asset_type_id: equipment.asset_type_id,
asset_type_name: equipment.assetType?.name || null,
snapshot_department_id: snapshotDepartmentId,
snapshot_department_name: snapshotDepartmentName,
snapshot_person_id: activeBorrowRecord?.person?.id || null,
snapshot_person_name: activeBorrowRecord?.person?.real_name || null,
snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null,
snapshot_status: equipment.status,
source_borrow_record_id: activeBorrowRecord?.id || null,
is_unassigned: false
})
items.push(itemDraft)
}
return {
@ -467,7 +442,8 @@ const refreshTaskCounters = async (tx: PrismaClient, taskId: number) => {
tx.inventoryTaskItem.count({
where: {
task_id: taskId,
is_unassigned: true
is_unassigned: true,
check_status: INVENTORY_ITEM_CHECK_STATUS.pending
}
})
])
@ -1180,11 +1156,137 @@ export const getTaskUnassignedItems = async (taskId: number) =>
prisma.inventoryTaskItem.findMany({
where: {
task_id: taskId,
is_unassigned: true
is_unassigned: true,
check_status: INVENTORY_ITEM_CHECK_STATUS.pending
},
orderBy: { equipment_code: 'asc' }
})
const assertTaskOpenForAdminInventory = async (taskId: number) => {
const task = await prisma.inventoryTask.findUnique({
where: { id: taskId },
select: {
id: true,
status: true
}
})
if (!task) {
throw new Error('盘点任务不存在')
}
if (task.status === INVENTORY_TASK_STATUSES.closed) {
throw new Error('盘点任务已关闭')
}
return task
}
const updateTaskItemStatusByAdmin = async (
taskId: number,
itemId: number,
operatorUserId: number,
payload: {
check_status: string
exception_type?: string | null
exception_note?: string | null
action_type: string
action_payload?: Record<string, unknown> | null
}
) => {
const item = await prisma.inventoryTaskItem.findFirst({
where: {
id: itemId,
task_id: taskId
}
})
if (!item) {
throw new Error('该资产不在本次盘点任务范围内')
}
await prisma.$transaction(async (tx) => {
await tx.inventoryTaskItem.update({
where: { id: item.id },
data: {
check_status: payload.check_status,
checked_by_participant_id: null,
checked_at: new Date(),
exception_type: payload.exception_type ?? null,
exception_note: payload.exception_note ?? null
}
})
if (item.participant_id) {
await tx.inventoryTaskParticipant.update({
where: { id: item.participant_id },
data: {
last_action_at: new Date()
}
})
await refreshParticipantStatus(tx as unknown as PrismaClient, item.participant_id)
}
await refreshTaskCounters(tx as unknown as PrismaClient, taskId)
await tx.inventoryTaskAction.create({
data: {
task_id: taskId,
participant_id: item.participant_id,
item_id: item.id,
action_type: payload.action_type,
action_payload: payload.action_payload ? JSON.stringify(payload.action_payload) : null,
operator_user_id: operatorUserId
}
})
})
return prisma.inventoryTaskItem.findUnique({
where: { id: item.id }
})
}
export const submitAdminInventoryScan = async (
taskId: number,
equipmentCode: string,
operatorUserId: number
) => {
const normalizedCode = equipmentCode.trim()
if (!normalizedCode) {
throw new Error('资产编码不能为空')
}
await assertTaskOpenForAdminInventory(taskId)
const item = await prisma.inventoryTaskItem.findFirst({
where: {
task_id: taskId,
equipment_code: normalizedCode
}
})
if (!item) {
throw new Error('该资产不在本次盘点任务范围内')
}
const updated = await updateTaskItemStatusByAdmin(taskId, item.id, operatorUserId, {
check_status: INVENTORY_ITEM_CHECK_STATUS.checked,
exception_type: null,
exception_note: null,
action_type: 'item_checked_by_admin',
action_payload: {
equipment_code: normalizedCode,
is_unassigned: item.is_unassigned
}
})
if (!updated) {
throw new Error('盘点资产不存在')
}
return updated
}
export const assignUnassignedTaskItem = async (
taskId: number,
itemId: number,

View File

@ -147,6 +147,10 @@ const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
const PNG_DATA_URL_PATTERN = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/i
const PRINT_JOB_RENDER_STORAGE_ROOT = path.resolve(__dirname, '..', '..', 'uploads', 'print-jobs')
const DEFAULT_TERMINAL_ORDER_BY: Prisma.PrintTerminalOrderByWithRelationInput[] = [
{ updated_at: 'desc' },
{ id: 'desc' }
]
type ParsedTerminalData = {
labelProfile: PrintLabelProfileInput | null
@ -155,6 +159,13 @@ type ParsedTerminalData = {
readiness: PrintTerminalReadinessResult
}
type DefaultTerminalCandidate = {
id: number
status: string
is_default: boolean
updated_at?: Date | string | null
}
type TerminalListRecord = PrintTerminal & {
recentFailedJob?: {
id: number
@ -194,6 +205,105 @@ export class PrintServiceError extends Error {
}
}
const getDefaultTerminalUpdatedAtMs = (value?: Date | string | null) => {
if (!value) return 0
const date = value instanceof Date ? value : new Date(value)
const timestamp = date.getTime()
return Number.isNaN(timestamp) ? 0 : timestamp
}
const compareDefaultTerminalPriority = <T extends DefaultTerminalCandidate>(a: T, b: T) => {
const updatedAtDiff = getDefaultTerminalUpdatedAtMs(b.updated_at) - getDefaultTerminalUpdatedAtMs(a.updated_at)
if (updatedAtDiff !== 0) {
return updatedAtDiff
}
return b.id - a.id
}
export const resolvePreferredDefaultTerminal = <T extends DefaultTerminalCandidate>(
terminals: T[]
) => {
const defaultTerminals = terminals.filter((terminal) => terminal.is_default).slice().sort(compareDefaultTerminalPriority)
return (
defaultTerminals.find((terminal) => terminal.status !== PRINT_TERMINAL_STATUS.disabled) ||
defaultTerminals[0] ||
null
)
}
const resolveActiveDefaultTerminal = <T extends DefaultTerminalCandidate>(terminals: T[]) => {
const preferredDefault = resolvePreferredDefaultTerminal(terminals)
if (!preferredDefault || preferredDefault.status === PRINT_TERMINAL_STATUS.disabled) {
return null
}
return preferredDefault
}
export const resolvePrintJobTargetTerminal = <T extends DefaultTerminalCandidate>(
terminals: T[],
requestedTerminalId: number | null
) => {
const defaultTerminals = terminals.filter((terminal) => terminal.is_default).slice().sort(compareDefaultTerminalPriority)
if (defaultTerminals.length === 0) {
throw new PrintServiceError('尚未配置默认打印电脑', 422)
}
const targetTerminal =
requestedTerminalId === null
? resolvePreferredDefaultTerminal(defaultTerminals)
: defaultTerminals.find((terminal) => terminal.id === requestedTerminalId) || null
if (!targetTerminal) {
throw new PrintServiceError('默认打印电脑状态已变化,请刷新页面后重试', 409)
}
if (targetTerminal.status === PRINT_TERMINAL_STATUS.disabled) {
throw new PrintServiceError('默认打印电脑已停用,请先启用或切换默认终端', 422)
}
return {
terminal: targetTerminal,
staleDefaultIdsToClear: defaultTerminals
.filter((terminal) => terminal.id !== targetTerminal.id)
.map((terminal) => terminal.id)
}
}
export const planPrintTerminalAdminStatusChange = <T extends DefaultTerminalCandidate>(
terminals: T[],
terminal: T,
nextStatus: 'offline' | 'disabled'
) => {
const preferredDefault = resolvePreferredDefaultTerminal(terminals)
const isPreferredDefault = preferredDefault?.id === terminal.id
if (terminal.is_default && !isPreferredDefault) {
return {
keepCurrentDefaultFlag: false,
staleDefaultIdsToClear: [] as number[]
}
}
if (nextStatus === 'disabled' && isPreferredDefault) {
return {
keepCurrentDefaultFlag: true,
staleDefaultIdsToClear: terminals
.filter((item) => item.is_default && item.id !== terminal.id)
.map((item) => item.id)
}
}
return {
keepCurrentDefaultFlag: terminal.is_default,
staleDefaultIdsToClear: [] as number[]
}
}
const normalizePositiveNumber = (value: number | null | undefined, fallback: number) => {
if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) {
return fallback
@ -369,6 +479,19 @@ const sanitizeEquipmentIds = (value: unknown) => {
return uniqueIds
}
const sanitizeTargetTerminalId = (value: unknown) => {
if (typeof value === 'undefined' || value === null || value === '') {
return null
}
const terminalId = Number(value)
if (!Number.isInteger(terminalId) || terminalId <= 0) {
throw new PrintServiceError('默认打印电脑编号无效,请刷新页面后重试', 422)
}
return terminalId
}
const decodePngDataUrl = (value: unknown) => {
if (typeof value !== 'string') {
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
@ -607,13 +730,6 @@ export const resolveEffectiveTerminalStatus = (
return PRINT_TERMINAL_STATUS.online
}
export const getPrintTerminalAdminStatusChangeError = (
_terminal: Pick<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
@ -675,8 +791,19 @@ const parseTerminalData = (terminal: {
}
}
const mapTerminalRecord = (terminal: TerminalListRecord) => {
const mapTerminalRecord = (
terminal: TerminalListRecord,
options: {
preferredDefaultTerminalId?: number | null
} = {}
) => {
const parsed = parseTerminalData(terminal)
const normalizedDefaultFlag =
typeof options.preferredDefaultTerminalId === 'number'
? terminal.id === options.preferredDefaultTerminalId
: options.preferredDefaultTerminalId === null
? false
: terminal.is_default
return {
id: terminal.id,
@ -684,7 +811,7 @@ const mapTerminalRecord = (terminal: TerminalListRecord) => {
platform: terminal.platform,
machine_name: terminal.machine_name,
status: parsed.effectiveStatus,
is_default: terminal.is_default,
is_default: normalizedDefaultFlag,
last_seen_at: terminal.last_seen_at,
selected_printer_name: terminal.selected_printer_name,
printer_snapshot: parsed.printerSnapshot,
@ -785,27 +912,17 @@ export const markStalePrintTerminalsOffline = async () => {
})
}
const getDefaultTargetTerminal = async () => {
const getDefaultTargetTerminal = async (requestedTerminalId: number | null = null) => {
await markStalePrintTerminalsOffline()
const terminal = await prisma.printTerminal.findFirst({
const terminals = await prisma.printTerminal.findMany({
where: {
is_default: true
},
orderBy: {
id: 'asc'
}
orderBy: DEFAULT_TERMINAL_ORDER_BY
})
if (!terminal) {
throw new PrintServiceError('尚未配置默认打印电脑', 422)
}
if (terminal.status === PRINT_TERMINAL_STATUS.disabled) {
throw new PrintServiceError('默认打印电脑已停用,请先启用或切换默认终端', 422)
}
return terminal
return resolvePrintJobTargetTerminal(terminals, requestedTerminalId)
}
const buildAssetLabelPayload = async (equipmentIds: number[]): Promise<AssetLabelJobPayload> => {
@ -951,10 +1068,14 @@ export const listPrintTerminals = async () => {
}
}
const preferredDefaultTerminalId = resolvePreferredDefaultTerminal(terminals)?.id ?? null
return terminals.map((terminal) =>
mapTerminalRecord({
...terminal,
recentFailedJob: latestFailedJobMap.get(terminal.id) || null
}, {
preferredDefaultTerminalId
})
)
}
@ -1008,25 +1129,52 @@ export const updatePrintTerminalAdminStatus = async (
terminalId: number,
nextStatus: 'offline' | 'disabled'
) => {
const existing = await prisma.printTerminal.findUnique({
where: { id: terminalId }
})
const [existing, defaultTerminals] = await Promise.all([
prisma.printTerminal.findUnique({
where: { id: terminalId }
}),
prisma.printTerminal.findMany({
where: {
is_default: true
},
orderBy: DEFAULT_TERMINAL_ORDER_BY,
select: {
id: true,
status: true,
is_default: true,
updated_at: true
}
})
])
if (!existing) {
throw new PrintServiceError('打印终端不存在', 404)
}
const statusChangeError = getPrintTerminalAdminStatusChangeError(existing, nextStatus)
if (statusChangeError) {
throw new PrintServiceError(statusChangeError, 422)
}
const changePlan = planPrintTerminalAdminStatusChange(defaultTerminals, existing, nextStatus)
const updated = await prisma.printTerminal.update({
where: { id: terminalId },
data: {
status: nextStatus,
last_seen_at: existing.last_seen_at
const updated = await prisma.$transaction(async (tx) => {
if (changePlan.staleDefaultIdsToClear.length > 0) {
await tx.printTerminal.updateMany({
where: {
id: {
in: changePlan.staleDefaultIdsToClear
}
},
data: {
is_default: false
}
})
}
return tx.printTerminal.update({
where: { id: terminalId },
data: {
status: nextStatus,
is_default: changePlan.keepCurrentDefaultFlag,
last_seen_at: existing.last_seen_at
}
})
})
return mapTerminalRecord({
@ -1041,12 +1189,15 @@ export const createAssetLabelPrintJob = async (input: unknown, requestedBy: numb
}
const equipmentIds = sanitizeEquipmentIds((input as { equipment_ids?: unknown }).equipment_ids)
const requestedTargetTerminalId = sanitizeTargetTerminalId(
(input as { target_terminal_id?: unknown }).target_terminal_id
)
const renderedLabels = sanitizeRenderedLabelImages(
(input as { rendered_labels?: unknown }).rendered_labels,
equipmentIds
)
const [targetTerminal, basePayload] = await Promise.all([
getDefaultTargetTerminal(),
const [targetTerminalSelection, basePayload] = await Promise.all([
getDefaultTargetTerminal(requestedTargetTerminalId),
buildAssetLabelPayload(equipmentIds)
])
@ -1060,47 +1211,62 @@ export const createAssetLabelPrintJob = async (input: unknown, requestedBy: numb
rendered_label_files: storedRenderBatch.storedFiles
}
job = await prisma.printJob.create({
data: {
job_type: PRINT_JOB_TYPE.assetLabel,
status: PRINT_JOB_STATUS.queued,
target_terminal_id: targetTerminal.id,
requested_by: requestedBy,
payload_json: stringifyJson(payload)
},
include: {
targetTerminal: {
select: {
id: true,
name: true,
status: true,
selected_printer_name: true,
last_seen_at: true,
label_profile_json: true,
printer_snapshot_json: true
job = await prisma.$transaction(async (tx) => {
if (targetTerminalSelection.staleDefaultIdsToClear.length > 0) {
await tx.printTerminal.updateMany({
where: {
id: {
in: targetTerminalSelection.staleDefaultIdsToClear
}
},
data: {
is_default: false
}
})
}
return tx.printJob.create({
data: {
job_type: PRINT_JOB_TYPE.assetLabel,
status: PRINT_JOB_STATUS.queued,
target_terminal_id: targetTerminalSelection.terminal.id,
requested_by: requestedBy,
payload_json: stringifyJson(payload)
},
claimedByTerminal: {
select: {
id: true,
name: true
}
},
requester: {
select: {
id: true,
username: true,
name: true
include: {
targetTerminal: {
select: {
id: true,
name: true,
status: true,
selected_printer_name: true,
last_seen_at: true,
label_profile_json: true,
printer_snapshot_json: true
}
},
claimedByTerminal: {
select: {
id: true,
name: true
}
},
requester: {
select: {
id: true,
username: true,
name: true
}
}
}
}
})
})
} catch (error) {
await removeStoredRenderBatch(storedRenderBatch.batchDirectory)
throw error
}
const parsedTarget = parseTerminalData(targetTerminal)
const parsedTarget = parseTerminalData(targetTerminalSelection.terminal)
return {
job: await mapPrintJobRecord(job),
@ -1250,22 +1416,44 @@ export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => {
throw new PrintServiceError('配对码已被其他终端使用,请重新生成', 409)
}
const defaultTerminalCount = await tx.printTerminal.count({
const defaultTerminals = await tx.printTerminal.findMany({
where: {
is_default: true
},
orderBy: DEFAULT_TERMINAL_ORDER_BY,
select: {
id: true,
status: true,
is_default: true,
status: {
not: PRINT_TERMINAL_STATUS.disabled
}
updated_at: true
}
})
const activeDefaultTerminal = resolveActiveDefaultTerminal(defaultTerminals)
const staleDefaultIds = defaultTerminals
.filter((terminal) => terminal.id !== activeDefaultTerminal?.id)
.map((terminal) => terminal.id)
if (staleDefaultIds.length > 0) {
await tx.printTerminal.updateMany({
where: {
id: {
in: staleDefaultIds
}
},
data: {
is_default: false
}
})
}
const terminal = await tx.printTerminal.create({
data: {
name,
platform,
machine_name: machineName,
status: PRINT_TERMINAL_STATUS.offline,
is_default: defaultTerminalCount === 0,
is_default: !activeDefaultTerminal,
terminal_token_hash: terminalTokenHash,
paired_at: now
}

View File

@ -0,0 +1,44 @@
import prisma from '../utils/prisma'
import {
PUBLIC_ASSET_PERSON_SOURCE_USERID,
buildPublicAssetPersonSeed
} from '../utils/systemReservedPerson'
export const ensurePublicAssetPersonExists = async () => {
const seed = buildPublicAssetPersonSeed()
return prisma.$transaction(async (tx) => {
const person = await tx.person.upsert({
where: {
source_userid: PUBLIC_ASSET_PERSON_SOURCE_USERID
},
create: seed,
update: {
nickname: seed.nickname,
real_name: seed.real_name,
department: null,
main_department_id: null,
position: seed.position,
status: 'active',
remarks: seed.remarks,
source_system: seed.source_system,
source_main_department_id: null,
source_dept_ids: null,
source_department_name: null,
source_main_department_name: null,
department_sync_bucket: null,
manager_source_userid: null,
source_employee_status: null,
last_synced_at: null
}
})
await tx.personDepartment.deleteMany({
where: {
person_id: person.id
}
})
return person
})
}

View File

@ -0,0 +1,29 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildParentAssetFirstOrderBy,
sortParentAssetsFirst
} from '../equipmentSearchPriority'
test('sortParentAssetsFirst 会把父资产排到子资产前面,并保持同组原顺序', () => {
const sorted = sortParentAssetsFirst([
{ id: 3, equipment_code: 'JS-CHILD-0003', parent_id: 101 },
{ id: 1, equipment_code: 'JS-PARENT-0001', parent_id: null },
{ id: 4, equipment_code: 'JS-CHILD-0004', parent_id: 102 },
{ id: 2, equipment_code: 'JS-PARENT-0002', parent_id: null }
])
assert.deepEqual(
sorted.map((item) => item.id),
[1, 2, 3, 4]
)
})
test('buildParentAssetFirstOrderBy 会把 parent_id 升序插到其他排序条件前面', () => {
assert.deepEqual(buildParentAssetFirstOrderBy([{ updated_at: 'desc' }, { id: 'desc' }]), [
{ parent_id: 'asc' },
{ updated_at: 'desc' },
{ id: 'desc' }
])
})

View File

@ -0,0 +1,155 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildInventoryScopeItemDraft } from '../inventoryTaskDraft'
test('buildInventoryScopeItemDraft 在存在唯一当前使用人时应生成已分配盘点项', () => {
const draft = buildInventoryScopeItemDraft({
equipment: {
id: 101,
equipment_code: 'JS-PC-1010',
equipment_name: '设计部主机',
equipment_model: 'Dell 7090',
equipment_type: '主机',
asset_type_id: 8,
status: '已借出',
assetType: {
id: 8,
name: '主机'
},
department: {
id: 12,
name: '设计部'
}
},
activeBorrowRecord: {
id: 9001,
person: {
id: 66,
real_name: '张三',
nickname: '三藏',
source_userid: 'ding_001',
department: '设计部',
main_department_id: 12,
mainDepartment: {
id: 12,
name: '设计部'
}
}
}
})
assert.deepEqual(draft, {
participant_person_id: 66,
equipment_id: 101,
equipment_code: 'JS-PC-1010',
equipment_name: '设计部主机',
equipment_model: 'Dell 7090',
equipment_type: '主机',
asset_type_id: 8,
asset_type_name: '主机',
snapshot_department_id: 12,
snapshot_department_name: '设计部',
snapshot_person_id: 66,
snapshot_person_name: '张三',
snapshot_person_nickname: '三藏',
snapshot_status: '已借出',
source_borrow_record_id: 9001,
is_unassigned: false
})
})
test('buildInventoryScopeItemDraft 在没有明确当前使用人时应生成待管理员代盘项', () => {
const draft = buildInventoryScopeItemDraft({
equipment: {
id: 202,
equipment_code: 'JS-MON-2020',
equipment_name: '前台公用显示器',
equipment_model: 'AOC 24B2',
equipment_type: '显示器',
asset_type_id: 6,
status: '可用',
assetType: {
id: 6,
name: '显示器'
},
department: {
id: 18,
name: '前台'
}
},
activeBorrowRecord: null
})
assert.deepEqual(draft, {
participant_person_id: null,
equipment_id: 202,
equipment_code: 'JS-MON-2020',
equipment_name: '前台公用显示器',
equipment_model: 'AOC 24B2',
equipment_type: '显示器',
asset_type_id: 6,
asset_type_name: '显示器',
snapshot_department_id: 18,
snapshot_department_name: '前台',
snapshot_person_id: null,
snapshot_person_name: null,
snapshot_person_nickname: null,
snapshot_status: '可用',
source_borrow_record_id: null,
is_unassigned: true
})
})
test('buildInventoryScopeItemDraft 遇到公共资产保留人员时仍应视为已分配盘点项', () => {
const draft = buildInventoryScopeItemDraft({
equipment: {
id: 303,
equipment_code: 'JS-DQ-0342',
equipment_name: '冰箱',
equipment_model: null,
equipment_type: '电器设备',
asset_type_id: 9,
status: '已借出',
assetType: {
id: 9,
name: '电器设备'
},
department: {
id: 21,
name: '仓储中心'
}
},
activeBorrowRecord: {
id: 9901,
person: {
id: 1315,
real_name: '公共资产',
nickname: '公共资产',
source_userid: '__public_asset__',
department: null,
main_department_id: null,
mainDepartment: null
}
}
})
assert.deepEqual(draft, {
participant_person_id: 1315,
equipment_id: 303,
equipment_code: 'JS-DQ-0342',
equipment_name: '冰箱',
equipment_model: null,
equipment_type: '电器设备',
asset_type_id: 9,
asset_type_name: '电器设备',
snapshot_department_id: 21,
snapshot_department_name: '仓储中心',
snapshot_person_id: 1315,
snapshot_person_name: '公共资产',
snapshot_person_nickname: '公共资产',
snapshot_status: '已借出',
source_borrow_record_id: 9901,
is_unassigned: false
})
})

View File

@ -0,0 +1,41 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
PUBLIC_ASSET_PERSON_SOURCE_SYSTEM,
PUBLIC_ASSET_PERSON_SOURCE_USERID,
buildPublicAssetPersonSeed,
isSystemReservedPerson
} from '../systemReservedPerson'
test('buildPublicAssetPersonSeed 应生成不参与组织同步的公共资产保留人员', () => {
assert.deepEqual(buildPublicAssetPersonSeed(), {
nickname: '公共资产',
real_name: '公共资产',
job_number: null,
id_number: null,
department: null,
main_department_id: null,
position: '系统保留',
phone: null,
email: null,
status: 'active',
remarks: '系统保留人员:用于承接公共设备,不参与 HR 组织同步。',
source_userid: PUBLIC_ASSET_PERSON_SOURCE_USERID,
source_system: PUBLIC_ASSET_PERSON_SOURCE_SYSTEM,
source_main_department_id: null,
source_dept_ids: null,
source_department_name: null,
source_main_department_name: null,
department_sync_bucket: null,
manager_source_userid: null,
source_employee_status: null,
last_synced_at: null
})
})
test('isSystemReservedPerson 只应识别系统保留来源', () => {
assert.equal(isSystemReservedPerson({ source_system: PUBLIC_ASSET_PERSON_SOURCE_SYSTEM }), true)
assert.equal(isSystemReservedPerson({ source_system: 'ding_hr' }), false)
assert.equal(isSystemReservedPerson({ source_system: null }), false)
})

View File

@ -0,0 +1,17 @@
export interface ParentAssetSortable {
parent_id?: number | null
}
export const sortParentAssetsFirst = <T extends ParentAssetSortable>(list: T[]) => {
return [...list].sort((left, right) => {
const leftPriority = left.parent_id == null ? 0 : 1
const rightPriority = right.parent_id == null ? 0 : 1
return leftPriority - rightPriority
})
}
export const buildParentAssetFirstOrderBy = <
T extends Record<string, 'asc' | 'desc'>
>(
fallbackOrderBy: T[]
) => [{ parent_id: 'asc' as const }, ...fallbackOrderBy]

View File

@ -0,0 +1,86 @@
export type InventoryDraftEquipment = {
id: number
equipment_code: string
equipment_name: string
equipment_model: string | null
equipment_type: string
asset_type_id: number | null
status: string | null
assetType?: {
id: number
name: string
} | null
department?: {
id: number
name: string
} | null
}
export type InventoryDraftPerson = {
id: number
real_name: string
nickname: string
source_userid?: string | null
department?: string | null
main_department_id?: number | null
mainDepartment?: {
id: number
name: string
} | null
}
export type InventoryDraftBorrowRecord = {
id: number
person?: InventoryDraftPerson | null
} | null
export type InventoryScopeItemDraft = {
participant_person_id: number | null
equipment_id: number
equipment_code: string
equipment_name: string
equipment_model: string | null
equipment_type: string
asset_type_id: number | null
asset_type_name: string | null
snapshot_department_id: number | null
snapshot_department_name: string | null
snapshot_person_id: number | null
snapshot_person_name: string | null
snapshot_person_nickname: string | null
snapshot_status: string | null
source_borrow_record_id: number | null
is_unassigned: boolean
}
export const buildInventoryScopeItemDraft = ({
equipment,
activeBorrowRecord
}: {
equipment: InventoryDraftEquipment
activeBorrowRecord: InventoryDraftBorrowRecord
}): InventoryScopeItemDraft => {
const person = activeBorrowRecord?.person || null
const snapshotDepartmentId = person?.mainDepartment?.id || equipment.department?.id || null
const snapshotDepartmentName =
person?.mainDepartment?.name || person?.department || equipment.department?.name || null
return {
participant_person_id: person?.id || null,
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
equipment_name: equipment.equipment_name,
equipment_model: equipment.equipment_model,
equipment_type: equipment.equipment_type,
asset_type_id: equipment.asset_type_id,
asset_type_name: equipment.assetType?.name || null,
snapshot_department_id: snapshotDepartmentId,
snapshot_department_name: snapshotDepartmentName,
snapshot_person_id: person?.id || null,
snapshot_person_name: person?.real_name || null,
snapshot_person_nickname: person?.nickname || null,
snapshot_status: equipment.status,
source_borrow_record_id: activeBorrowRecord?.id || null,
is_unassigned: !person
}
}

View File

@ -0,0 +1,29 @@
export const PUBLIC_ASSET_PERSON_SOURCE_SYSTEM = 'system_reserved'
export const PUBLIC_ASSET_PERSON_SOURCE_USERID = '__public_asset__'
export const buildPublicAssetPersonSeed = () => ({
nickname: '公共资产',
real_name: '公共资产',
job_number: null,
id_number: null,
department: null,
main_department_id: null,
position: '系统保留',
phone: null,
email: null,
status: 'active',
remarks: '系统保留人员:用于承接公共设备,不参与 HR 组织同步。',
source_userid: PUBLIC_ASSET_PERSON_SOURCE_USERID,
source_system: PUBLIC_ASSET_PERSON_SOURCE_SYSTEM,
source_main_department_id: null,
source_dept_ids: null,
source_department_name: null,
source_main_department_name: null,
department_sync_bucket: null,
manager_source_userid: null,
source_employee_status: null,
last_synced_at: null
})
export const isSystemReservedPerson = (person?: { source_system?: string | null } | null) =>
person?.source_system === PUBLIC_ASSET_PERSON_SOURCE_SYSTEM

View File

@ -1,9 +1,9 @@
{
"version": "0.2.5",
"published_at": "2026-03-26T21:07:29+08:00",
"filename": "print-agent-setup.exe",
"notes": "打印助手最新稳定版,当前为安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/latest/print-agent-setup.exe",
"relativePath": "downloads/print-agent/latest/print-agent-setup.exe"
}
"version": "0.2.6",
"published_at": "2026-03-28T23:59:00+08:00",
"filename": "print-agent-setup.exe",
"notes": "打印助手最新稳定版,已修复远程批量打印时网页成品标签图没有被打印助手实际使用的问题;现在优先直接打印网页最终 PNG,批量标签的版式与网页预览保持一致,并同步放宽大批量任务的请求容量限制。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/latest/print-agent-setup.exe",
"relativePath": "downloads/print-agent/latest/print-agent-setup.exe"
}

View File

@ -1,9 +1,9 @@
{
"version": "0.2.5",
"published_at": "2026-03-26T21:07:29+08:00",
"filename": "print-agent-setup-0.2.5.exe",
"notes": "打印助手 v0.2.5,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/releases/0.2.5/print-agent-setup-0.2.5.exe",
"relativePath": "downloads/print-agent/releases/0.2.5/print-agent-setup-0.2.5.exe"
}
"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"
}

View File

@ -0,0 +1,9 @@
{
"version": "0.2.6",
"published_at": "2026-03-28T23:59:00+08:00",
"filename": "print-agent-setup-0.2.6.exe",
"notes": "打印助手 v0.2.6,已修复远程批量打印时网页成品标签图没有被打印助手实际使用的问题;现在优先直接打印网页最终 PNG,批量标签的版式与网页预览保持一致,并同步放宽大批量任务的请求容量限制。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/releases/0.2.6/print-agent-setup-0.2.6.exe",
"relativePath": "downloads/print-agent/releases/0.2.6/print-agent-setup-0.2.6.exe"
}

View File

@ -12,7 +12,7 @@ const samplePersons = [
id: 1,
nickname: '阿杰',
real_name: '王杰',
id_number: 'A1001',
job_number: 'A1001',
department: '设计中心',
department_names: ['设计中心', '品牌设计部'],
phone: '13800000001',
@ -22,7 +22,7 @@ const samplePersons = [
id: 2,
nickname: '小周',
real_name: '周晴',
id_number: 'A1002',
job_number: 'A1002',
department: '行政部',
department_names: ['行政部'],
phone: '13800000002',
@ -32,7 +32,7 @@ const samplePersons = [
id: 3,
nickname: '离职张',
real_name: '张离',
id_number: 'A1003',
job_number: 'A1003',
department: '设计中心',
department_names: ['设计中心'],
phone: '13800000003',
@ -48,8 +48,8 @@ const byRealName = filterManualBorrowPersons(samplePersons as any, '周晴')
assert.equal(byRealName.length, 1, '姓名搜索应返回正确人员')
assert.equal(byRealName[0].id, 2, '姓名匹配结果不正确')
const byIdNumber = filterManualBorrowPersons(samplePersons as any, 'A1001')
assert.equal(byIdNumber[0].id, 1, '工号搜索应支持')
const byJobNumber = filterManualBorrowPersons(samplePersons as any, 'A1001')
assert.equal(byJobNumber[0].id, 1, '工号搜索应支持')
const byDepartment = filterManualBorrowPersons(samplePersons as any, '设计')
assert.equal(byDepartment.length, 1, '搜索部门时应排除已停用人员')
@ -89,7 +89,7 @@ const multiResults = filterManualBorrowPersons(
id: 4,
nickname: '阿杰2',
real_name: '王杰同名',
id_number: 'A1004',
job_number: 'A1004',
department: '设计中心',
department_names: ['设计中心'],
phone: '13800000004',

View File

@ -0,0 +1,33 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
getInventoryPublicAssetLabel,
isInventoryPublicAssetPerson
} from '../src/views/inventory/inventoryPublicAsset.ts'
test('isInventoryPublicAssetPerson 应识别公共资产保留人员', () => {
assert.equal(
isInventoryPublicAssetPerson({
person_source_userid: '__public_asset__',
person_nickname: '公共资产',
person_name: '公共资产'
}),
true
)
})
test('isInventoryPublicAssetPerson 不应把普通人员误判为公共资产', () => {
assert.equal(
isInventoryPublicAssetPerson({
person_source_userid: 'ding_001',
person_nickname: '三藏',
person_name: '张三'
}),
false
)
})
test('getInventoryPublicAssetLabel 应返回系统保留标签文案', () => {
assert.equal(getInventoryPublicAssetLabel(), '系统保留')
})

View File

@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildLocalBatchPrintHtml } from '../src/utils/localBatchPrint.ts'
const extractStyleBlock = (html: string) => {
const match = html.match(/<style>([\s\S]*?)<\/style>/)
return match?.[1] || ''
}
test('本地批量打印会为每张标签生成独立分页容器', () => {
const html = buildLocalBatchPrintHtml(
['data:image/png;base64,ZmFrZS0x', 'data:image/png;base64,ZmFrZS0y', 'data:image/png;base64,ZmFrZS0z'],
{
widthMm: 60,
heightMm: 40
}
)
const pageCount = (html.match(/class="print-page"/g) || []).length
const imageCount = (html.match(/class="qrcode-image"/g) || []).length
assert.equal(pageCount, 3, '每张标签都应拥有自己的分页容器')
assert.equal(imageCount, 3, '每张标签都应输出一张独立图片')
})
test('本地批量打印不再把 html/body 锁死为单页高度', () => {
const html = buildLocalBatchPrintHtml(['data:image/png;base64,ZmFrZS0x'], {
widthMm: 60,
heightMm: 40
})
const styles = extractStyleBlock(html)
const htmlBodyBlock = styles.match(/html, body\s*\{([\s\S]*?)\}/)?.[1] || ''
const printPageBlock = styles.match(/\.print-page\s*\{([\s\S]*?)\}/)?.[1] || ''
assert.doesNotMatch(htmlBodyBlock, /overflow\s*:\s*hidden/i)
assert.doesNotMatch(htmlBodyBlock, /height\s*:\s*40mm/i)
assert.match(printPageBlock, /break-after\s*:\s*page/i)
assert.match(printPageBlock, /page-break-after\s*:\s*always/i)
})

View File

@ -4,6 +4,7 @@ import type { ApiResponse } from '@/types'
interface PersonImportData {
nickname: string
real_name: string
job_number?: string
id_number?: string
department?: string
position?: string

View File

@ -1,4 +1,5 @@
import request from './index'
import type { ApiResponse, PaginatedResponse } from '@/types'
export interface DisposalRecord {
id: number
@ -39,7 +40,9 @@ export const createDisposal = (data: CreateDisposalParams) => {
// 获取报废记录列表
export const getDisposalList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number }) => {
return request.get('/disposal/list', { params })
return request
.get<ApiResponse<PaginatedResponse<DisposalRecord>>>('/disposal/list', { params })
.then((response) => response.data)
}
// 获取报废记录详情

View File

@ -45,6 +45,7 @@ export const getEquipmentList = async (params: {
equipmentType?: string
status?: string
keyword?: string
sort?: 'created_desc' | 'updated_desc' | 'recent_change'
storageLocationId?: number
departmentId?: number
mount_status?: string

View File

@ -75,3 +75,13 @@ export const assignInventoryTaskUnassignedItem = async (
)
return response.data
}
export const submitAdminInventoryScan = async (taskId: number, equipmentCode: string) => {
const response = await api.post<ApiResponse<InventoryTaskItem>>(
`/inventory/tasks/${taskId}/admin-scan`,
{
equipment_code: equipmentCode
}
)
return response.data
}

View File

@ -1,4 +1,5 @@
import request from './index'
import type { ApiResponse, PaginatedResponse } from '@/types'
export interface MaintenanceRecord {
id: number
@ -36,7 +37,9 @@ export const createMaintenance = (data: CreateMaintenanceParams) => {
// 获取维修记录列表
export const getMaintenanceList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number }) => {
return request.get('/maintenance/list', { params })
return request
.get<ApiResponse<PaginatedResponse<MaintenanceRecord>>>('/maintenance/list', { params })
.then((response) => response.data)
}
// 获取维修记录详情

View File

@ -19,6 +19,7 @@ export const getPersonDetail = async (id: number) => {
export const createPerson = async (data: {
nickname: string
real_name: string
job_number?: string
id_number?: string
department?: string
position?: string
@ -36,6 +37,7 @@ export const updatePerson = async (
data: {
nickname?: string
real_name?: string
job_number?: string
id_number?: string
department?: string
position?: string
@ -60,7 +62,11 @@ export const deletePerson = async (id: number) => {
return response.data
}
export const getPersonOptions = async () => {
const response = await api.get<ApiResponse<Person[]>>('/persons/options')
export const getPersonOptions = async (params?: { includeInactive?: boolean }) => {
const response = await api.get<ApiResponse<Person[]>>('/persons/options', {
params: {
include_inactive: params?.includeInactive ? 'true' : undefined
}
})
return response.data
}

View File

@ -329,7 +329,9 @@ export const getPrintTerminals = async () => {
export const createAssetLabelPrintJob = async (
payload: CreateAssetLabelPrintJobPayload
): Promise<CreateAssetLabelPrintJobResult> => {
const response = await api.post<ApiResponse<unknown>>('/print-jobs/asset-labels', payload)
const response = await api.post<ApiResponse<unknown>>('/print-jobs/asset-labels', payload, {
timeout: 120000
})
return {
job: normalizePrintJob(response.data.data),
queue_notice: toNullableString(response.data.message)

View File

@ -94,6 +94,10 @@ import { getEquipmentBatchForBarcode } from '@/api/barcode'
import { createAssetLabelPrintJob, getPrintTerminals } from '@/api/print'
import type { PrintTerminal } from '@/types'
import { formatDateTime } from '@/utils/format'
import {
buildLocalBatchPrintHtml,
waitForLocalBatchPrintWindowReady
} from '@/utils/localBatchPrint'
import {
buildIndustrialCardDisplay,
fitMultilineText,
@ -734,12 +738,15 @@ const handleRemotePrint = async () => {
image_data_url: images[index]
}))
const equipmentIds = equipmentList.value.map((equipment) => equipment.id)
const targetTerminalId = defaultTerminal.value.id
const result = await createAssetLabelPrintJob({
equipment_ids: equipmentIds,
rendered_labels: renderedLabels
rendered_labels: renderedLabels,
target_terminal_id: targetTerminalId
})
alert(result.queue_notice || `标签已发送到 ${defaultTerminal.value.name}`)
const targetTerminalName = result.job.target_terminal_name || defaultTerminal.value.name
alert(result.queue_notice || `标签已发送到 ${targetTerminalName}`)
await fetchDefaultTerminal()
} catch (error: any) {
@ -766,54 +773,34 @@ const handlePrint = async () => {
return
}
const printHTML = `
<!DOCTYPE html>
<html>
<head>
<title>二维码打印</title>
<style>
@page {
size: ${LABEL_PRESET.widthMm}mm ${LABEL_PRESET.heightMm}mm;
margin: 0;
}
html, body {
margin: 0;
padding: 0;
width: ${LABEL_PRESET.widthMm}mm;
height: ${LABEL_PRESET.heightMm}mm;
background: #ffffff;
overflow: hidden;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.qrcode-image {
width: ${LABEL_PRESET.widthMm}mm;
height: ${LABEL_PRESET.heightMm}mm;
display: block;
object-fit: contain;
page-break-after: always;
page-break-inside: avoid;
}
.qrcode-image:last-child {
page-break-after: auto;
}
</style>
</head>
<body>
${images.map((img) => `<img src="${img}" class="qrcode-image" />`).join('')}
</body>
</html>
`
const printHTML = buildLocalBatchPrintHtml(images, LABEL_PRESET)
printWindow.document.write(printHTML)
printWindow.document.close()
printWindow.onload = () => {
setTimeout(() => {
printWindow.print()
await waitForLocalBatchPrintWindowReady(printWindow)
const closePrintWindow = () => {
if (!printWindow.closed) {
printWindow.close()
}, 500)
}
}
const closeTimer = printWindow.setTimeout(() => {
closePrintWindow()
}, 1000)
printWindow.addEventListener(
'afterprint',
() => {
printWindow.clearTimeout(closeTimer)
closePrintWindow()
},
{ once: true }
)
printWindow.focus()
printWindow.print()
} catch (error) {
console.error('打印失败:', error)
alert('打印失败,请重试')

View File

@ -94,7 +94,7 @@
@keyup.enter="handlePersonKeywordSubmit"
/>
<p class="text-xs text-text-tertiary">
补登记借出时,只能选择系统里当前启用中的人员。
这里支持搜索已停用人员,但只用于补登记历史借出、处理离职人员名下资产。
</p>
<div v-if="showPersonSearchResults" class="space-y-2">
@ -116,13 +116,21 @@
>
<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>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-text-primary">
{{ person.nickname }} · {{ person.real_name }}
</p>
<span
v-if="person.status === 'inactive'"
class="badge badge-neutral !border-status-warning/30 !bg-status-warning/10 !text-status-warning"
>
已停用
</span>
</div>
<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>
<span v-if="person.job_number"> · {{ person.job_number }}</span>
</p>
</div>
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
@ -134,6 +142,10 @@
当前没有匹配到使用者,请换个关键词再试。
</p>
</div>
<p v-if="selectedPerson?.status === 'inactive'" class="text-xs text-status-warning">
已选中的人员当前为停用状态,本次仅用于补登记历史借出与后续入库处理。
</p>
</div>
</div>
@ -250,7 +262,9 @@ const selectedPerson = computed(() => {
return persons.value.find((person) => person.id === selectedPersonId.value) || null
})
const filteredPersons = computed(() => filterManualBorrowPersons(persons.value, personKeyword.value).slice(0, 8))
const filteredPersons = computed(() =>
filterManualBorrowPersons(persons.value, personKeyword.value, { allowInactive: true }).slice(0, 8)
)
const showPersonSearchResults = computed(() =>
shouldShowManualBorrowPersonResults(selectedPerson.value, personKeyword.value)
)
@ -278,7 +292,7 @@ const ensurePersonsLoaded = async () => {
personsLoading.value = true
try {
const response = await getPersonOptions()
const response = await getPersonOptions({ includeInactive: true })
persons.value = response.data || []
} finally {
personsLoading.value = false

View File

@ -150,6 +150,8 @@ export interface Equipment {
} | null
current_borrow?: EquipmentCurrentBorrow | null
borrow_anomaly?: BorrowAnomalyInfo | null
recent_change_at?: string | null
latest_mount_change_at?: string | null
children?: Array<{
id: number
equipment_code: string
@ -159,6 +161,7 @@ export interface Equipment {
unit_price?: number
status?: string
mount_status?: string
updated_at?: string
current_borrow?: EquipmentCurrentBorrow | null
}>
created_at: string
@ -177,7 +180,8 @@ export interface EquipmentCurrentBorrow {
id: number
nickname: string
real_name: string
id_number?: string
job_number?: string | null
id_number?: string | null
} | null
}
@ -186,7 +190,8 @@ export interface Person {
id: number
nickname: string
real_name: string
id_number?: string
job_number?: string | null
id_number?: string | null
department?: string | null
main_department_id?: number | null
main_department?: Department | null
@ -222,7 +227,7 @@ export interface BorrowRecord {
person_id: number
person_name: string
person_nickname: string
person_identifier?: string
person_identifier?: string | null
person?: Person
borrow_time: string
expected_return_time?: string
@ -855,6 +860,7 @@ export interface PrintJobListData extends PaginatedResponse<PrintJob> {}
export interface CreateAssetLabelPrintJobPayload {
equipment_ids: number[]
rendered_labels: AssetLabelRenderedImage[]
target_terminal_id?: number
}
export interface CreateAssetLabelPrintJobResult {

View File

@ -0,0 +1,123 @@
export interface LocalBatchPrintPreset {
widthMm: number
heightMm: number
}
const waitForPopupLoad = (printWindow: Window) =>
new Promise<void>((resolve) => {
if (printWindow.document.readyState === 'complete') {
resolve()
return
}
const handleLoad = () => resolve()
printWindow.addEventListener('load', handleLoad, { once: true })
})
const waitForPopupFrame = (printWindow: Window) =>
new Promise<void>((resolve) => {
printWindow.requestAnimationFrame(() => resolve())
})
const waitForImageReady = async (image: HTMLImageElement) => {
if (!image.complete || image.naturalWidth === 0) {
await new Promise<void>((resolve, reject) => {
const handleLoad = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error('标签图片加载失败,无法发起打印'))
}
const cleanup = () => {
image.removeEventListener('load', handleLoad)
image.removeEventListener('error', handleError)
}
image.addEventListener('load', handleLoad)
image.addEventListener('error', handleError)
})
}
if (typeof image.decode === 'function') {
try {
await image.decode()
} catch (error) {
// 某些浏览器会在 data URL 上抛 decode 异常,但图片实际上已可用于打印。
void error
}
}
}
export const waitForLocalBatchPrintWindowReady = async (printWindow: Window) => {
await waitForPopupLoad(printWindow)
const images = Array.from(printWindow.document.images)
await Promise.all(images.map((image) => waitForImageReady(image)))
// 连续等待两帧,尽量让浏览器把图片布局和分页都稳定下来后再拉起打印。
await waitForPopupFrame(printWindow)
await waitForPopupFrame(printWindow)
}
export const buildLocalBatchPrintHtml = (
images: string[],
preset: LocalBatchPrintPreset
) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>二维码打印</title>
<style>
@page {
size: ${preset.widthMm}mm ${preset.heightMm}mm;
margin: 0;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body {
width: auto;
}
.print-page {
width: ${preset.widthMm}mm;
height: ${preset.heightMm}mm;
display: flex;
align-items: stretch;
justify-content: stretch;
box-sizing: border-box;
break-after: page;
page-break-after: always;
break-inside: avoid;
page-break-inside: avoid;
}
.print-page:last-child {
break-after: auto;
page-break-after: auto;
}
.qrcode-image {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
</style>
</head>
<body>
${images.map((img) => `<div class="print-page"><img src="${img}" class="qrcode-image" /></div>`).join('')}
</body>
</html>
`

View File

@ -46,7 +46,7 @@
v-model.trim="filters.person_name"
class="input"
type="text"
placeholder="输入花名、姓名或证件号"
placeholder="输入花名、姓名或工号"
@keyup.enter="handleSearch"
/>
<input

View File

@ -223,11 +223,11 @@
<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>
<p class="mt-1 text-xs text-text-secondary">
{{ person.department || '未填写部门' }}
<span v-if="person.phone"> · {{ person.phone }}</span>
<span v-if="person.job_number"> · {{ person.job_number }}</span>
</p>
</div>
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
</div>

View File

@ -2,7 +2,7 @@ export interface ManualBorrowSearchPerson {
id: number
nickname: string
real_name: string
id_number?: string | null
job_number?: string | null
department?: string | null
department_names?: string[]
phone?: string | null
@ -26,19 +26,19 @@ const buildDepartmentText = (
}
const getMatchScore = (
person: Pick<ManualBorrowSearchPerson, 'nickname' | 'real_name' | 'id_number'>,
person: Pick<ManualBorrowSearchPerson, 'nickname' | 'real_name' | 'job_number'>,
keyword: string
) => {
const nickname = normalize(person.nickname)
const realName = normalize(person.real_name)
const idNumber = normalize(person.id_number)
const jobNumber = normalize(person.job_number)
if (nickname === keyword) return 0
if (realName === keyword) return 1
if (idNumber === keyword) return 2
if (jobNumber === keyword) return 2
if (nickname.startsWith(keyword)) return 3
if (realName.startsWith(keyword)) return 4
if (idNumber.startsWith(keyword)) return 5
if (jobNumber.startsWith(keyword)) return 5
if (nickname.includes(keyword)) return 6
if (realName.includes(keyword)) return 7
return 8
@ -46,11 +46,15 @@ const getMatchScore = (
export const filterManualBorrowPersons = <T extends ManualBorrowSearchPerson>(
persons: T[],
keyword: string
keyword: string,
options: {
allowInactive?: boolean
} = {}
) => {
const normalizedKeyword = normalize(keyword)
const allowInactive = Boolean(options.allowInactive)
const list = persons.filter((person) => {
if (person.status !== 'active') {
if (!allowInactive && person.status !== 'active') {
return false
}
@ -61,13 +65,18 @@ export const filterManualBorrowPersons = <T extends ManualBorrowSearchPerson>(
return (
includesKeyword(person.nickname, normalizedKeyword) ||
includesKeyword(person.real_name, normalizedKeyword) ||
includesKeyword(person.id_number, normalizedKeyword) ||
includesKeyword(person.job_number, normalizedKeyword) ||
includesKeyword(buildDepartmentText(person), normalizedKeyword) ||
includesKeyword(person.phone, normalizedKeyword)
)
})
return list.sort((left, right) => {
const statusDiff = Number(left.status !== 'active') - Number(right.status !== 'active')
if (statusDiff !== 0) {
return statusDiff
}
if (!normalizedKeyword) {
return `${left.nickname}${left.real_name}`.localeCompare(
`${right.nickname}${right.real_name}`,
@ -100,7 +109,7 @@ export const findPreferredManualBorrowPerson = <T extends ManualBorrowSearchPers
return (
normalize(person.nickname) === normalizedKeyword ||
normalize(person.real_name) === normalizedKeyword ||
normalize(person.id_number) === normalizedKeyword
normalize(person.job_number) === normalizedKeyword
)
})
@ -124,7 +133,7 @@ export const isManualBorrowSelectedPersonKeyword = <T extends ManualBorrowSearch
return [
normalize(person.nickname),
normalize(person.real_name),
normalize(person.id_number),
normalize(person.job_number),
displayName
]
.filter(Boolean)

View File

@ -770,7 +770,8 @@ const submitDisposal = async () => {
await createDisposal({ equipment_id: Number(route.params.id), ...disposalForm.value })
showDisposalForm.value = false
disposalForm.value = { disposal_reason: '', disposal_method: '报废', estimated_value: 0, notes: '' }
fetchDisposalRecords()
await fetchEquipmentData()
await fetchDisposalRecords()
alert('报废申请已提交')
} catch (error: any) { alert(error?.message || '提交报废申请失败') }
}

File diff suppressed because it is too large Load Diff

View File

@ -24,12 +24,12 @@
<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">仅纳入能明确挂到 1 位当前使用人的资产</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?.items_count || 0) }}</p>
<p class="page-meta__subvalue">无人归属 {{ formatNumber(preview?.unassigned_count || 0) }} 件</p>
<p class="page-meta__subvalue">其中无人归属 {{ formatNumber(preview?.unassigned_count || 0) }} 件</p>
</div>
</div>
</section>
@ -241,7 +241,7 @@
<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>
@ -249,9 +249,9 @@
<p class="mt-1 text-xs text-text-secondary">按当前借用快照生成</p>
</div>
<div class="panel">
<p class="mobile-card__label">无人归属</p>
<p class="mobile-card__label">无人归属资产</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p>
<p class="mt-1 text-xs text-text-secondary">新规则下应为 0</p>
<p class="mt-1 text-xs text-text-secondary">这部分会单列给管理员处理,其他资产管理员也可直接代扫</p>
</div>
</div>
@ -307,9 +307,17 @@
:key="participant.person_id"
class="panel"
>
<p class="text-sm font-medium text-text-primary">
{{ participant.person_nickname }} · {{ participant.person_name }}
</p>
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-text-primary">
{{ participant.person_nickname }} · {{ participant.person_name }}
</p>
<span
v-if="isInventoryPublicAssetPerson(participant)"
class="badge badge-neutral !border-status-warning/30 !bg-status-warning/10 !text-status-warning"
>
{{ getInventoryPublicAssetLabel() }}
</span>
</div>
<p class="mt-1 text-xs text-text-secondary">
{{ participant.department_name || '未归属部门' }}
</p>
@ -332,6 +340,9 @@
<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 || '未纳入' }}
<span v-if="isInventoryPublicAssetPerson(item)" class="ml-2 text-status-warning">
· {{ getInventoryPublicAssetLabel() }}
</span>
<span v-if="item.snapshot_department_name"> · {{ item.snapshot_department_name }}</span>
</p>
</div>
@ -357,6 +368,7 @@ import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
import { getStorageLocationOptions } from '@/api/storageLocation'
import type { AssetType, Department, InventoryTaskPreview, StorageLocation } from '@/types'
import { formatNumber } from '@/utils/format'
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
const router = useRouter()

View File

@ -54,9 +54,9 @@
<p class="page-meta__subvalue">待确认 {{ formatNumber(pendingParticipantCount) }} 名</p>
</div>
<div class="page-meta">
<p class="page-meta__label">待分配资产</p>
<p class="page-meta__label">无人归属资产</p>
<p class="page-meta__value">{{ formatNumber(unassignedItems.length) }}</p>
<p class="page-meta__subvalue">手机链接随任务自动生成</p>
<p class="page-meta__subvalue">这部分会单列处理,其他责任人资产管理员也可直接代扫</p>
</div>
</div>
</section>
@ -140,6 +140,58 @@
</div>
</section>
<section v-if="task.status === 'active'" class="card">
<div class="list-header">
<div>
<h3 class="section-title">管理员代扫</h3>
<p class="mt-1 text-sm text-text-secondary">管理员可直接替任意责任人完成扫码盘点,也能顺手处理无人归属资产。</p>
</div>
<span class="badge badge-warning">全任务通用</span>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto_auto] lg:items-end">
<div class="field-stack">
<label class="field-label">资产编码</label>
<input
v-model.trim="adminScanCode"
type="text"
class="input"
placeholder="直接扫码或输入资产编码"
:disabled="adminScanning"
@keyup.enter="handleAdminManualScan"
/>
</div>
<button
type="button"
class="btn-secondary text-sm"
:disabled="adminScanning"
@click="showAdminScanner = true"
>
手机扫码
</button>
<button
type="button"
class="btn-primary text-sm"
:disabled="adminScanning || !adminScanCode"
@click="handleAdminManualScan"
>
{{ adminScanning ? '处理中...' : '确认代扫' }}
</button>
</div>
<div v-if="adminLastHandledItem" class="mt-4 rounded-2xl border border-status-success/30 bg-status-success/10 px-4 py-4">
<p class="text-sm font-medium text-status-success">最近代扫成功</p>
<p class="mt-2 text-sm text-text-primary">
{{ adminLastHandledItem.equipment_name }}({{ adminLastHandledItem.equipment_code }})
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ adminLastHandledItem.snapshot_person_nickname || '无人归属资产' }}
<span v-if="adminLastHandledItem.snapshot_person_name"> · {{ adminLastHandledItem.snapshot_person_name }}</span>
<span v-if="adminLastHandledItem.snapshot_department_name"> · {{ adminLastHandledItem.snapshot_department_name }}</span>
</p>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">参与人进度</h3>
@ -154,9 +206,17 @@
>
<div class="mobile-card__row">
<div>
<p class="text-sm font-semibold text-text-primary">
{{ participant.person_nickname }} · {{ participant.person_name }}
</p>
<div class="flex items-center gap-2">
<p class="text-sm font-semibold text-text-primary">
{{ participant.person_nickname }} · {{ participant.person_name }}
</p>
<span
v-if="isInventoryPublicAssetPerson(participant)"
class="badge badge-neutral !border-status-warning/30 !bg-status-warning/10 !text-status-warning"
>
{{ getInventoryPublicAssetLabel() }}
</span>
</div>
<p class="mt-1 text-xs text-text-tertiary">{{ participant.department_name || '未归属部门' }}</p>
</div>
<span class="badge" :class="getParticipantStatusClass(participant.status)">
@ -205,7 +265,15 @@
<tbody>
<tr v-for="participant in participantRows" :key="participant.id">
<td>
<p class="text-text-primary">{{ participant.person_nickname }}</p>
<div class="flex items-center gap-2">
<p class="text-text-primary">{{ participant.person_nickname }}</p>
<span
v-if="isInventoryPublicAssetPerson(participant)"
class="badge badge-neutral !border-status-warning/30 !bg-status-warning/10 !text-status-warning"
>
{{ getInventoryPublicAssetLabel() }}
</span>
</div>
<p class="mt-1 text-xs text-text-secondary">{{ participant.person_name }}</p>
</td>
<td class="text-sm text-text-primary">{{ participant.department_name || '-' }}</td>
@ -282,6 +350,9 @@
<p class="mobile-card__label">责任人</p>
<p class="mobile-card__value">
{{ item.snapshot_person_nickname || '待分配' }}
<span v-if="isInventoryPublicAssetPerson(item)" class="ml-2 text-status-warning">
· {{ getInventoryPublicAssetLabel() }}
</span>
<span v-if="item.snapshot_person_name" class="block text-xs text-text-secondary">
{{ item.snapshot_person_name }}
</span>
@ -333,7 +404,12 @@
<p class="mt-1 text-xs text-text-secondary">{{ item.asset_type_name || item.equipment_type }}</p>
</td>
<td>
<p class="text-text-primary">{{ item.snapshot_person_nickname || '待分配' }}</p>
<p class="text-text-primary">
{{ item.snapshot_person_nickname || '待分配' }}
<span v-if="isInventoryPublicAssetPerson(item)" class="ml-2 text-xs text-status-warning">
{{ getInventoryPublicAssetLabel() }}
</span>
</p>
<p class="mt-1 text-xs text-text-secondary">{{ item.snapshot_person_name || '-' }}</p>
</td>
<td class="text-sm text-text-primary">{{ item.snapshot_department_name || '-' }}</td>
@ -361,8 +437,8 @@
<section v-if="unassignedItems.length > 0" class="card">
<div class="list-header">
<h3 class="section-title">待分配资产</h3>
<span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待处理</span>
<h3 class="section-title">无人归属待分配资产</h3>
<span class="badge badge-warning">{{ formatNumber(unassignedItems.length) }} 件待分配</span>
</div>
<div class="space-y-3">
@ -433,6 +509,14 @@
<section v-else class="card">
<div class="empty-state">没有找到这个盘点任务。</div>
</section>
<CameraScannerModal
v-model="showAdminScanner"
title="管理员代扫"
description="扫码后将直接替责任人完成本次盘点。"
manual-placeholder="请输入资产编码"
@detected="handleAdminScannerDetected"
/>
</div>
</template>
@ -445,8 +529,10 @@ import {
closeInventoryTask,
deleteInventoryTask,
getInventoryTaskDetail,
getInventoryTaskUnassignedItems
getInventoryTaskUnassignedItems,
submitAdminInventoryScan
} from '@/api/inventory'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
import type {
InventoryTaskAction,
InventoryTaskDetail,
@ -455,6 +541,7 @@ import type {
Person
} from '@/types'
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
@ -476,9 +563,13 @@ const loading = ref(false)
const closing = ref(false)
const deleting = ref(false)
const assigningItemId = ref<number | null>(null)
const adminScanning = ref(false)
const itemFilter = ref<ItemFilter>('all')
const participantFilter = ref('all')
const itemKeyword = ref('')
const adminScanCode = ref('')
const showAdminScanner = ref(false)
const adminLastHandledItem = ref<InventoryTaskItem | null>(null)
const assignPersonIdMap = reactive<Record<number, string>>({})
const taskId = computed(() => Number(route.params.id))
@ -606,6 +697,7 @@ const resolveActionSummary = (action: InventoryTaskAction) => {
task_closed: '关闭了盘点任务',
participant_confirmed: '盘点人完成身份确认',
item_checked: '确认了一件资产',
item_checked_by_admin: '管理员代扫了一件资产',
item_exception_reported: '上报了一件异常资产',
item_reassigned: '重新分配了一件无人归属资产'
}
@ -622,6 +714,10 @@ const resolveActionDetail = (action: InventoryTaskAction) => {
: '任务已初始化完成。'
case 'item_checked':
return payload?.equipment_code ? `资产编码:${payload.equipment_code}` : '资产已被确认。'
case 'item_checked_by_admin':
return payload?.equipment_code
? `管理员代扫资产编码:${payload.equipment_code}${payload?.is_unassigned ? '(原为无人归属)' : ''}`
: '管理员已代为完成扫码盘点。'
case 'item_exception_reported':
return payload
? `${getExceptionText(payload.exception_type)}${payload.exception_note ? `,备注:${payload.exception_note}` : ''}`
@ -704,6 +800,33 @@ const handleCloseTask = async () => {
}
}
const runAdminScan = async (equipmentCode: string) => {
const normalizedCode = equipmentCode.trim()
if (!normalizedCode || !task.value) return
adminScanning.value = true
try {
const response = await submitAdminInventoryScan(task.value.id, normalizedCode)
adminLastHandledItem.value = response.data
adminScanCode.value = ''
showAdminScanner.value = false
await loadTask()
} catch (error: any) {
alert(error?.message || '管理员扫码盘点失败')
} finally {
adminScanning.value = false
}
}
const handleAdminManualScan = async () => {
await runAdminScan(adminScanCode.value)
}
const handleAdminScannerDetected = async (value: string) => {
adminScanCode.value = value.trim()
await runAdminScan(adminScanCode.value)
}
const handleDeleteTask = async () => {
if (!task.value) return

View File

@ -0,0 +1,32 @@
const PUBLIC_ASSET_SOURCE_USERID = '__public_asset__'
const PUBLIC_ASSET_NAME = '公共资产'
type PublicAssetLike = {
person_source_userid?: string | null
person_nickname?: string | null
person_name?: string | null
snapshot_person_nickname?: string | null
snapshot_person_name?: string | null
}
const normalizeText = (value?: string | null) => String(value || '').trim()
const hasPublicAssetIdentity = (nickname?: string | null, name?: string | null) =>
normalizeText(nickname) === PUBLIC_ASSET_NAME && normalizeText(name) === PUBLIC_ASSET_NAME
export const isInventoryPublicAssetPerson = (value?: PublicAssetLike | null) => {
if (!value) {
return false
}
if (normalizeText(value.person_source_userid) === PUBLIC_ASSET_SOURCE_USERID) {
return true
}
return hasPublicAssetIdentity(
value.person_nickname ?? value.snapshot_person_nickname,
value.person_name ?? value.snapshot_person_name
)
}
export const getInventoryPublicAssetLabel = () => '系统保留'

View File

@ -147,7 +147,7 @@
<tr v-for="person in persons" :key="person.id">
<td class="font-medium">{{ person.nickname }}</td>
<td>{{ person.real_name }}</td>
<td class="font-mono text-sm text-text-secondary">{{ person.id_number || '-' }}</td>
<td class="font-mono text-sm text-text-secondary">{{ person.job_number || '-' }}</td>
<td class="text-sm text-text-secondary">
<p>{{ person.main_department?.name || person.department || '-' }}</p>
<p v-if="person.position" class="text-text-tertiary">{{ person.position }}</p>
@ -227,7 +227,11 @@
</div>
<div class="field-stack">
<label class="field-label">工号</label>
<input v-model.trim="newPerson.id_number" class="input" type="text" placeholder="请输入工号(可选)" />
<input v-model.trim="newPerson.job_number" class="input" type="text" placeholder="请输入工号(可选)" />
</div>
<div class="field-stack">
<label class="field-label">身份证号</label>
<input v-model.trim="newPerson.id_number" class="input" type="text" placeholder="请输入身份证号(可选)" />
</div>
<div class="field-stack">
<label class="field-label">部门</label>
@ -281,6 +285,10 @@
</div>
<div class="field-stack">
<label class="field-label">工号</label>
<input v-model.trim="editDraft.job_number" class="input" type="text" />
</div>
<div class="field-stack">
<label class="field-label">身份证号</label>
<input v-model.trim="editDraft.id_number" class="input" type="text" />
</div>
<div class="field-stack">
@ -391,12 +399,12 @@ const confirmDialog = reactive({
})
const newPerson = reactive({
nickname: '', real_name: '', id_number: '', department: '', position: '', phone: '', email: '', status: 'active', remarks: ''
nickname: '', real_name: '', job_number: '', id_number: '', department: '', position: '', phone: '', email: '', status: 'active', remarks: ''
})
const editingPerson = ref<Person | null>(null)
const editDraft = reactive({
id: 0, nickname: '', real_name: '', id_number: '', department: '', position: '', phone: '', email: '', status: 'active' as 'active' | 'inactive', remarks: ''
id: 0, nickname: '', real_name: '', job_number: '', id_number: '', department: '', position: '', phone: '', email: '', status: 'active' as 'active' | 'inactive', remarks: ''
})
const canCreate = computed(() => newPerson.nickname.trim() && newPerson.real_name.trim())
@ -513,6 +521,7 @@ const fetchSummary = async () => {
const resetNewForm = () => {
newPerson.nickname = ''
newPerson.real_name = ''
newPerson.job_number = ''
newPerson.id_number = ''
newPerson.department = ''
newPerson.position = ''
@ -535,6 +544,7 @@ const handleCreatePerson = async () => {
await createPerson({
nickname: newPerson.nickname,
real_name: newPerson.real_name,
job_number: newPerson.job_number || undefined,
id_number: newPerson.id_number || undefined,
department: newPerson.department || undefined,
position: newPerson.position || undefined,
@ -558,6 +568,7 @@ const openEdit = (person: Person) => {
editDraft.id = person.id
editDraft.nickname = person.nickname
editDraft.real_name = person.real_name
editDraft.job_number = person.job_number || ''
editDraft.id_number = person.id_number || ''
editDraft.department = person.department || ''
editDraft.position = person.position || ''
@ -579,6 +590,7 @@ const submitEdit = async () => {
await updatePerson(editingPerson.value.id, {
nickname: editDraft.nickname,
real_name: editDraft.real_name,
job_number: editDraft.job_number || undefined,
id_number: editDraft.id_number || undefined,
department: editDraft.department || undefined,
position: editDraft.position || undefined,
@ -706,11 +718,12 @@ const handleFileImport = async (event: Event) => {
.map((cols) => ({
nickname: String(cols[0] || '').trim(),
real_name: String(cols[1] || '').trim(),
id_number: cols[2] ? String(cols[2]).trim() : undefined,
department: cols[3] ? String(cols[3]).trim() : undefined,
position: cols[4] ? String(cols[4]).trim() : undefined,
phone: cols[5] ? String(cols[5]).trim() : undefined,
email: cols[6] ? String(cols[6]).trim() : undefined
job_number: cols[2] ? String(cols[2]).trim() : undefined,
id_number: cols[3] ? String(cols[3]).trim() : undefined,
department: cols[4] ? String(cols[4]).trim() : undefined,
position: cols[5] ? String(cols[5]).trim() : undefined,
phone: cols[6] ? String(cols[6]).trim() : undefined,
email: cols[7] ? String(cols[7]).trim() : undefined
}))
.filter((p) => p.nickname && p.real_name)

View File

@ -1,12 +1,12 @@
{
"name": "fixed-asset-print-agent",
"version": "0.1.0",
"version": "0.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fixed-asset-print-agent",
"version": "0.1.0",
"version": "0.2.6",
"dependencies": {
"qrcode": "^1.5.4"
},
@ -423,6 +423,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@ -444,6 +445,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@ -460,6 +462,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@ -474,6 +477,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@ -910,7 +914,6 @@
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -1728,7 +1731,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@ -1967,7 +1971,6 @@
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.8.1",
"builder-util": "26.8.1",
@ -2269,6 +2272,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@ -2289,6 +2293,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@ -3582,6 +3587,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@ -3961,7 +3967,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -4000,6 +4005,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@ -4017,6 +4023,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@ -4296,6 +4303,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@ -4701,6 +4709,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"

View File

@ -1,6 +1,6 @@
{
"name": "fixed-asset-print-agent",
"version": "0.1.0",
"version": "0.2.6",
"description": "固定资产系统 Windows 托盘常驻打印助手,支持远程标签静默打印",
"main": "src/main.js",
"type": "module",

View File

@ -2,6 +2,9 @@ import fs from 'node:fs/promises'
import path from 'node:path'
export const CONFIG_FILE_NAME = 'agent-config.json'
export const DEFAULT_SERVER_URL = 'https://asset.jiansun.vip'
const LEGACY_SERVER_HOSTS = new Set(['www.jsgdzc.cn', 'jsgdzc.cn'])
const DEFAULT_DRIVER_HELP = {
officialLatest: {
@ -18,9 +21,32 @@ const DEFAULT_DRIVER_HELP = {
}
}
export function normalizeServerUrl(serverUrl) {
const raw = String(serverUrl || '').trim()
if (!raw) {
return DEFAULT_SERVER_URL
}
try {
const url = new URL(raw)
url.hash = ''
url.search = ''
url.pathname = url.pathname.replace(/\/api$/i, '')
if (LEGACY_SERVER_HOSTS.has(url.hostname.toLowerCase())) {
return DEFAULT_SERVER_URL
}
return url.toString().replace(/\/+$/, '')
} catch {
return raw.replace(/\/api$/i, '').replace(/\/+$/, '') || DEFAULT_SERVER_URL
}
}
export function defaultAgentConfig() {
return {
serverUrl: 'https://www.jsgdzc.cn',
serverUrl: DEFAULT_SERVER_URL,
pairingCode: '',
deviceToken: '',
terminalId: null,
@ -43,10 +69,12 @@ export function createConfigStore(baseDir) {
async function load() {
try {
const content = await fs.readFile(filePath, 'utf8')
return {
const config = {
...defaultAgentConfig(),
...JSON.parse(content)
}
config.serverUrl = normalizeServerUrl(config.serverUrl)
return config
} catch (error) {
if (error && error.code === 'ENOENT') {
return defaultAgentConfig()
@ -62,6 +90,8 @@ export function createConfigStore(baseDir) {
...patch
}
next.serverUrl = normalizeServerUrl(next.serverUrl)
if (patch?.pairResult) {
next.terminalId =
patch.pairResult.terminalId ??

View File

@ -37,7 +37,7 @@
<form id="pair-form" class="field-stack">
<label class="field">
<span>服务器地址</span>
<input id="server-url" type="url" placeholder="https://www.jsgdzc.cn" />
<input id="server-url" type="url" placeholder="https://asset.jiansun.vip" />
</label>
<label class="field">
<span>终端名称</span>

View File

@ -11,7 +11,11 @@ import {
shell
} from 'electron'
import { createConfigStore } from './config/storage.js'
import {
createConfigStore,
DEFAULT_SERVER_URL,
normalizeServerUrl
} from './config/storage.js'
import {
loadDriverResources,
resolveDriverDownloadUrl
@ -21,6 +25,7 @@ import { createJobProcessor } from './services/jobProcessor.js'
import { createAgentRuntime } from './services/agentRuntime.js'
import {
buildAssetLabelPrintHtml,
buildRenderedLabelImagePrintHtml,
buildTestLabelPrintHtml
} from './services/labelDocument.js'
import { printHtmlSilently } from './services/electronPrinter.js'
@ -134,7 +139,7 @@ async function getDriverResourcesForUi(config) {
companyArchive: {
...resources.companyArchive,
downloadUrl: resolveDriverDownloadUrl(
config?.serverUrl || 'https://www.jsgdzc.cn',
normalizeServerUrl(config?.serverUrl || DEFAULT_SERVER_URL),
resources.companyArchive
)
}
@ -168,8 +173,9 @@ function registerIpcHandlers() {
ipcMain.handle('agent:pair', async (_event, payload) => {
const currentConfig = await runtime.loadConfig()
const serverUrl = normalizeServerUrl(payload.serverUrl || currentConfig.serverUrl)
const client = createPrintAgentClient({
serverUrl: payload.serverUrl || currentConfig.serverUrl
serverUrl
})
const result = await client.pair({
@ -180,7 +186,7 @@ function registerIpcHandlers() {
})
const config = await runtime.updateConfig({
serverUrl: payload.serverUrl || currentConfig.serverUrl,
serverUrl,
terminalName: payload.terminalName || currentConfig.terminalName,
pairingCode: '',
pairResult: result
@ -196,10 +202,15 @@ function registerIpcHandlers() {
async function createRuntime() {
store = createConfigStore(path.join(app.getPath('userData'), 'data'))
const jobProcessor = createJobProcessor({
async printAssetLabels({ items, printerName }) {
const html = await buildAssetLabelPrintHtml({
items
})
async printAssetLabels({ items, renderedLabels, printerName }) {
const html =
Array.isArray(renderedLabels) && renderedLabels.length > 0
? buildRenderedLabelImagePrintHtml({
images: renderedLabels.map((item) => item.image_data_url)
})
: await buildAssetLabelPrintHtml({
items
})
await printHtmlSilently({
html,

View File

@ -28,6 +28,21 @@ const getAssetLabelItems = (job) => {
return payload.items
}
const getRenderedLabelImages = (job) => {
const payload = parsePayload(job)
if (!payload || !Array.isArray(payload.rendered_labels)) {
return null
}
return payload.rendered_labels
.filter((item) => item && typeof item === 'object')
.map((item) => ({
equipment_id: Number(item.equipment_id),
image_data_url: String(item.image_data_url ?? '').trim()
}))
.filter((item) => item.equipment_id > 0 && item.image_data_url)
}
export function createTestPrintSuccessMessage(printerName) {
const resolvedPrinterName = normalizePrinterName(printerName) || '已选打印机'
return `测试标签已发送到 ${resolvedPrinterName},请确认是否按 60x40mm 正常出纸。`
@ -63,9 +78,11 @@ export function createJobProcessor({
}
try {
const renderedLabels = getRenderedLabelImages(job)
const printResult = await printAssetLabels({
job,
items,
renderedLabels,
printerName
})

View File

@ -1,6 +1,6 @@
import QRCode from 'qrcode'
const DEFAULT_COMPANY_NAME = '常州简尚家具有限公司'
const DEFAULT_COMPANY_NAME = '常州简尚家居有限公司'
const DEFAULT_EQUIPMENT_TYPE = '未分类资产'
const DEFAULT_ORGANIZATION = '未分配组织'
@ -391,6 +391,79 @@ const buildDocumentShell = (pages) => `
</html>
`
export const buildRenderedLabelImagePrintHtml = ({ images }) => {
if (!Array.isArray(images) || images.length === 0) {
throw new Error('没有可打印的网页成品标签图')
}
const normalizedImages = images
.map((image) => String(image ?? '').trim())
.filter(Boolean)
if (normalizedImages.length === 0) {
throw new Error('没有可打印的网页成品标签图')
}
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>固定资产标签打印</title>
<style>
@page {
size: 60mm 40mm;
margin: 0;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body {
width: auto;
}
.print-page {
width: 60mm;
height: 40mm;
display: flex;
align-items: stretch;
justify-content: stretch;
box-sizing: border-box;
break-after: page;
page-break-after: always;
break-inside: avoid;
page-break-inside: avoid;
}
.print-page:last-child {
break-after: auto;
page-break-after: auto;
}
.label-image {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
background: #ffffff;
}
</style>
</head>
<body>
${normalizedImages
.map((image) => `<div class="print-page"><img src="${escapeHtml(image)}" class="label-image" /></div>`)
.join('')}
</body>
</html>
`
}
export const buildAssetLabelPrintHtml = async ({
companyName = DEFAULT_COMPANY_NAME,
items

View File

@ -11,7 +11,7 @@ test('start 会立即发送一次心跳并尝试领取任务', async () => {
store: {
async load() {
return {
serverUrl: 'https://www.jsgdzc.cn/api',
serverUrl: 'https://asset.jiansun.vip/api',
deviceToken: 'device-token',
terminalId: 18,
terminalName: '行政标签机',

View File

@ -5,14 +5,16 @@ import os from 'node:os'
import path from 'node:path'
import {
DEFAULT_SERVER_URL,
createConfigStore,
defaultAgentConfig
defaultAgentConfig,
normalizeServerUrl
} from '../src/config/storage.js'
test('defaultAgentConfig 返回完整的初始配置', () => {
const config = defaultAgentConfig()
assert.equal(config.serverUrl, 'https://www.jsgdzc.cn')
assert.equal(config.serverUrl, DEFAULT_SERVER_URL)
assert.equal(config.pairingCode, '')
assert.equal(config.deviceToken, '')
assert.equal(config.terminalName, '')
@ -56,3 +58,13 @@ test('createConfigStore 可以保存并重新读取配置', async () => {
assert.equal(loaded.terminalId, 8)
assert.equal(loaded.deviceToken, 'terminal-token')
})
test('normalizeServerUrl 会把旧域名自动切换到当前可用域名', () => {
assert.equal(normalizeServerUrl('https://www.jsgdzc.cn'), DEFAULT_SERVER_URL)
assert.equal(normalizeServerUrl('https://www.jsgdzc.cn/api'), DEFAULT_SERVER_URL)
assert.equal(normalizeServerUrl('https://jsgdzc.cn/'), DEFAULT_SERVER_URL)
assert.equal(
normalizeServerUrl('https://asset.jiansun.vip/api'),
DEFAULT_SERVER_URL
)
})

View File

@ -45,6 +45,54 @@ test('正式资产标签任务会调用真实打印执行器并返回 completed'
assert.equal(printedJobs[0].items[0].equipment_code, 'JS-PC-2101')
})
test('正式资产标签任务会把网页成品 PNG 一并交给打印执行器,确保和网页预览一致', async () => {
const printedJobs = []
const processor = createJobProcessor({
async printAssetLabels(payload) {
printedJobs.push(payload)
return {
printed_count: payload.renderedLabels?.length ?? 0
}
}
})
const result = await processor.process(
{
id: 23,
job_type: 'asset_label',
payload: {
job_type: 'asset_label',
equipment_ids: [1],
items: [
{
id: 1,
equipment_code: 'JS-PC-2102',
equipment_name: '办公主机',
equipment_type: '主机'
}
],
rendered_labels: [
{
equipment_id: 1,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
}
]
}
},
{
selectedPrinterName: 'Xprinter XP-420B'
}
)
assert.equal(result.ok, true)
assert.equal(printedJobs.length, 1)
assert.equal(printedJobs[0].renderedLabels?.length, 1)
assert.equal(
printedJobs[0].renderedLabels?.[0]?.image_data_url,
'data:image/png;base64,ZmFrZS1pbWFnZS0x'
)
})
test('未选择打印机时会返回人话错误,不会继续打印', async () => {
const processor = createJobProcessor({
async printAssetLabels() {

View File

@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import {
buildAssetLabelPrintHtml,
buildRenderedLabelImagePrintHtml,
formatLabelDate,
splitCodeForDisplay
} from '../src/services/labelDocument.js'
@ -44,3 +45,14 @@ test('buildAssetLabelPrintHtml 会生成可直接静默打印的 60x40 标签文
assert.match(html, /行政部/)
assert.match(html, /data:image\/png;base64,/)
})
test('buildRenderedLabelImagePrintHtml 会按网页成品 PNG 逐张分页,直接复用网页最终版式', () => {
const html = buildRenderedLabelImagePrintHtml({
images: ['data:image/png;base64,ZmFrZS0x', 'data:image/png;base64,ZmFrZS0y']
})
assert.match(html, /@page\s*\{\s*size:\s*60mm 40mm;/)
assert.equal((html.match(/class="print-page"/g) || []).length, 2)
assert.match(html, /data:image\/png;base64,ZmFrZS0x/)
assert.match(html, /data:image\/png;base64,ZmFrZS0y/)
})