fix: preserve asset state and recover uncertain print results

This commit is contained in:
WuYongJie 2026-10-09 14:06:26 +08:00
parent 843babd381
commit 8cfeb1c6b1
103 changed files with 7950 additions and 3250 deletions

4169
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,6 @@
"express-session": "^1.17.3",
"keycloak-connect": "^23.0.7",
"mssql": "^11.0.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.0.0"
},
"devDependencies": {
@ -35,11 +34,26 @@
"@types/cors": "^2.8.0",
"@types/express": "^4.17.0",
"@types/express-session": "^1.17.7",
"@types/multer": "^1.4.7",
"@types/node": "^20.0.0",
"nodemon": "^3.0.0",
"prisma": "^5.0.0",
"ts-node": "^10.9.0",
"typescript": "^5.0.0"
},
"overrides": {
"exceljs": {
"uuid": "11.1.1"
},
"@azure-rest/core-client": "2.7.0",
"@azure/abort-controller": "2.1.2",
"@azure/core-auth": "1.10.1",
"@azure/core-client": "1.10.2",
"@azure/core-paging": "1.6.2",
"@azure/core-rest-pipeline": "1.24.0",
"@azure/core-tracing": "1.3.1",
"@azure/core-util": "1.13.1",
"@azure/identity": "4.13.1",
"@azure/logger": "1.3.0",
"@typespec/ts-http-runtime": "0.3.6"
}
}

View File

@ -0,0 +1,4 @@
-- Nullable: legacy maintenance records have no reliable pre-maintenance snapshot.
-- Do not backfill guessed asset statuses.
ALTER TABLE `maintenance_record`
ADD COLUMN `original_tree_snapshot` LONGTEXT NULL;

View File

@ -0,0 +1,8 @@
-- Add claim fencing and result verification without guessing historical delivery.
ALTER TABLE `print_terminal` ADD COLUMN `protocol_version` INTEGER NOT NULL DEFAULT 1;
ALTER TABLE `print_job`
ADD COLUMN `claim_token` VARCHAR(64) NULL,
ADD COLUMN `lease_expires_at` DATETIME(3) NULL,
ADD COLUMN `result_confirmed_by` INTEGER NULL,
ADD COLUMN `result_confirmed_at` DATETIME(3) NULL;
CREATE INDEX `print_job_status_lease_expires_at_idx` ON `print_job` (`status`, `lease_expires_at`);

View File

@ -191,6 +191,7 @@ model PrintTerminal {
platform String @db.VarChar(30)
machine_name String @db.VarChar(100)
status String @default("offline") @db.VarChar(20)
protocol_version Int @default(1)
is_default Boolean @default(false)
last_seen_at DateTime?
selected_printer_name String? @db.VarChar(150)
@ -241,6 +242,10 @@ model PrintJob {
payload_json String @db.Text
error_message String? @db.Text
claimed_at DateTime?
claim_token String? @db.VarChar(64)
lease_expires_at DateTime?
result_confirmed_by Int?
result_confirmed_at DateTime?
started_at DateTime?
completed_at DateTime?
attempt_count Int @default(0)
@ -252,6 +257,7 @@ model PrintJob {
requester User @relation("PrintJobRequester", fields: [requested_by], references: [id])
@@index([status, created_at])
@@index([status, lease_expires_at])
@@index([target_terminal_id, status, created_at])
@@index([requested_by])
@@index([claimed_by_terminal_id])
@ -798,6 +804,7 @@ model MaintenanceRecord {
actual_end_date DateTime? // 实际完成日期
status String @default("in_progress") @db.VarChar(20) // in_progress/completed/cancelled
repair_result String? @db.Text // 维修结果
original_tree_snapshot String? @db.LongText // 送修前整棵资产树状态和关系快照
created_by Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View File

@ -171,7 +171,7 @@ test('总量 2 并发借出 B 先提交后归还 A,使用锁后库存恢复为
unit_price: 100
}
const borrowRecords = [
{ id: 401, equipment_id: lockedRoot.id, status: '已借出', person_id: 1 },
{ id: 401, equipment_id: lockedRoot.id, status: '已借出', person_id: 1, children_snapshot: JSON.stringify([{ id: mountedChild.id, parent_id: lockedRoot.id }]) },
{ id: 402, equipment_id: lockedRoot.id, status: '已借出', person_id: 2 }
]
const rootUpdates: any[] = []

View File

@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { batchImportEquipments } from '../batchImportController'
test('Excel import preserves source row, rejects invalid quantities/prices without creating equipment, and retains legacy row fallback', async () => {
const originals = [prisma.assetType.findMany, prisma.department.findMany, prisma.storageLocation.findMany, prisma.equipment.create, prisma.operationLog.create]
let creates = 0
;(prisma.assetType.findMany as any) = async () => [{ id: 1, name: '电脑', can_be_child: false }]
;(prisma.department.findMany as any) = async () => []
;(prisma.storageLocation.findMany as any) = async () => []
;(prisma.equipment.create as any) = async () => { creates++; return {} }
;(prisma.operationLog.create as any) = async () => ({})
const state = { code: 200, body: null as any }
const res = { locals: { user: { id: 9, role: 'it' } }, status(code: number) { state.code = code; return this }, json(body: any) { state.body = body; return this } }
try {
const quantities = [null, 0, -1, 1.5, 'abc', '', true]
const equipments = quantities.map((quantity, index) => ({ equipment_name: '电脑', equipment_type: '电脑', total_quantity: quantity, source_row: index + 10 }))
equipments.push({ equipment_name: '电脑', equipment_type: '电脑', total_quantity: 1, unit_price: -2, source_row: 27 } as any)
equipments.push({ equipment_name: '电脑', equipment_type: '电脑', total_quantity: 1, unit_price: 'bad-price', source_row: 28 } as any)
equipments.push({ equipment_name: '电脑', equipment_type: '电脑', total_quantity: 1, unit_price: 'Infinity', source_row: 29 } as any)
equipments.push({ equipment_name: '', equipment_type: '电脑', total_quantity: 1, source_row: 2.5 } as any)
await batchImportEquipments({ body: { equipments }, ip: 'mock' } as any, res as any)
assert.equal(state.code, 200)
assert.equal(creates, 0)
assert.equal(state.body.data.failed, 11)
assert.deepEqual(state.body.data.errors.map((item: any) => item.row), [10, 11, 12, 13, 14, 15, 16, 27, 28, 29, 12])
assert.match(state.body.data.errors[0].message, /数量.*整数/)
assert.match(state.body.data.errors[7].message, /单价/)
} finally {
;[prisma.assetType.findMany, prisma.department.findMany, prisma.storageLocation.findMany, prisma.equipment.create, prisma.operationLog.create] = originals as any
}
})
test('valid positive quantity and zero price retain exact stock/value while child types still require one item', async () => {
const targets: Array<[any, string, any]> = [
[prisma.assetType, 'findMany', async () => [{ id: 1, name: '电脑', can_be_child: false }, { id: 2, name: '内存', can_be_child: true }]],
[prisma.assetType, 'findFirst', async () => ({ id: 1, short_name: 'PC' })],
[prisma.department, 'findMany', async () => []],
[prisma.storageLocation, 'findMany', async () => []],
[prisma.equipment, 'findMany', async () => []],
[prisma.equipment, 'findUnique', async () => null],
[prisma.operationLog, 'create', async () => ({})]
]
const captured: any[] = []
targets.push([prisma.equipment, 'create', async (args: any) => { captured.push(args.data); return args.data }])
const original = targets.map(([object, key]) => object[key])
targets.forEach(([object, key, fn]) => { object[key] = fn })
const state = { body: null as any }
const res = { locals: { user: { id: 9, role: 'it' } }, json(body: any) { state.body = body; return this }, status() { return this } }
try {
await batchImportEquipments({ ip: 'mock', body: { equipments: [
{ equipment_name: '办公电脑', equipment_type: '电脑', total_quantity: '2', unit_price: 0, source_row: 6 },
{ equipment_name: '多数量配件', equipment_type: '内存', total_quantity: 2, source_row: 19 }
] } } as any, res as any)
assert.equal(state.body.data.success, 1)
assert.equal(state.body.data.failed, 1)
assert.equal(state.body.data.errors[0].row, 19)
assert.match(state.body.data.errors[0].message, /子资产.*数量必须为 1/)
assert.equal(captured.length, 1)
assert.equal(captured[0].total_quantity, 2)
assert.equal(captured[0].remaining_quantity, 2)
assert.equal(captured[0].available_value, 0)
} finally {
targets.forEach(([object, key], index) => { object[key] = original[index] })
}
})

View File

@ -44,7 +44,7 @@ test('单笔归还省略部门动作或显式 clear 均按入仓规则清空部
equipment_id: root.id,
person_id: 5,
status: '已借出',
children_snapshot: null
children_snapshot: JSON.stringify([{ id: child.id, parent_id: root.id }])
}
const rootUpdates: any[] = []
const childUpdates: any[] = []
@ -271,7 +271,7 @@ test('行政可在报废前将唯一未归还资产快捷入库,并保留正
assert.equal(state.body.message, '资产已入库,可以继续申请报废')
assert.equal(state.body.data.return_condition, '损坏')
assert.equal(borrowRecord.status, '已归还')
assert.equal(rootUpdates.at(-1)?.data.status, '可用')
assert.equal(rootUpdates.at(-1)?.data.status, '已损坏')
assert.equal(rootUpdates.at(-1)?.data.department_id, null)
assert.deepEqual(movementReasons, ['报废前快捷入库'])
} finally {

View File

@ -0,0 +1,129 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { returnEquipment, scanReturn, scanReturnBatch } from '../borrowController'
import { batchReturn } from '../batchController'
const now = new Date('2026-10-09T02:00:00Z')
const root = {
id: 10, equipment_code: 'ROOT', equipment_name: '主机', equipment_model: null,
equipment_type: '电脑', parent_id: null, department_id: 3, storage_location_id: null,
status: '已借出', mount_status: 'none', total_quantity: 1, remaining_quantity: 0,
unit_price: 100, updated_at: now
}
const child = { ...root, id: 11, equipment_code: 'CHILD', parent_id: 10, mount_status: 'mounted', unit_price: 20 }
const record = { id: 5, equipment_id: 10, equipment_code: 'ROOT', person_id: 8, status: '已借出', children_snapshot: null, updated_at: now }
const run = async (handler: any, tree: any[], snapshot: string | null, body: Record<string, unknown>) => {
const originals = [prisma.equipment.findUnique, prisma.borrowRecord.findFirst, prisma.borrowRecord.findMany, prisma.$transaction, prisma.operationLog.create]
const borrowed: any = { ...record, children_snapshot: snapshot }
let committed = tree.map((item) => ({ ...item }))
const movements: any[] = []
let writes = 0
;(prisma.equipment.findUnique as any) = async () => ({ ...committed[0] })
;(prisma.borrowRecord.findFirst as any) = async () => ({ ...borrowed })
;(prisma.borrowRecord.findMany as any) = async () => [{ ...borrowed }]
;(prisma.operationLog.create as any) = async () => ({})
;(prisma.$transaction as any) = async (callback: any) => {
const working = committed.map((item) => ({ ...item }))
const tx = {
$queryRaw: async (query: any) => {
if (query.sql.includes('WHERE equipment.id = ?')) return working.filter((item) => item.id === query.values[0])
if (query.sql.includes('WHERE equipment.parent_id IN')) return working.filter((item) => query.values.includes(item.parent_id))
return []
},
equipment: {
findUnique: async ({ where }: any) => ({ ...working.find((item) => item.id === where.id) }),
findMany: async ({ where }: any) => working.filter((item) => where.parent_id.in.includes(item.parent_id)).map((item) => ({ ...item })),
updateMany: async ({ where, data }: any) => { writes++; Object.assign(working.find((item) => item.id === where.id)!, data); return { count: 1 } },
update: async ({ where, data }: any) => { writes++; Object.assign(working.find((item) => item.id === where.id)!, data); return {} }
},
department: { findUnique: async () => ({ name: '部门' }) },
borrowRecord: { updateMany: async ({ data }: any) => { writes++; Object.assign(borrowed, data); return { count: 1 } } },
assetTransferRecord: { create: async ({ data }: any) => { movements.push(data); return { id: 1, items: [] } } }
}
const result = await callback(tx)
committed = working
return result
}
const state = { code: 200, body: null as any }
const res = {
locals: { user: { id: 9, role: 'it', username: 'it' } },
status(code: number) { state.code = code; return this },
json(payload: any) { state.body = payload; return this }
}
try {
await handler({ body: { equipment_id: 10, equipment_code: 'ROOT', equipment_ids: [10], equipment_codes: ['ROOT'], borrow_record_id: 5, ...body }, ip: 'mock' }, res)
return { state, committed, borrowed, movements, writes }
} finally {
;[prisma.equipment.findUnique, prisma.borrowRecord.findFirst, prisma.borrowRecord.findMany, prisma.$transaction, prisma.operationLog.create] = originals as any
}
}
test('single, scan and batch damaged/lost returns preserve physical stock and never create available value', async () => {
for (const handler of [returnEquipment, scanReturn, scanReturnBatch, batchReturn]) {
for (const condition of ['损坏', '丢失']) {
const result = await run(handler, [root], null, { return_condition: condition })
assert.equal(result.state.code, 200)
if (handler === scanReturnBatch || handler === batchReturn) assert.equal(result.state.body.data.success_count, 1)
assert.equal(result.borrowed.status, '已归还')
assert.equal(result.borrowed.return_condition, condition)
assert.equal(result.committed[0].status, condition === '损坏' ? '已损坏' : '已丢失')
assert.equal(result.committed[0].remaining_quantity, condition === '损坏' ? 1 : 0)
assert.equal(result.committed[0].available_value, 0)
assert.equal(result.committed[0].borrowed_value, 0)
assert.equal(result.movements[0].action_type, condition === '丢失' ? 'loss' : 'return')
if (condition === '丢失') assert.match(result.movements[0].reason, /未入仓/)
}
}
})
test('non-normal return of multi-quantity asset preserves active borrow and inventory with explicit conflict', async () => {
for (const handler of [returnEquipment, scanReturn, scanReturnBatch, batchReturn]) {
for (const condition of ['损坏', '丢失']) {
const multi = { ...root, total_quantity: 2 }
const result = await run(handler, [multi], null, { return_condition: condition })
if (handler === returnEquipment || handler === scanReturn) {
assert.equal(result.state.code, 409)
assert.match(result.state.body.message, /多数量.*原借用单保持未归还/)
} else {
assert.equal(result.state.body.data.failed_count, 1)
assert.match(result.state.body.data.results[0].message, /多数量/)
}
assert.equal(result.borrowed.status, '已借出')
assert.deepEqual(result.committed, [multi])
assert.equal(result.writes, 0)
assert.equal(result.movements.length, 0)
}
}
})
test('empty borrowed-child snapshot detects new child; boolean or stale token cannot bypass confirmation', async () => {
for (const handler of [returnEquipment, scanReturn]) {
const first = await run(handler, [root, child], null, { return_condition: '正常', confirm_children_diff: true })
assert.equal(first.state.body.data.need_confirm, true)
assert.equal(first.state.body.data.children_diff.added[0].id, child.id)
assert.equal(first.writes, 0)
assert.equal(first.movements.length, 0)
const token = first.state.body.data.confirmation_token
const changed = await run(handler, [root, { ...child, updated_at: new Date(now.getTime() + 1000) }], null, { confirmation_token: token })
assert.equal(changed.state.body.data.need_confirm, true)
assert.equal(changed.writes, 0)
assert.notEqual(changed.state.body.data.confirmation_token, token)
const accepted = await run(handler, [root, child], null, { confirmation_token: token, return_condition: '损坏' })
assert.equal(accepted.borrowed.status, '已归还')
assert.deepEqual(accepted.committed.map((item) => item.status), ['已损坏', '已损坏'])
assert.ok(accepted.committed.every((item) => item.available_value === 0))
}
})
test('batch paths reject added child without writes and direct operator to single return', async () => {
for (const handler of [scanReturnBatch, batchReturn]) {
const result = await run(handler, [root, child], null, { return_condition: '正常' })
assert.equal(result.state.body.data.failed_count, 1)
assert.match(result.state.body.data.results[0].message, /单个.*归还确认/)
assert.equal(result.writes, 0)
assert.equal(result.movements.length, 0)
assert.equal(result.borrowed.status, '已借出')
}
})

View File

@ -59,6 +59,35 @@ const createResponse = (role = 'it') => {
return { res, state }
}
test('已丢失主资产或配件不能申请报废,写入前拒绝且不改待报废状态', async () => {
const originalTransaction = prisma.$transaction
try {
for (const rootIsLost of [true, false]) {
const root = rootIsLost ? { ...rootEquipment, status: '已丢失', remaining_quantity: 0 } : rootEquipment
const child = rootIsLost ? childEquipment : { ...childEquipment, status: '已丢失', remaining_quantity: 0 }
let writes = 0
const children = (ids: number[]) => ids.includes(root.id) ? [child] : []
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => query.sql.includes('WHERE equipment.id = ?') ? [root]
: query.sql.includes('WHERE equipment.parent_id IN') ? children(query.values) : [],
equipment: {
findUnique: async () => root,
findMany: async (args: any) => children(args.where.parent_id.in),
updateMany: async () => { writes++; return { count: 1 } }
},
disposalRecord: { create: async () => { writes++; return { id: 1 } } }
})
const { res, state } = createResponse()
await createDisposal({ body: { equipment_id: root.id, item_ids: [root.id, child.id], disposal_reason: '丢失后报废', disposal_method: '报废' } } as any, res as any)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /已丢失.*不能申请报废/)
assert.equal(writes, 0)
}
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('报废流程钉钉正文不重复项目标题、时间优先且字段间保留空行', () => {
const cases = [
{
@ -430,7 +459,7 @@ test('行政已处理列表包含全部已审批、已驳回和已完成记录
)
assert.equal(state.statusCode, 200)
assert.deepEqual(capturedWhere.AND[0], {
assert.deepEqual(capturedWhere.AND[1], {
status: { in: ['approved', 'rejected', 'completed'] }
})
assert.doesNotMatch(JSON.stringify(capturedWhere), /approved_by|completed_by/)
@ -464,7 +493,7 @@ test('行政可以查询自己发起的报废流程', async () => {
)
assert.equal(state.statusCode, 200)
assert.deepEqual(capturedWhere.AND[0], { applied_by: 6 })
assert.ok(capturedWhere.AND.some((condition: any) => condition.applied_by === 6))
} finally {
;(prisma.disposalRecord.findMany as any) = originalFindMany
;(prisma.disposalRecord.count as any) = originalCount
@ -493,7 +522,7 @@ test('IT即使请求all范围也只能查询本人发起的报废记录', async
)
assert.equal(state.statusCode, 200)
assert.deepEqual(capturedWhere.AND[0], { applied_by: 6 })
assert.ok(capturedWhere.AND.some((condition: any) => condition.applied_by === 6))
} finally {
;(prisma.disposalRecord.findMany as any) = originalFindMany
;(prisma.disposalRecord.count as any) = originalCount

View File

@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { getDisposalDetail, getDisposalList, getEquipmentDisposalHistory } from '../disposalController'
const response = (role: string) => {
const state = { code: 200, body: null as any }
const res = {
locals: { user: { id: 6, role } },
status(code: number) { state.code = code; return this },
json(body: any) { state.body = body; return this }
}
return { state, res }
}
test('disposal history limits IT to applicant across root and child equipment records', async () => {
const original = prisma.disposalRecord.findMany
const records = [{ id: 1, applied_by: 6 }, { id: 2, applied_by: 99 }]
const queries: any[] = []
;(prisma.disposalRecord.findMany as any) = async (args: any) => {
queries.push(args.where)
const userId = args.where.AND[0].applied_by
return records.filter((item) => !userId || item.applied_by === userId)
}
try {
for (const role of ['it', 'administrative', 'admin']) {
const { state, res } = response(role)
await getEquipmentDisposalHistory({ params: { equipment_id: '10' } } as any, res as any)
assert.equal(state.code, 200)
assert.deepEqual(state.body.data.map((item: any) => item.id), role === 'it' ? [1] : [1, 2])
}
assert.deepEqual(queries[0].AND, [
{ applied_by: 6 },
{ OR: [{ equipment_id: 10 }, { items: { some: { equipment_id: 10 } } }] }
])
} finally {
;(prisma.disposalRecord.findMany as any) = original
}
})
test('disposal detail permits IT own record and rejects another applicant', async () => {
const original = prisma.disposalRecord.findUnique
;(prisma.disposalRecord.findUnique as any) = async (args: any) => ({
id: args.where.id, applied_by: args.where.id === 1 ? 6 : 99, status: 'pending'
})
try {
for (const [role, id, code] of [['it', '1', 200], ['it', '2', 403], ['admin', '2', 200], ['administrative', '2', 200]] as const) {
const { res, state } = response(role)
await getDisposalDetail({ params: { id } } as any, res as any)
assert.equal(state.code, code)
if (code === 403) assert.equal(state.body.data, null)
}
} finally {
;(prisma.disposalRecord.findUnique as any) = original
}
})
test('disposal read endpoints reject unsupported roles and malformed IDs before DB access', async () => {
for (const handler of [getDisposalList, getDisposalDetail, getEquipmentDisposalHistory]) {
const { res, state } = response('user')
await handler({ query: {}, params: { id: '1', equipment_id: '1' } } as any, res as any)
assert.equal(state.code, 403)
}
for (const handler of [getDisposalDetail, getEquipmentDisposalHistory]) {
for (const value of ['0', '-1', 'NaN', '9007199254740992', ['1', '2']]) {
const { res, state } = response('it')
await handler({ params: { id: value, equipment_id: value } } as any, res as any)
assert.equal(state.code, 422)
}
}
const { res, state } = response('administrative')
await getDisposalList({ query: { scope: 'all' } } as any, res as any)
assert.equal(state.code, 403)
})

View File

@ -49,6 +49,39 @@ const currentEquipment = {
updated_at: new Date('2026-07-14T01:00:00.000Z')
}
test('已丢失资产基础编辑保留状态与零库存,禁止增总数量生成虚假库存', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalTransaction = prisma.$transaction
const lostEquipment = { ...currentEquipment, status: '已丢失', remaining_quantity: 0, unit_price: 100, total_value: 100 }
let updateData: any
let writes = 0
;(prisma.equipment.findUnique as any) = async () => lostEquipment
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRawUnsafe: async () => [],
operationLog: { create: async () => ({ id: 1 }) },
equipment: {
updateMany: async (args: any) => { writes++; updateData = args.data; return { count: 1 } },
count: async () => 0
}
})
try {
const metadata = createMockResponse()
await updateEquipment({ params: { id: '903' }, body: { equipment_name: '丢失主机', total_quantity: 1, unit_price: 120 } } as any, metadata.res as any)
assert.equal(metadata.responseBody.statusCode, 200)
assert.equal(updateData.status, '已丢失')
assert.equal(updateData.remaining_quantity, 0)
assert.equal(updateData.available_value, 0)
assert.equal(updateData.borrowed_value, 0)
const quantity = createMockResponse()
await updateEquipment({ params: { id: '903' }, body: { total_quantity: 2 } } as any, quantity.res as any)
assert.equal(quantity.responseBody.statusCode, 409)
assert.equal(writes, 1)
} finally {
;(prisma.equipment.findUnique as any) = originalFindUnique
;(prisma.$transaction as any) = originalTransaction
}
})
test('普通编辑资产时不应把待报废资产冲回可用', async () => {
const originalFindUnique = prisma.equipment.findUnique
const originalTransaction = prisma.$transaction

View File

@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { createHealthController } from '../healthController'
const response = () => {
const state = { code: 0, body: null as any }
const res = {
status(code: number) { state.code = code; return this },
json(body: any) { state.body = body; return this }
}
return { state, res }
}
test('health verifies business tables and maintenance migration even with empty tables', async () => {
const original = prisma.$queryRaw
let queryText = ''
;(prisma.$queryRaw as any) = async (parts: any) => {
queryText = parts.join('')
return []
}
try {
const { res, state } = response()
await createHealthController()({} as any, res as any)
assert.equal(state.code, 200)
assert.equal(state.body.status, 'ok')
for (const table of ['equipment', 'maintenance_record', 'web_session', 'original_tree_snapshot',
'claim_token', 'lease_expires_at', 'result_confirmed_by', 'result_confirmed_at', 'protocol_version']) {
assert.ok(queryText.includes(table))
}
assert.match(queryText, /WHERE 1 = 0/)
} finally {
;(prisma.$queryRaw as any) = original
}
})
test('health database failure returns sanitized 503', async () => {
const { res, state } = response()
await createHealthController(async () => { throw new Error('mysql://secret-password@host/db') })({} as any, res as any)
assert.equal(state.code, 503)
assert.equal(state.body.status, 'error')
assert.doesNotMatch(JSON.stringify(state.body), /secret|mysql|host/)
})
test('timed out health probe is shared until DB query finishes, then retries', async () => {
let calls = 0
let finish!: () => void
const pending = new Promise<void>((resolve) => { finish = resolve })
const handler = createHealthController(async () => { calls += 1; if (calls === 1) await pending }, 10)
const responses = [response(), response(), response()]
await Promise.all(responses.map(({ res }) => handler({} as any, res as any)))
assert.equal(calls, 1)
assert.ok(responses.every(({ state }) => state.code === 503))
const subsequent = response()
await handler({} as any, subsequent.res as any)
assert.equal(calls, 1)
assert.equal(subsequent.state.code, 503)
finish()
await new Promise((resolve) => setImmediate(resolve))
const recovered = response()
await handler({} as any, recovered.res as any)
assert.equal(calls, 2)
assert.equal(recovered.state.code, 200)
})

View File

@ -31,7 +31,9 @@ const equipment = {
status: '可用',
total_quantity: 1,
remaining_quantity: 1,
unit_price: 1000
unit_price: 1000,
mount_status: 'none',
updated_at: new Date('2026-10-07T00:00:00Z')
}
test('并发送修未抢到资产状态时不创建第二条维修单', async () => {
@ -40,6 +42,7 @@ test('并发送修未抢到资产状态时不创建第二条维修单', async ()
let acquireWhere: any = null
const tx = {
$queryRaw: async (query: any) => query.sql.includes('WHERE equipment.id = ?') ? [equipment] : [],
equipment: {
findUnique: async () => equipment,
findMany: async () => [],
@ -80,7 +83,9 @@ test('并发送修未抢到资产状态时不创建第二条维修单', async ()
status: '可用',
parent_id: null,
remaining_quantity: 1,
department_id: 3
department_id: 3,
mount_status: 'none',
updated_at: equipment.updated_at
})
} finally {
;(prisma.$transaction as any) = originalTransaction
@ -92,6 +97,7 @@ test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', asyn
let maintenanceWhere: any = null
const tx = {
$queryRaw: async (query: any) => query.sql.includes('WHERE equipment.id = ?') ? [{ ...equipment, status: '维护中' }] : [],
maintenanceRecord: {
findUnique: async () => ({
id: 20,
@ -107,6 +113,7 @@ test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', asyn
},
equipment: {
findUnique: async () => ({ ...equipment, status: '维护中' }),
findMany: async () => [],
updateMany: async () => {
equipmentUpdated = true
return { count: 1 }
@ -123,7 +130,7 @@ test('重复完成维修时用 in_progress 条件阻止再次覆盖资产', asyn
)
assert.equal(responseBody.statusCode, 409)
assert.deepEqual(maintenanceWhere, { id: 20, status: 'in_progress' })
assert.deepEqual(maintenanceWhere, { id: 20, status: 'in_progress', updated_at: undefined })
assert.equal(equipmentUpdated, false)
} finally {
;(prisma.$transaction as any) = originalTransaction

View File

@ -0,0 +1,187 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { cancelMaintenance, completeMaintenance, createMaintenance } from '../maintenanceController'
import { buildMaintenanceSnapshot } from '../../utils/maintenanceSnapshot'
const root = {
id: 10, equipment_code: 'ROOT', equipment_name: '主机', equipment_model: null,
equipment_type: '电脑', parent_id: null, department_id: 1, storage_location_id: null,
status: '已损坏', mount_status: 'none', total_quantity: 2, remaining_quantity: 1,
unit_price: 100, updated_at: new Date('2026-10-07T00:00:00Z')
}
const child = { ...root, id: 11, equipment_code: 'CHILD', status: '可用', parent_id: 10, mount_status: 'mounted' }
const grandchild = { ...child, id: 12, equipment_code: 'GRANDCHILD', parent_id: 11, status: '已损坏' }
const originalTree = [root, child, grandchild]
const snapshot = buildMaintenanceSnapshot(originalTree as any)
const response = () => {
const state = { code: 200, body: null as any }
const res = {
locals: { user: { id: 6, username: 'test' } },
status(code: number) { state.code = code; return this },
json(body: any) { state.body = body; return this }
}
return { state, res }
}
const harness = async (
tree: any[],
record: any,
operation: 'create' | 'cancel' | 'complete',
failedEquipmentId?: number
) => {
const originalTransaction = prisma.$transaction
const originalLog = prisma.operationLog.create
let committedTree = tree.map((item) => ({ ...item }))
let committedRecord = { ...record }
let options: any
const writes: any[] = []
const locks: string[] = []
let created: any
;(prisma.$transaction as any) = async (callback: any, txOptions: any) => {
options = txOptions
const working = tree.map((item) => ({ ...item }))
let workingRecord = { ...record }
const tx = {
$queryRaw: async (query: any) => {
locks.push(query.sql)
if (query.sql.includes('WHERE equipment.id = ?')) return working.filter((item) => item.id === query.values[0])
if (query.sql.includes('WHERE equipment.parent_id IN')) return working.filter((item) => query.values.includes(item.parent_id))
return []
},
equipment: {
findUnique: async ({ where }: any) => working.find((item) => item.id === where.id),
findMany: async ({ where }: any) => working.filter((item) => where.parent_id.in.includes(item.parent_id)),
updateMany: async (args: any) => {
writes.push(args)
if (args.where.id === failedEquipmentId) return { count: 0 }
Object.assign(working.find((item) => item.id === args.where.id), args.data)
return { count: 1 }
}
},
borrowRecord: { count: async () => 0 },
disposalItem: { count: async () => 0 },
maintenanceRecord: {
count: async () => 0,
findUnique: async () => workingRecord,
create: async ({ data }: any) => { created = data; return { id: 20, ...data } },
updateMany: async ({ data }: any) => { workingRecord = { ...workingRecord, ...data }; return { count: 1 } }
}
}
const result = await callback(tx)
committedTree = working
committedRecord = workingRecord
return result
}
;(prisma.operationLog.create as any) = async () => ({})
try {
const { state, res } = response()
if (operation === 'create') {
await createMaintenance({ body: { equipment_id: 10, issue_description: '故障' } } as any, res as any)
} else if (operation === 'cancel') {
await cancelMaintenance({ params: { id: '20' }, body: { reason: '取消' } } as any, res as any)
} else {
await completeMaintenance({ params: { id: '20' }, body: { actual_cost: 0, repair_result: '已修复' } } as any, res as any)
}
return { state, committedTree, committedRecord, writes, options, created, locks }
} finally {
;(prisma.$transaction as any) = originalTransaction
;(prisma.operationLog.create as any) = originalLog
}
}
const record = { id: 20, equipment_id: 10, equipment_code: 'ROOT', status: 'in_progress', original_tree_snapshot: snapshot, updated_at: root.updated_at }
const maintenanceTree = originalTree.map((item) => ({ ...item, status: '维护中' }))
test('sending entire asset tree to maintenance preserves each original status and inventory', async () => {
const result = await harness(originalTree, {}, 'create')
assert.equal(result.state.code, 200)
assert.equal(result.options.isolationLevel, 'ReadCommitted')
assert.deepEqual(JSON.parse(result.created.original_tree_snapshot).assets, JSON.parse(snapshot).assets)
assert.ok(result.committedTree.every((item) => item.status === '维护中' && item.remaining_quantity === 1))
assert.equal(result.writes.length, 3)
})
test('cancel restores mixed damaged/available states for every descendant and recomputes values', async () => {
const result = await harness(maintenanceTree, record, 'cancel')
assert.equal(result.state.code, 200)
assert.equal(result.committedRecord.status, 'cancelled')
assert.equal(result.options.isolationLevel, 'ReadCommitted')
assert.deepEqual(result.committedTree.map((item) => item.status), ['已损坏', '可用', '已损坏'])
assert.match(result.locks[0], /FROM maintenance_record.*FOR UPDATE/)
assert.match(result.locks[1], /FROM equipment.*FOR UPDATE/)
assert.deepEqual(result.committedTree.map((item) => item.available_value), [0, 100, 0])
assert.deepEqual(result.committedTree.map((item) => item.borrowed_value), [0, 100, 0])
assert.deepEqual(result.writes.map((item) => item.where.parent_id), [null, 10, 11])
assert.ok(result.writes.every((item) => item.where.status === '维护中' && item.where.updated_at))
})
test('legacy or corrupt snapshots return explicit manual verification conflict without writes', async () => {
for (const value of [null, '', '{', '{}', JSON.stringify({ version: 1, assets: [JSON.parse(snapshot).assets[0], JSON.parse(snapshot).assets[0]] })]) {
const result = await harness(maintenanceTree, { ...record, original_tree_snapshot: value }, 'cancel')
assert.equal(result.state.code, 409)
assert.match(result.state.body.message, /人工核实/)
assert.equal(result.writes.length, 0)
assert.equal(result.committedRecord.status, 'in_progress')
}
})
test('changed asset tree, inventory or maintenance status prevents cancellation', async () => {
const cases = [
[...maintenanceTree, { ...child, id: 13, status: '维护中' }],
maintenanceTree.slice(0, 2),
maintenanceTree.map((item) => item.id === 12 ? { ...item, parent_id: 10 } : item),
maintenanceTree.map((item) => item.id === 11 ? { ...item, status: '可用' } : item),
maintenanceTree.map((item) => item.id === 11 ? { ...item, remaining_quantity: 2 } : item)
]
for (const tree of cases) {
const result = await harness(tree, record, 'cancel')
assert.equal(result.state.code, 409)
assert.equal(result.writes.length, 0)
}
const completed = await harness(maintenanceTree, { ...record, status: 'completed' }, 'cancel')
assert.equal(completed.state.code, 409)
assert.equal(completed.writes.length, 0)
})
test('conditional descendant write conflict rolls back both asset tree and maintenance record', async () => {
const result = await harness(maintenanceTree, record, 'cancel', 11)
assert.equal(result.state.code, 409)
assert.deepEqual(result.committedTree, maintenanceTree)
assert.equal(result.committedRecord.status, 'in_progress')
})
test('completion locks whole asset tree after maintenance row and conditionally repairs all descendants', async () => {
const result = await harness(maintenanceTree, record, 'complete')
assert.equal(result.state.code, 200)
assert.equal(result.options.isolationLevel, 'ReadCommitted')
assert.match(result.locks[0], /FROM maintenance_record.*FOR UPDATE/)
assert.match(result.locks[1], /FROM equipment.*FOR UPDATE/)
assert.deepEqual(result.committedTree.map((item) => item.status), ['可用', '可用', '可用'])
assert.equal(result.committedRecord.status, 'completed')
assert.equal(result.writes.length, 3)
assert.deepEqual(result.writes.map((item) => item.where.parent_id), [null, 10, 11])
assert.ok(result.writes.every((item) => item.where.status === '维护中' && item.where.updated_at && item.where.unit_price))
})
test('completion rejects removed, reparented, independently borrowed or changed-stock descendants without overwrite', async () => {
for (const tree of [
maintenanceTree.slice(0, 2),
maintenanceTree.map((item) => item.id === 12 ? { ...item, parent_id: 10 } : item),
maintenanceTree.map((item) => item.id === 11 ? { ...item, status: '已借出' } : item),
maintenanceTree.map((item) => item.id === 11 ? { ...item, remaining_quantity: 0 } : item)
]) {
const result = await harness(tree, record, 'complete')
assert.equal(result.state.code, 409)
assert.equal(result.writes.length, 0)
assert.equal(result.committedRecord.status, 'in_progress')
assert.deepEqual(result.committedTree, tree)
}
})
test('completion descendant CAS failure rolls back earlier repaired rows and completed record', async () => {
const result = await harness(maintenanceTree, record, 'complete', 11)
assert.equal(result.state.code, 409)
assert.deepEqual(result.committedTree, maintenanceTree)
assert.equal(result.committedRecord.status, 'in_progress')
})

View File

@ -288,3 +288,30 @@ test('卸载接口拒绝直接把配件设为维修中或待报废', async () =>
;(prisma.$transaction as any) = originalTransaction
}
})
test('maintenance parent rejects mounting and unmounting before any child or history writes', async () => {
const originalTransaction = prisma.$transaction
let writes = 0
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async () => [],
equipment: {
findUnique: async ({ where }: any) => where.id === parent.id
? { ...parent, status: '维护中' }
: { ...child, parent_id: parent.id, status: '维护中' },
updateMany: async () => { writes++; return { count: 1 } },
update: async () => { writes++; return {} }
},
mountHistory: { create: async () => { writes++; return {} } }
})
try {
for (const handler of [mountChild, unmountChild]) {
const { res, state } = createResponse()
await handler({ params: { parentId: String(parent.id) }, body: { child_id: child.id } } as any, res as any)
assert.equal(state.statusCode, 409)
assert.match(state.body.message, /先完成或取消维修/)
}
assert.equal(writes, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})

View File

@ -20,13 +20,15 @@ test('首页统计应排除已报废库存、完整覆盖在册状态并使用
{ status: '可用', _sum: { total_quantity: 1122, remaining_quantity: 1122, total_value: 424805, borrowed_value: 0, available_value: 424805 } },
{ status: '已借出', _sum: { total_quantity: 4507, remaining_quantity: 0, total_value: 2848258.44, borrowed_value: 2848258.44, available_value: 0 } },
{ status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1, total_value: 0, borrowed_value: 0, available_value: 0 } },
{ status: '已丢失', _sum: { total_quantity: 3, remaining_quantity: 0, total_value: 300, borrowed_value: 0, available_value: 0 } },
{ status: '已报废', _sum: { total_quantity: 127, remaining_quantity: 127, total_value: 83257, borrowed_value: 0, available_value: 0 } }
]
}
if (args.by.includes('status')) {
return [
{ equipment_type: '显示器', status: '可用', _sum: { total_quantity: 10, remaining_quantity: 10 } },
{ equipment_type: '显示器', status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1 } }
{ equipment_type: '显示器', status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1 } },
{ equipment_type: '仅丢失资产类型', status: '已丢失', _sum: { total_quantity: 3, remaining_quantity: 0 } }
]
}
return [{ equipment_type: '显示器', _sum: { remaining_quantity: 8 } }]
@ -47,13 +49,16 @@ test('首页统计应排除已报废库存、完整覆盖在册状态并使用
total_value_with_children: 0
}
]
;(prisma.equipment.aggregate as any) = async () => ({
_count: { id: 2057 },
_sum: { total_quantity: 2057 }
})
;(prisma.equipment.aggregate as any) = async (args: any) => {
assert.deepEqual(args.where.status.notIn, ['已报废', '已丢失'])
return { _count: { id: 2057 }, _sum: { total_quantity: 2057 } }
}
;(prisma.equipment.count as any) = async () => 3
;(prisma.borrowRecord.findMany as any) = async (args: any) => {
borrowRanges.push(args.where.borrow_time || args.where.return_time)
if (args.where.return_time) {
assert.deepEqual(args.where.return_condition, { not: '丢失' })
}
return args.where.borrow_time
? [{ children_snapshot: '[{"unit_price":20}]', equipment: { unit_price: 100 } }]
: []
@ -82,13 +87,20 @@ test('首页统计应排除已报废库存、完整覆盖在册状态并使用
assert.equal(state.body.data.borrowed_equipment, 4507)
assert.equal(state.body.data.pending_disposal_equipment, 1)
assert.equal(state.body.data.disposed_equipment, 127)
assert.equal(state.body.data.lost_equipment, 3)
assert.equal(state.body.data.other_equipment, 0)
const lostOnlyType = state.body.data.inventory_by_type.find((row: any) => row.equipment_type === '仅丢失资产类型')
assert.equal(lostOnlyType.total_quantity, 0)
assert.equal(lostOnlyType.lost_quantity, 3)
assert.equal(lostOnlyType.other_quantity, 0)
assert.equal(state.body.data.borrowed_without_active_record, 3)
assert.equal(state.body.data.today_borrow, 1)
assert.equal(state.body.data.today_return, 0)
assert.equal(state.body.data.today_borrow_value, 120)
assert.equal(state.body.data.business_timezone, 'Asia/Shanghai')
assert.equal(state.body.data.inventory_scope, 'current_excluding_disposed')
assert.equal(state.body.data.total_value, 3273063.44)
assert.equal(state.body.data.inventory_scope, 'current_excluding_disposed_and_lost')
assert.equal(state.body.data.value_scope, 'book_value_excluding_disposed_including_lost')
assert.equal(state.body.data.total_value, 3273363.44)
assert.equal(state.body.data.borrowed_value, 2848258.44)
assert.equal(
state.body.data.available_equipment +

View File

@ -1,6 +1,8 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { parseBusinessDate } from '../utils/businessDate'
import { getReturnChildrenDiff } from '../utils/returnSnapshot'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment'
@ -8,7 +10,9 @@ import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import {
borrowAssetTree,
finalizeNormalReturnState,
recordReturnToStorage,
loadAndLockAssetTree,
createAssetMovementRecord,
assertReturnConditionSupported,
transferAssetTree
} from '../services/assetMovementService'
@ -24,7 +28,7 @@ const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => {
const batchBorrowTransaction = async (
equipmentId: number,
person: any,
expectedReturnTime?: string,
expectedReturnTime?: Date | null,
isLongTerm?: boolean,
notes?: string
) => {
@ -35,7 +39,7 @@ const batchBorrowTransaction = async (
person,
expectedReturnTime: isLongTerm
? null
: (expectedReturnTime ? new Date(expectedReturnTime) : null),
: (expectedReturnTime || null),
isLongTerm,
notes: finalNotes
}),
@ -51,10 +55,17 @@ const batchReturnTransaction = async (
operatedBy: number
) => {
return prisma.$transaction(async (tx) => {
const { items } = await recordReturnToStorage(tx, {
rootEquipmentId: equipment.id,
const items = await loadAndLockAssetTree(tx, equipment.id)
assertReturnConditionSupported(items, returnCondition)
if (getReturnChildrenDiff(items, borrowRecord)) {
throw Object.assign(new Error('配件与借出时不一致,请使用单个归还确认'), { status: 409 })
}
await createAssetMovementRecord(tx, {
items,
actionType: returnCondition === '丢失' ? 'loss' : 'return',
toDepartmentId: null,
operatedBy,
reason: notes || '批量归还入仓'
reason: returnCondition === '丢失' ? `丢失登记(未入仓)${notes ? `;${notes}` : ''}` : notes || '批量归还入仓'
})
const currentRoot = items[0]
const updatedRecord = await tx.borrowRecord.updateMany({
@ -75,8 +86,8 @@ const batchReturnTransaction = async (
throw new Error('该借用记录已归还,请刷新后重试')
}
await finalizeNormalReturnState(tx, currentRoot)
})
await finalizeNormalReturnState(tx, currentRoot, returnCondition, items)
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
// 批量借出
@ -88,6 +99,7 @@ export const batchBorrow = async (req: Request, res: Response) => {
return errorResponse(res, '请选择要借出的资产')
}
const expectedReturn = is_long_term ? null : parseBusinessDate(expected_return_time, '预计归还时间')
const person = await prisma.person.findUnique({ where: { id: Number(person_id) } })
if (!person || person.status !== 'active') {
return errorResponse(res, '使用者不存在或已禁用')
@ -127,7 +139,7 @@ export const batchBorrow = async (req: Request, res: Response) => {
await batchBorrowTransaction(
equipment.id,
person,
expected_return_time,
expectedReturn,
is_long_term,
notes
)
@ -158,7 +170,7 @@ export const batchBorrow = async (req: Request, res: Response) => {
successResponse(res, { success_count: successCount, failed_count: failedCount, results }, '批量借出完成')
} catch (error: any) {
errorResponse(res, error.message || '批量借出失败')
errorResponse(res, error.message || '批量借出失败', error.status || 400)
}
}
@ -213,7 +225,7 @@ export const batchReturn = async (req: Request, res: Response) => {
await batchReturnTransaction(equipment, borrowRecord, return_condition, notes, operatedBy)
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '归还成功' })
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: return_condition === '丢失' ? '丢失已登记(未入仓)' : '归还成功' })
successCount++
} catch (error: any) {
results.push({ equipment_id: equipmentId, status: 'failed', message: error.message })

View File

@ -6,6 +6,7 @@ import { calculateEquipmentValues } from '../utils/assetValue'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
interface PersonImportData {
source_row?: number
nickname: string
real_name: string
job_number?: string
@ -17,12 +18,13 @@ interface PersonImportData {
}
interface EquipmentImportData {
source_row?: number
equipment_name: string
equipment_type: string
equipment_model?: string
department?: string
storage_location?: string
unit_price?: number
unit_price?: number | string
total_quantity: number
supplier?: string
purchase_date?: string
@ -35,6 +37,9 @@ const normalizeOptionalText = (value: unknown) => {
return text || null
}
const getSourceRow = (value: unknown, index: number) =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 2 ? value : index + 2
// 批量导入使用者
export const batchImportPersons = async (req: Request, res: Response) => {
try {
@ -56,7 +61,7 @@ export const batchImportPersons = async (req: Request, res: Response) => {
for (let i = 0; i < persons.length; i++) {
const person = persons[i]
const rowNum = i + 2 // Excel行号从2开始(第1行是表头)
const rowNum = getSourceRow(person?.source_row, i)
try {
// 验证必填字段
@ -173,11 +178,11 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
for (let i = 0; i < equipments.length; i++) {
const eq = equipments[i]
const rowNum = i + 2
const rowNum = getSourceRow(eq?.source_row, i)
try {
// 验证必填字段
if (!eq.equipment_name || !eq.equipment_type || !eq.total_quantity) {
if (!eq || !normalizeOptionalText(eq.equipment_name) || !normalizeOptionalText(eq.equipment_type)) {
results.errors.push({ row: rowNum, message: '资产名称、资产类型、数量为必填项' })
results.failed++
continue
@ -190,8 +195,22 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
results.failed++
continue
}
const quantityInput = eq.total_quantity
const totalQuantity = typeof quantityInput === 'number' || (typeof quantityInput === 'string' && String(quantityInput).trim())
? Number(quantityInput) : NaN
if (!Number.isSafeInteger(totalQuantity) || totalQuantity <= 0) {
results.errors.push({ row: rowNum, message: '数量必须是大于0的整数' })
results.failed++
continue
}
const unitPrice = eq.unit_price === undefined || eq.unit_price === null ? 0 : Number(eq.unit_price)
if (!Number.isFinite(unitPrice) || unitPrice < 0) {
results.errors.push({ row: rowNum, message: '单价必须是大于或等于0的有效数字' })
results.failed++
continue
}
if (assetType.can_be_child && Number(eq.total_quantity) !== 1) {
if (assetType.can_be_child && totalQuantity !== 1) {
results.errors.push({ row: rowNum, message: `子资产类型 "${eq.equipment_type}" 导入时数量必须为 1` })
results.failed++
continue
@ -228,8 +247,6 @@ export const batchImportEquipments = async (req: Request, res: Response) => {
const equipmentCode = await generateEquipmentCode(eq.equipment_type, prisma, locationId)
// 计算价值
const unitPrice = Number(eq.unit_price) || 0
const totalQuantity = Number(eq.total_quantity) || 1
const values = calculateEquipmentValues({
unitPrice,
totalQuantity,

View File

@ -1,7 +1,8 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import { createHash } from 'crypto'
import prisma from '../utils/prisma'
import { parseBusinessDate } from '../utils/businessDate'
import { getReturnChildrenDiff, buildReturnConfirmationToken } from '../utils/returnSnapshot'
import { successResponse, errorResponse } from '../utils/response'
import {
getDisplayEquipmentValue
@ -14,7 +15,7 @@ import {
} from '../utils/equipmentSearchPriority'
import { logOperation } from './operationLogController'
import {
buildRecursiveBorrowSnapshot,
assertReturnConditionSupported,
borrowAssetTree,
createAssetMovementRecord,
finalizeBorrowAnomalyReturnState,
@ -30,23 +31,8 @@ const parseNumber = (value: any): number | undefined => {
return Number.isNaN(num) ? undefined : num
}
const parseDate = (value?: any): Date | null => {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
const parseBorrowAnomalyBorrowTime = (value?: any): Date | null => {
if (!value || typeof value !== 'string') return null
const normalizedValue = value.trim()
if (!normalizedValue) return null
if (/^\d{4}-\d{2}-\d{2}$/.test(normalizedValue)) {
return parseDate(`${normalizedValue}T00:00:00`)
}
return parseDate(normalizedValue)
}
const parseDate = (value?: unknown) => parseBusinessDate(value, '预计归还时间')
const parseBorrowAnomalyBorrowTime = (value?: unknown) => parseBusinessDate(value, '借出日期')
const equipmentInclude = {
storageLocation: {
@ -271,27 +257,11 @@ const findActiveBorrowRecord = async (
}
const getChildrenDiff = async (equipmentId: number, borrowRecord: any) => {
if (!borrowRecord?.children_snapshot) {
return null
}
const borrowedChildren = JSON.parse(borrowRecord.children_snapshot) as any[]
const currentTree = await prisma.$transaction(
(tx) => loadAssetTree(tx, equipmentId),
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
)
const currentChildren = buildRecursiveBorrowSnapshot(currentTree)
const borrowedIds = new Set(borrowedChildren.map((child) => child.id))
const currentIds = new Set(currentChildren.map((child) => child.id))
const removed = borrowedChildren.filter((child) => !currentIds.has(child.id))
const added = currentChildren.filter((child) => !borrowedIds.has(child.id))
if (removed.length === 0 && added.length === 0) {
return null
}
return { removed, added }
return getReturnChildrenDiff(currentTree, borrowRecord)
}
const returnEquipmentInTransaction = async (
@ -299,16 +269,29 @@ const returnEquipmentInTransaction = async (
borrowRecord: any,
returnCondition: string,
notes?: string,
operatedBy?: number
operatedBy?: number,
confirmationToken?: string
) => {
return prisma.$transaction(async (tx) => {
if (!operatedBy) {
throw new Error('未获取到归还操作人')
}
const { items } = await recordReturnToStorage(tx, {
rootEquipmentId: equipment.id,
const items = await loadAndLockAssetTree(tx, equipment.id)
assertReturnConditionSupported(items, returnCondition)
const childrenDiff = getReturnChildrenDiff(items, borrowRecord)
const currentToken = buildReturnConfirmationToken(items, borrowRecord)
if (childrenDiff && confirmationToken !== currentToken) {
return { need_confirm: true as const, confirmation_token: currentToken, children_diff: childrenDiff }
}
const finalNotes = childrenDiff
? [notes, `配件变更:移除${childrenDiff.removed.length}个,新增${childrenDiff.added.length}个`].filter(Boolean).join(';')
: notes
await createAssetMovementRecord(tx, {
items,
actionType: returnCondition === '丢失' ? 'loss' : 'return',
toDepartmentId: null,
operatedBy,
reason: notes || '正常归还入仓'
reason: returnCondition === '丢失' ? `丢失登记(未入仓)${finalNotes ? `;${finalNotes}` : ''}` : finalNotes || '正常归还入仓'
})
const currentRoot = items[0]
const returnTime = new Date()
@ -322,7 +305,7 @@ const returnEquipmentInTransaction = async (
return_time: returnTime,
status: '已归还',
return_condition: returnCondition,
notes
notes: finalNotes
}
})
@ -330,15 +313,17 @@ const returnEquipmentInTransaction = async (
throw new Error('该借用记录已归还,请刷新后重试')
}
await finalizeNormalReturnState(tx, currentRoot)
await finalizeNormalReturnState(tx, currentRoot, returnCondition, items)
return {
need_confirm: false as const,
children_diff: childrenDiff,
id: borrowRecord.id,
person_id: borrowRecord.person_id,
return_time: returnTime,
status: '已归还'
}
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
type DisposalReturnBorrowRecord = {
@ -349,71 +334,8 @@ type DisposalReturnBorrowRecord = {
updated_at: Date
}
const getLockedDisposalChildrenDiff = (
items: Awaited<ReturnType<typeof loadAndLockAssetTree>>,
borrowRecord: DisposalReturnBorrowRecord
) => {
const currentChildren = buildRecursiveBorrowSnapshot(items)
let borrowedChildren: Array<{
id: number
parent_id?: number | null
parent_equipment_id?: number | null
equipment_code?: string
equipment_name?: string
}> = []
if (borrowRecord.children_snapshot) {
try {
const parsed = JSON.parse(borrowRecord.children_snapshot)
if (!Array.isArray(parsed)) throw new Error('配件快照格式错误')
borrowedChildren = parsed
} catch {
throw new Error('借用记录的配件快照无效,无法安全快捷入库,请先使用正式归还流程')
}
}
const borrowedById = new Map(borrowedChildren.map((child) => [Number(child.id), child]))
const currentById = new Map(currentChildren.map((child) => [child.id, child]))
const getParentId = (child: { parent_id?: number | null; parent_equipment_id?: number | null }) =>
child.parent_id ?? child.parent_equipment_id ?? null
const samePlacement = (
left: { parent_id?: number | null; parent_equipment_id?: number | null },
right: { parent_id?: number | null; parent_equipment_id?: number | null }
) => getParentId(left) === getParentId(right)
const removed = borrowedChildren.filter((child) => {
const current = currentById.get(Number(child.id))
return !current || !samePlacement(child, current)
})
const added = currentChildren.filter((child) => {
const borrowed = borrowedById.get(child.id)
return !borrowed || !samePlacement(borrowed, child)
})
return removed.length === 0 && added.length === 0 ? null : { removed, added }
}
const buildDisposalReturnConfirmationToken = (
items: Awaited<ReturnType<typeof loadAndLockAssetTree>>,
borrowRecord: DisposalReturnBorrowRecord
) => createHash('sha256').update(JSON.stringify({
borrow_record: {
id: borrowRecord.id,
equipment_id: borrowRecord.equipment_id,
updated_at: borrowRecord.updated_at.getTime(),
children_snapshot: borrowRecord.children_snapshot
},
asset_tree: items.map((item) => ({
id: item.id,
parent_id: item.parent_id,
department_id: item.department_id,
status: item.status,
mount_status: item.mount_status,
total_quantity: item.total_quantity,
remaining_quantity: item.remaining_quantity,
unit_price: item.unit_price.toString(),
updated_at: item.updated_at.getTime()
}))
})).digest('hex')
const getLockedDisposalChildrenDiff = getReturnChildrenDiff
const buildDisposalReturnConfirmationToken = buildReturnConfirmationToken
const returnEquipmentBeforeDisposalInTransaction = async (input: {
equipmentId: number
@ -487,7 +409,7 @@ const returnEquipmentBeforeDisposalInTransaction = async (input: {
throw new Error('该借用记录已归还,请刷新后重试')
}
const returnState = await finalizeNormalReturnState(tx, root)
const returnState = await finalizeNormalReturnState(tx, root, input.returnCondition, items)
return {
need_confirm: false as const,
equipment_id: root.id,
@ -781,7 +703,7 @@ export const scanBorrowBatch = async (req: Request, res: Response) => {
body: req.body,
user: res.locals.user
})
errorResponse(res, error.message || '批量出库失败')
errorResponse(res, error.message || '批量出库失败', error.status || 400)
}
}
@ -795,7 +717,7 @@ export const returnEquipment = async (req: Request, res: Response) => {
person_id,
return_condition = '正常',
notes,
confirm_children_diff
confirmation_token
} = req.body as Record<string, any>
try {
@ -826,37 +748,27 @@ export const returnEquipment = async (req: Request, res: Response) => {
return errorResponse(res, '未找到对应的借用记录')
}
const childrenDiff = await getChildrenDiff(equipment.id, borrowRecord)
if (childrenDiff && !confirm_children_diff) {
return successResponse(res, {
need_confirm: true,
children_diff: childrenDiff,
message: '配件与借出时不一致,请确认后继续'
}, '配件差异提醒')
}
const finalNotes = childrenDiff
? [notes, `配件变更:移除${childrenDiff.removed.length}个,新增${childrenDiff.added.length}个`]
.filter(Boolean)
.join(';')
: notes
await returnEquipmentInTransaction(
const updatedRecord = await returnEquipmentInTransaction(
equipment,
borrowRecord,
return_condition,
finalNotes,
Number(res.locals.user?.id)
notes,
Number(res.locals.user?.id),
typeof confirmation_token === 'string' ? confirmation_token : undefined
)
if (updatedRecord.need_confirm) {
return successResponse(res, updatedRecord, '配件差异提醒')
}
successResponse(
res,
{
id: borrowRecord.id,
return_time: new Date(),
children_diff: childrenDiff
children_diff: updatedRecord.children_diff
},
'归还成功'
return_condition === '丢失' ? '丢失已登记(未入仓)' : '归还成功'
)
} catch (error: any) {
console.error('[归还资产失败]', {
@ -865,7 +777,7 @@ export const returnEquipment = async (req: Request, res: Response) => {
body: req.body,
user: res.locals.user
})
errorResponse(res, error.message || '归还资产失败')
errorResponse(res, error.message || '归还资产失败', error.status || 400)
}
}
@ -925,7 +837,7 @@ export const scanReturn = async (req: Request, res: Response) => {
notes,
person_id,
borrow_record_id,
confirm_children_diff
confirmation_token
} = req.body as Record<string, any>
try {
@ -955,29 +867,19 @@ export const scanReturn = async (req: Request, res: Response) => {
return errorResponse(res, '未找到对应的借用记录,请选择正确的使用者', 404)
}
const childrenDiff = await getChildrenDiff(equipment.id, borrowRecord)
if (childrenDiff && !confirm_children_diff) {
return successResponse(res, {
need_confirm: true,
children_diff: childrenDiff,
message: '配件与借出时不一致,请确认后继续'
}, '配件差异提醒')
}
const finalNotes = childrenDiff
? [notes, `配件变更:移除${childrenDiff.removed.length}个,新增${childrenDiff.added.length}个`]
.filter(Boolean)
.join(';')
: notes
const updatedRecord = await returnEquipmentInTransaction(
equipment,
borrowRecord,
return_condition,
finalNotes,
Number(res.locals.user?.id)
notes,
Number(res.locals.user?.id),
typeof confirmation_token === 'string' ? confirmation_token : undefined
)
if (updatedRecord.need_confirm) {
return successResponse(res, updatedRecord, '配件差异提醒')
}
successResponse(
res,
{
@ -988,7 +890,7 @@ export const scanReturn = async (req: Request, res: Response) => {
status: '已归还',
person_id: updatedRecord.person_id
},
'入库成功'
return_condition === '丢失' ? '丢失已登记(未入仓)' : '入库成功'
)
} catch (error: any) {
console.error('[扫码入库失败]', {
@ -997,7 +899,7 @@ export const scanReturn = async (req: Request, res: Response) => {
body: req.body,
user: res.locals.user
})
errorResponse(res, error.message || '入库失败')
errorResponse(res, error.message || '入库失败', error.status || 400)
}
}
@ -1063,7 +965,7 @@ export const scanReturnBatch = async (req: Request, res: Response) => {
continue
}
await returnEquipmentInTransaction(
const returned = await returnEquipmentInTransaction(
equipment,
borrowRecord,
return_condition,
@ -1071,7 +973,12 @@ export const scanReturnBatch = async (req: Request, res: Response) => {
Number(res.locals.user?.id)
)
results.push({ equipment_code: code, status: 'success', message: '入库成功' })
if (returned.need_confirm) {
results.push({ equipment_code: code, status: 'failed', message: '配件与借出时不一致,请使用单个扫码归还确认' })
failedCount++
continue
}
results.push({ equipment_code: code, status: 'success', message: return_condition === '丢失' ? '丢失已登记(未入仓)' : '入库成功' })
successCount++
} catch (error: any) {
results.push({

View File

@ -21,7 +21,7 @@ class DisposalConflictError extends Error {
status = 409
}
const blockedStatuses = new Set(['已借出', '维护中', '待报废', '已报废'])
const blockedStatuses = new Set(['已借出', '维护中', '待报废', '已报废', '已丢失'])
const disposalInclude = {
equipment: true,
@ -207,6 +207,16 @@ const getCurrentUser = (res: Response) => ({
role: String(res.locals.user?.role || '')
})
const getDisposalVisibility = (res: Response): Prisma.DisposalRecordWhereInput => {
const user = getCurrentUser(res)
if (!Number.isSafeInteger(user.id) || user.id <= 0) {
throw Object.assign(new Error('用户未登录'), { status: 401 })
}
if (user.role === 'admin' || user.role === 'administrative') return {}
if (user.role === 'it') return { applied_by: user.id }
throw Object.assign(new Error('无权查看报废流程'), { status: 403 })
}
const DISPOSAL_STATUSES = ['pending', 'approved', 'rejected', 'completed'] as const
const parseListPositiveInteger = (
@ -215,9 +225,15 @@ const parseListPositiveInteger = (
fallback: number,
max?: number
) => {
if (value === undefined || value === null || value === '') return fallback
if (value === undefined || value === null || value === '') {
if (fallback > 0) return fallback
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+$/.test(String(value))) {
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
if (!Number.isSafeInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
return parsed
@ -425,21 +441,11 @@ export const getDisposalList = async (req: Request, res: Response) => {
return errorResponse(res, '报废列表范围无效', 422)
}
const summaryConditions: Prisma.DisposalRecordWhereInput[] = []
if (currentUser.role === 'it') {
summaryConditions.push({ applied_by: currentUser.id })
} else if (currentUser.role === 'administrative') {
if (resolvedScope === 'mine') {
summaryConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'pending') {
summaryConditions.push({ status: 'pending' })
} else if (resolvedScope === 'processed') {
summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
} else {
const summaryConditions: Prisma.DisposalRecordWhereInput[] = [getDisposalVisibility(res)]
if (currentUser.role === 'administrative' && resolvedScope === 'all') {
return errorResponse(res, '行政仅可查看本人发起、全部待审批和全部已处理报废流程', 403)
}
} else if (currentUser.role === 'admin') {
if (currentUser.role !== 'it') {
if (resolvedScope === 'mine') {
summaryConditions.push({ applied_by: currentUser.id })
} else if (resolvedScope === 'pending') {
@ -447,8 +453,6 @@ export const getDisposalList = async (req: Request, res: Response) => {
} else if (resolvedScope === 'processed') {
summaryConditions.push({ status: { in: ['approved', 'rejected', 'completed'] } })
}
} else {
return errorResponse(res, '无权查看报废流程', 403)
}
const normalizedStatus = String(status || '').trim()
@ -530,18 +534,19 @@ export const getDisposalList = async (req: Request, res: Response) => {
export const getDisposalDetail = async (req: Request, res: Response) => {
try {
const visibility = getDisposalVisibility(res)
const disposalId = parseListPositiveInteger(req.params.id, '报废记录ID', 0)
const disposal = await prisma.disposalRecord.findUnique({
where: { id: Number(req.params.id) },
where: { id: disposalId },
include: disposalInclude
})
if (!disposal) return errorResponse(res, '报废记录不存在', 404)
const currentUser = getCurrentUser(res)
if (currentUser.role === 'it' && disposal.applied_by !== currentUser.id) {
if (visibility.applied_by && disposal.applied_by !== visibility.applied_by) {
return errorResponse(res, '无权查看该报废申请', 403)
}
successResponse(res, withHistoricalDisposalNotes(disposal), '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取报废记录详情失败')
errorResponse(res, error.message || '获取报废记录详情失败', error.status || 500)
}
}
@ -763,13 +768,14 @@ export const completeDisposal = async (req: Request, res: Response) => {
export const getEquipmentDisposalHistory = async (req: Request, res: Response) => {
try {
const equipmentId = Number(req.params.equipment_id)
const visibility = getDisposalVisibility(res)
const equipmentId = parseListPositiveInteger(req.params.equipment_id, '资产ID', 0)
const history = await prisma.disposalRecord.findMany({
where: {
OR: [
AND: [visibility, { OR: [
{ equipment_id: equipmentId },
{ items: { some: { equipment_id: equipmentId } } }
]
] }]
},
orderBy: { created_at: 'desc' },
include: {
@ -782,6 +788,6 @@ export const getEquipmentDisposalHistory = async (req: Request, res: Response) =
})
successResponse(res, history, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取报废历史失败')
errorResponse(res, error.message || '获取报废历史失败', error.status || 500)
}
}

View File

@ -376,7 +376,7 @@ const ensureEquipmentCodeHasNoBusinessHistory = async (equipmentId: number, clie
}
}
const EDIT_LOCKED_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏'])
const EDIT_LOCKED_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏', '已丢失'])
const resolveEditableEquipmentStatus = ({
currentStatus,
@ -1211,6 +1211,9 @@ export const updateEquipment = async (req: Request, res: Response) => {
) {
return errorResponse(res, '资产总数量必须是大于或等于 1 的整数', 422)
}
if (currentEquipment.status === '已丢失' && finalTotalQuantity !== currentEquipment.total_quantity) {
return errorResponse(res, '已丢失资产不能修改库存数量,请先人工核实台账', 409)
}
const finalUnitPrice = unit_price !== undefined ? Number(unit_price) : Number(currentEquipment.unit_price)
// 已借数量 = 原总数 - 原剩余数(保证不为负)
const borrowedQuantity = Math.max(currentEquipment.total_quantity - currentEquipment.remaining_quantity, 0)

View File

@ -0,0 +1,51 @@
import type { Request, Response } from 'express'
import prisma from '../utils/prisma'
const probeDatabase = async () => {
// Referencing the new column also detects a missing maintenance migration.
await prisma.$queryRaw`
SELECT
(SELECT COUNT(id) FROM equipment WHERE 1 = 0) AS equipment_ready,
(SELECT COUNT(original_tree_snapshot) FROM maintenance_record WHERE 1 = 0) AS maintenance_ready,
(SELECT COUNT(claim_token) + COUNT(lease_expires_at) + COUNT(result_confirmed_by) + COUNT(result_confirmed_at) FROM print_job WHERE 1 = 0) AS print_job_ready,
(SELECT COUNT(protocol_version) FROM print_terminal WHERE 1 = 0) AS print_terminal_ready,
(SELECT COUNT(sid) FROM web_session WHERE 1 = 0) AS session_ready
`
}
export const createHealthController = (
probe: () => Promise<unknown> = probeDatabase,
timeoutMs = 3000
) => {
let inFlight: Promise<boolean> | null = null
const check = () => {
if (inFlight) return inFlight
let timer: NodeJS.Timeout
const pending = Promise.resolve().then(probe)
const result = Promise.race([
pending.then(() => true, () => false),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs)
})
]).finally(() => clearTimeout(timer))
inFlight = result
// A timed-out query cannot be cancelled by Prisma. Retain its shared failed
// result until it settles so repeated health calls cannot fill the DB pool.
const release = () => {
if (inFlight === result) inFlight = null
}
pending.then(release, release)
return result
}
return async (_req: Request, res: Response) => {
const healthy = await check()
res.status(healthy ? 200 : 503).json({
status: healthy ? 'ok' : 'error',
message: healthy ? 'Server is ready' : 'Service is unavailable'
})
}
}
export const getHealth = createHealthController()

View File

@ -1,9 +1,11 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue'
import { loadAssetTree } from '../services/assetMovementService'
import { calculateEquipmentValues } from '../utils/assetValue'
import { loadAndLockAssetTree } from '../services/assetMovementService'
import { buildMaintenanceSnapshot, validateMaintenanceSnapshot } from '../utils/maintenanceSnapshot'
class MaintenanceConflictError extends Error {
status = 409
@ -18,9 +20,15 @@ const parseListPositiveInteger = (
fallback: number,
max?: number
) => {
if (value === undefined || value === null || value === '') return fallback
if (value === undefined || value === null || value === '') {
if (fallback > 0) return fallback
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+$/.test(String(value))) {
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
if (!Number.isSafeInteger(parsed) || parsed <= 0 || (max !== undefined && parsed > max)) {
throw Object.assign(new Error(`${fieldName}无效`), { status: 422 })
}
return parsed
@ -41,7 +49,7 @@ export const createMaintenance = async (req: Request, res: Response) => {
}
const result = await prisma.$transaction(async (tx) => {
const tree = await loadAssetTree(tx, equipmentId)
const tree = await loadAndLockAssetTree(tx, equipmentId)
const equipment = tree[0]
if (equipment.parent_id) {
throw Object.assign(new Error('该子资产当前已挂载,请先卸载后再送修'), { status: 422 })
@ -82,31 +90,33 @@ export const createMaintenance = async (req: Request, res: Response) => {
throw new MaintenanceConflictError('资产树已有进行中的维修记录,请刷新后查看')
}
if (tree.some((item) => !['可用', '已损坏'].includes(item.status))) {
throw new MaintenanceConflictError('资产原状态无效,请人工核实后送修')
}
// All rows are locked by loadAndLockAssetTree; conditional writes still reject stale state.
for (const item of tree) {
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: Number(equipment.remaining_quantity),
unitPrice: Number(item.unit_price),
totalQuantity: item.total_quantity,
remainingQuantity: item.remaining_quantity,
status: '维护中'
})
const acquired = await tx.equipment.updateMany({
where: {
id: equipment.id,
status: equipment.status,
parent_id: null,
remaining_quantity: equipment.remaining_quantity,
department_id: equipment.department_id
id: item.id,
status: item.status,
parent_id: item.parent_id,
remaining_quantity: item.remaining_quantity,
department_id: item.department_id,
mount_status: item.mount_status,
updated_at: item.updated_at
},
data: {
status: '维护中',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
data: { status: '维护中', ...values }
})
if (acquired.count !== 1) {
throw new MaintenanceConflictError('资产状态、库存或归属已变化,请刷新后重试')
}
}
const created = await tx.maintenanceRecord.create({
data: {
@ -116,14 +126,13 @@ export const createMaintenance = async (req: Request, res: Response) => {
repair_vendor,
estimated_cost: Number(estimated_cost) || 0,
expected_end_date: expected_end_date ? new Date(expected_end_date) : null,
created_by: userId
created_by: userId,
original_tree_snapshot: buildMaintenanceSnapshot(tree)
}
})
await syncMountedChildrenState(tx, equipment.id, '维护中')
return { maintenance: created, equipment }
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
// 记录操作日志
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
@ -286,35 +295,31 @@ export const completeMaintenance = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { actual_cost, repair_result } = req.body
const maintenanceId = Number(id)
const maintenanceId = parseListPositiveInteger(id, '维修记录ID', 0)
const maintenance = await prisma.$transaction(async (tx) => {
const currentMaintenance = await tx.maintenanceRecord.findUnique({
where: { id: maintenanceId }
})
await tx.$queryRaw(Prisma.sql`SELECT id FROM maintenance_record WHERE id = ${maintenanceId} FOR UPDATE`)
const currentMaintenance = await tx.maintenanceRecord.findUnique({ where: { id: maintenanceId } })
if (!currentMaintenance) {
throw Object.assign(new Error('维修记录不存在'), { status: 404 })
}
const equipment = await tx.equipment.findUnique({
where: { id: currentMaintenance.equipment_id }
})
if (!equipment) {
throw Object.assign(new Error('资产不存在'), { status: 404 })
if (currentMaintenance.status !== 'in_progress') {
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
const tree = await loadAndLockAssetTree(tx, currentMaintenance.equipment_id)
if (tree[0].parent_id !== null || tree.some((item) => item.status !== '维护中')) {
throw new MaintenanceConflictError('资产树已不处于维修状态,不能覆盖当前状态')
}
// Legacy records can be completed after whole-tree checks, but cannot be
// cancelled without original statuses. New records must match their tree.
if (currentMaintenance.original_tree_snapshot) {
validateMaintenanceSnapshot(currentMaintenance.original_tree_snapshot, tree, currentMaintenance.equipment_id)
}
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: Number(equipment.remaining_quantity),
status: '可用'
})
const updatedMaintenance = await tx.maintenanceRecord.updateMany({
where: { id: maintenanceId, status: 'in_progress' },
where: { id: maintenanceId, status: 'in_progress', updated_at: currentMaintenance.updated_at },
data: {
status: 'completed',
actual_cost:
actual_cost !== undefined ? Number(actual_cost) : currentMaintenance.estimated_cost,
actual_cost: actual_cost !== undefined ? Number(actual_cost) : currentMaintenance.estimated_cost,
actual_end_date: new Date(),
repair_result
}
@ -322,27 +327,26 @@ export const completeMaintenance = async (req: Request, res: Response) => {
if (updatedMaintenance.count !== 1) {
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
const updatedEquipment = await tx.equipment.updateMany({
for (const item of tree) {
const values = calculateEquipmentValues({
unitPrice: Number(item.unit_price), totalQuantity: item.total_quantity,
remainingQuantity: item.remaining_quantity, status: '可用'
})
const updated = await tx.equipment.updateMany({
where: {
id: currentMaintenance.equipment_id,
status: '维护中',
parent_id: null
id: item.id, status: '维护中', parent_id: item.parent_id,
total_quantity: item.total_quantity, remaining_quantity: item.remaining_quantity,
department_id: item.department_id, mount_status: item.mount_status,
unit_price: item.unit_price, updated_at: item.updated_at
},
data: {
status: '可用',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
data: { status: '可用', ...values }
})
if (updatedEquipment.count !== 1) {
throw new MaintenanceConflictError('资产已不处于维修状态,不能覆盖当前状态')
if (updated.count !== 1) {
throw new MaintenanceConflictError('资产状态、库存或挂载关系已变化,不能覆盖当前状态')
}
}
await syncMountedChildrenState(tx, equipment.id, '可用')
return currentMaintenance
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
// 记录操作日志
const userId = res.locals.user?.id
@ -368,9 +372,12 @@ export const cancelMaintenance = async (req: Request, res: Response) => {
try {
const { id } = req.params
const { reason } = req.body
const maintenanceId = Number(id)
const maintenanceId = parseListPositiveInteger(id, '维修记录ID', 0)
const maintenance = await prisma.$transaction(async (tx) => {
// Completion acquires the maintenance row before equipment writes. Use
// the same order to prevent completion/cancellation from deadlocking.
await tx.$queryRaw(Prisma.sql`SELECT id FROM maintenance_record WHERE id = ${maintenanceId} FOR UPDATE`)
const currentMaintenance = await tx.maintenanceRecord.findUnique({
where: { id: maintenanceId }
})
@ -378,47 +385,50 @@ export const cancelMaintenance = async (req: Request, res: Response) => {
throw Object.assign(new Error('维修记录不存在'), { status: 404 })
}
const equipment = await tx.equipment.findUnique({
where: { id: currentMaintenance.equipment_id }
})
if (!equipment) {
throw Object.assign(new Error('资产不存在'), { status: 404 })
if (currentMaintenance.status !== 'in_progress') {
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: Number(equipment.remaining_quantity),
status: '可用'
})
const tree = await loadAndLockAssetTree(tx, currentMaintenance.equipment_id)
const snapshot = validateMaintenanceSnapshot(
currentMaintenance.original_tree_snapshot,
tree,
currentMaintenance.equipment_id
)
const updatedMaintenance = await tx.maintenanceRecord.updateMany({
where: { id: maintenanceId, status: 'in_progress' },
where: { id: maintenanceId, status: 'in_progress', updated_at: currentMaintenance.updated_at },
data: { status: 'cancelled', repair_result: reason || '已取消' }
})
if (updatedMaintenance.count !== 1) {
throw new MaintenanceConflictError('该维修记录已被处理,请刷新后查看')
}
const updatedEquipment = await tx.equipment.updateMany({
for (const item of tree) {
const previous = snapshot.find((entry) => entry.id === item.id)!
const values = calculateEquipmentValues({
unitPrice: Number(item.unit_price),
totalQuantity: item.total_quantity,
remainingQuantity: previous.remaining_quantity,
status: previous.status
})
const restored = await tx.equipment.updateMany({
where: {
id: currentMaintenance.equipment_id,
id: item.id,
status: '维护中',
parent_id: null
parent_id: previous.parent_id,
total_quantity: item.total_quantity,
remaining_quantity: item.remaining_quantity,
department_id: item.department_id,
mount_status: item.mount_status,
updated_at: item.updated_at
},
data: {
status: '可用',
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
}
data: { status: previous.status, remaining_quantity: previous.remaining_quantity, ...values }
})
if (updatedEquipment.count !== 1) {
throw new MaintenanceConflictError('资产已不处于维修状态,不能覆盖当前状态')
if (restored.count !== 1) {
throw new MaintenanceConflictError('资产已不处于原维修状态,不能覆盖当前状态')
}
}
await syncMountedChildrenState(tx, equipment.id, '可用')
return currentMaintenance
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
// 记录操作日志
const userId = res.locals.user?.id

View File

@ -17,7 +17,7 @@ import {
type TransactionClient = Prisma.TransactionClient
type MountLifecycleStatus = '可用' | '已借出' | '维护中' | '待报废' | '已报废'
const PARENT_MOUNTABLE_STATUSES = new Set<MountLifecycleStatus>(['可用', '已借出', '维护中'])
const PARENT_MOUNTABLE_STATUSES = new Set<MountLifecycleStatus>(['可用', '已借出'])
class MountOperationError extends Error {
statusCode: number
@ -30,6 +30,9 @@ class MountOperationError extends Error {
}
const ensureParentMountOperable = (equipment: { status: string }, label: string) => {
if (equipment.status === '维护中') {
throw new MountOperationError(`${label}正在维修,请先完成或取消维修后再挂载或卸载配件`, 409)
}
if (!PARENT_MOUNTABLE_STATUSES.has(equipment.status as MountLifecycleStatus)) {
throw new MountOperationError(`${label}当前状态不可挂载或更换配件`)
}

View File

@ -6,6 +6,8 @@ import {
createPrintTerminalPairCode as createPrintTerminalPairCodeService,
heartbeatPrintTerminalAgent,
getDefaultPrintTerminalReadiness,
getPrintAgentJobState,
resolveUnknownPrintJob,
isPrintServiceError,
listPrintJobs,
listPrintTerminals,
@ -195,7 +197,8 @@ export const heartbeatPrintAgent = async (req: Request, res: Response) => {
machine_name: req.body?.machine_name,
selected_printer_name: req.body?.selected_printer_name,
printer_snapshot: req.body?.printer_snapshot,
label_profile: req.body?.label_profile
label_profile: req.body?.label_profile,
protocol_version: req.body?.protocol_version
})
successResponse(res, result, '心跳已接收')
@ -211,7 +214,7 @@ export const claimPrintAgentJob = async (req: Request, res: Response) => {
}
try {
const result = await claimNextPrintJob(terminalId)
const result = await claimNextPrintJob(terminalId, req.body?.protocol_version)
successResponse(res, result, result.message)
} catch (error) {
handlePrintServiceError(res, error, '领取打印任务失败')
@ -233,6 +236,7 @@ export const updatePrintAgentJobStatus = async (req: Request, res: Response) =>
try {
const result = await updateClaimedPrintJobStatus(terminalId, jobId, {
status: req.body?.status,
claim_token: req.body?.claim_token,
error_message: req.body?.error_message,
retryable: req.body?.retryable
})
@ -243,6 +247,30 @@ export const updatePrintAgentJobStatus = async (req: Request, res: Response) =>
}
}
export const readPrintAgentJobState = async (req: Request, res: Response) => {
const terminalId = getCurrentTerminalId(res)
const jobId = parsePositiveInt(req.params.id)
if (!terminalId) return errorResponse(res, '打印终端未认证', 401)
if (!jobId) return errorResponse(res, '打印任务编号无效', 422)
try {
successResponse(res, await getPrintAgentJobState(terminalId, jobId), '获取任务状态成功')
} catch (error) {
handlePrintServiceError(res, error, '获取任务状态失败')
}
}
export const resolvePrintJob = async (req: Request, res: Response) => {
const userId = getCurrentUserId(res)
const jobId = parsePositiveInt(req.params.id)
if (!userId) return errorResponse(res, '当前登录用户无效', 401)
if (!jobId) return errorResponse(res, '打印任务编号无效', 422)
try {
successResponse(res, await resolveUnknownPrintJob(jobId, userId, req.body || {}), '核实结果已保存')
} catch (error) {
handlePrintServiceError(res, error, '保存核实结果失败')
}
}
export const printAgentTestPrint = async (req: Request, res: Response) => {
const terminalId = getCurrentTerminalId(res)
if (!terminalId) {

View File

@ -19,6 +19,7 @@ type InventoryByTypeRow = {
maintenance_quantity: number
pending_disposal_quantity: number
damaged_quantity: number
lost_quantity: number
other_quantity: number
mountable_available_quantity: number
is_child_type: boolean
@ -122,7 +123,7 @@ export const getDashboardStats = async (req: Request, res: Response) => {
prisma.equipment.aggregate({
where: {
parent_id: null,
status: { not: '已报废' }
status: { notIn: ['已报废', '已丢失'] }
},
_count: {
id: true
@ -164,7 +165,8 @@ export const getDashboardStats = async (req: Request, res: Response) => {
return_time: {
gte: todayRange.start,
lt: todayRange.end
}
},
return_condition: { not: '丢失' }
},
select: {
children_snapshot: true,
@ -237,6 +239,7 @@ export const getDashboardStats = async (req: Request, res: Response) => {
maintenance_equipment: maintenanceEquipmentQuantity,
pending_disposal_equipment: inventorySummary.pendingDisposal,
damaged_equipment: inventorySummary.damaged,
lost_equipment: inventorySummary.lost,
other_equipment: inventorySummary.other,
disposed_equipment: inventorySummary.disposed,
borrowed_without_active_record: borrowedWithoutActiveRecord,
@ -252,7 +255,8 @@ export const getDashboardStats = async (req: Request, res: Response) => {
today_return_value: todayReturnValue,
generated_at: generatedAt.toISOString(),
business_timezone: DASHBOARD_TIME_ZONE,
inventory_scope: 'current_excluding_disposed',
inventory_scope: 'current_excluding_disposed_and_lost',
value_scope: 'book_value_excluding_disposed_including_lost',
inventory_by_type: Array.from(
inventoryByType.reduce((map, item) => {
const equipmentType = item.equipment_type || '其他'
@ -264,6 +268,7 @@ export const getDashboardStats = async (req: Request, res: Response) => {
maintenance_quantity: 0,
pending_disposal_quantity: 0,
damaged_quantity: 0,
lost_quantity: 0,
other_quantity: 0,
mountable_available_quantity: mountableInventoryByTypeMap.get(equipmentType) || 0,
is_child_type: childTypeNames.has(equipmentType)
@ -282,6 +287,7 @@ export const getDashboardStats = async (req: Request, res: Response) => {
current.maintenance_quantity += breakdown.maintenance
current.pending_disposal_quantity += breakdown.pendingDisposal
current.damaged_quantity += breakdown.damaged
current.lost_quantity += breakdown.lost
current.other_quantity += breakdown.other
map.set(equipmentType, current)
@ -289,7 +295,7 @@ export const getDashboardStats = async (req: Request, res: Response) => {
}, new Map<string, InventoryByTypeRow>()).values()
)
.sort((a, b) => b.total_quantity - a.total_quantity)
.filter((item) => item.total_quantity > 0)
.filter((item) => item.total_quantity > 0 || item.lost_quantity > 0)
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取统计数据失败')
@ -388,6 +394,7 @@ export const getValueStatistics = async (req: Request, res: Response) => {
available_value: summary.available_value,
maintenance_value: summary.maintenance_value,
other_value: otherValue,
value_scope: 'book_value_excluding_disposed_including_lost',
value_by_type: valueByType,
top_equipment: topEquipment
}, '获取成功')

View File

@ -1,6 +1,7 @@
import 'dotenv/config'
import path from 'path'
import express from 'express'
import { getHealth } from './controllers/healthController'
import cors from 'cors'
import session from 'express-session'
import equipmentRoutes from './routes/equipment'
@ -41,6 +42,7 @@ import { isCorsOriginAllowed } from './utils/corsPolicy'
import { unsafeOriginGuard } from './middleware/originGuard'
import { assertSupportedAuthMode, resolveSessionSecret } from './utils/authMode'
import { persistentSessionStore, startSessionCleanupWorker } from './utils/sessionStore'
import { parseStandardJsonBody, parseStandardFormBody } from './utils/requestBody'
const app = express()
const PORT = process.env.PORT || 3000
@ -57,8 +59,8 @@ app.use(cors({
credentials: true
}))
app.use(unsafeOriginGuard)
app.use(express.json({ limit: '50mb' }))
app.use(express.urlencoded({ extended: true, limit: '50mb' }))
app.use(parseStandardJsonBody)
app.use(parseStandardFormBody)
app.use(
session({
secret: SESSION_SECRET,
@ -112,9 +114,7 @@ app.use('/api/dingtalk', dingtalkRoutes)
app.use('/downloads', printDownloadRoutes)
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'Server is running' })
})
app.get('/api/health', getHealth)
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {

View File

@ -4,7 +4,8 @@ import {
heartbeatPrintAgent,
pairPrintAgent,
printAgentTestPrint,
updatePrintAgentJobStatus
updatePrintAgentJobStatus,
readPrintAgentJobState
} from '../controllers/printController'
import { protectPrintAgent } from '../middleware/printAgentAuth'
@ -17,6 +18,7 @@ router.use(protectPrintAgent())
router.post('/heartbeat', heartbeatPrintAgent)
router.post('/jobs/claim', claimPrintAgentJob)
router.post('/jobs/:id/status', updatePrintAgentJobStatus)
router.get('/jobs/:id/state', readPrintAgentJobState)
router.post('/test-print', printAgentTestPrint)
export default router

View File

@ -2,15 +2,18 @@ import { Router } from 'express'
import {
cancelPrintJob,
createAssetLabelPrintJob,
getPrintJobs
getPrintJobs,
resolvePrintJob
} from '../controllers/printController'
import { requirePermission } from '../middleware/auth'
import { PERMISSIONS } from '../utils/permissions'
import { parsePrintLabelJsonBody } from '../utils/requestBody'
const router = Router()
router.post('/asset-labels', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), createAssetLabelPrintJob)
router.post('/asset-labels', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), parsePrintLabelJsonBody, createAssetLabelPrintJob)
router.get('/', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), getPrintJobs)
router.patch('/:id/cancel', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), cancelPrintJob)
router.post('/:id/resolve', requirePermission(PERMISSIONS.SYSTEM_SETTINGS), resolvePrintJob)
export default router

View File

@ -1,5 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { batchTransfer } from '../../controllers/batchController'
import {
borrowAssetTree,
@ -38,6 +40,45 @@ const childEquipment = {
updated_at: now
}
test('lost roots or descendants reject department transfer through service and batch API before any writes', async () => {
const originalTransaction = prisma.$transaction
const originalDepartmentFindFirst = prisma.department.findFirst
try {
;(prisma.department.findFirst as any) = async () => ({ id: 8, name: '目标部门' })
for (const rootIsLost of [true, false]) {
const root = rootIsLost ? { ...rootEquipment, status: '已丢失', remaining_quantity: 0 } : rootEquipment
const child = rootIsLost ? childEquipment : { ...childEquipment, status: '已丢失', remaining_quantity: 0 }
let writes = 0
const children = (ids: number[]) => ids.includes(root.id) ? [child] : []
const client = {
$queryRaw: async (query: any) => query.sql.includes('WHERE equipment.id = ?') ? [root]
: query.sql.includes('WHERE equipment.parent_id IN') ? children(query.values) : [],
equipment: {
findUnique: async () => root,
findMany: async (args: any) => children(args.where.parent_id.in),
updateMany: async () => { writes++; return { count: 1 } }
},
borrowRecord: { count: async () => 0 },
assetTransferRecord: { create: async () => { writes++; return {} } }
}
await assert.rejects(transferAssetTree(client as any, {
rootEquipmentId: root.id, actionType: 'transfer', toDepartmentId: 8, operatedBy: 9, reason: '部门调整'
}), /已丢失.*不能调拨/)
;(prisma.$transaction as any) = async (callback: any) => callback(client)
const state = { body: null as any }
const response = { locals: { user: { id: 9, role: 'it' } }, status() { return this }, json(body: any) { state.body = body; return this } }
await batchTransfer({ body: { equipment_ids: [root.id], department_id: 8, reason: '部门调整' } } as any, response as any)
assert.equal(state.body.data.success_count, 0)
assert.equal(state.body.data.failed_count, 1)
assert.match(state.body.data.results[0].message, /已丢失.*不能调拨/)
assert.equal(writes, 0)
}
} finally {
;(prisma.$transaction as any) = originalTransaction
;(prisma.department.findFirst as any) = originalDepartmentFindFirst
}
})
test('初读资产树后并发挂载新配件时调拨中止且不拆分部门', async () => {
let mountedAfterInitialRead = false
let movementWriteCount = 0

View File

@ -0,0 +1,221 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { randomUUID } from 'node:crypto'
import type { Person, InventoryTaskParticipant, InventoryTaskItem } from '@prisma/client'
// Explicit opt-in and a narrow database allowlist, before importing any client.
// Do not load .env or use the application entry point in this test.
const enabled = process.env.RUN_BUSINESS_DB_TESTS === 'true'
const assertLocalDatabase = (raw: string) => {
const url = new URL(raw)
assert.ok(url.protocol === 'mysql:' && ['127.0.0.1', 'localhost'].includes(url.hostname)
&& url.port === '13309' && url.pathname === '/equipment_audit' && !url.search && !url.hash,
'Business integration tests require localhost:13309/equipment_audit without URL overrides')
}
test('business integration database guard rejects remote, other-port, other-database and socket overrides before client loading', () => {
for (const target of [
'mysql://unused@example.invalid:13309/equipment_audit',
'mysql://unused@127.0.0.1:3306/equipment_audit',
'mysql://unused@127.0.0.1:13309/other_database',
'mysql://unused@localhost:13309/equipment_audit?socket=/tmp/another.sock'
]) assert.throws(() => assertLocalDatabase(target), /require localhost/)
assertLocalDatabase('mysql://unused@localhost:13309/equipment_audit')
})
test('business locks and exceptional returns in isolated local MySQL', { skip: !enabled }, async (t) => {
assertLocalDatabase(process.env.DATABASE_URL || '')
const prisma = (await import('../../utils/prisma')).default
const { Prisma } = await import('@prisma/client')
const inventory = await import('../inventoryService')
const maintenance = await import('../../controllers/maintenanceController')
const { scanReturn } = await import('../../controllers/borrowController')
const prefix = `biz-${randomUUID()}`
const userIds: number[] = []
const personIds: number[] = []
const equipmentIds: number[] = []
const taskIds: number[] = []
let equipmentSequence = 0
const response = (userId: number) => {
const state = { status: 200, body: null as any }
const res = {
locals: { user: { id: userId, username: prefix, name: prefix, role: 'it' } },
status(code: number) { state.status = code; return this },
json(body: any) { state.body = body; return this }
}
return { state, res }
}
const createEquipment = async (extra: Record<string, any> = {}) => {
const row = await prisma.equipment.create({ data: {
equipment_code: `${prefix}-${++equipmentSequence}`, equipment_name: prefix, equipment_type: '业务测试',
total_quantity: 1, remaining_quantity: 1, unit_price: 100,
total_value: 100, available_value: 100, status: '可用', ...extra
} })
equipmentIds.push(row.id)
return row
}
// Hold the real InnoDB row while both requests start, then release them to
// contend for it. All operations settle before the fixture is cleaned up.
const contend = async <T>(table: 'inventory_task' | 'maintenance_record', id: number, operations: Array<() => Promise<T>>) => {
let acquired!: () => void
let release!: () => void
const ready = new Promise<void>((resolve) => { acquired = resolve })
const gate = new Promise<void>((resolve) => { release = resolve })
const blocker = prisma.$transaction(async (tx) => {
await tx.$queryRaw(table === 'inventory_task'
? Prisma.sql`SELECT id FROM inventory_task WHERE id = ${id} FOR UPDATE`
: Prisma.sql`SELECT id FROM maintenance_record WHERE id = ${id} FOR UPDATE`)
acquired()
await gate
}, { timeout: 10000 })
await Promise.race([ready, blocker.then(() => { throw new Error('Lock fixture ended before acquiring row') })])
let results: Promise<PromiseSettledResult<T>[]> | undefined
try {
results = Promise.allSettled(operations.map((operation) => operation()))
await new Promise((resolve) => setTimeout(resolve, 40))
} finally {
release()
await blocker
}
return results!
}
try {
const user = await prisma.user.create({ data: { username: prefix, name: prefix, password: 'local-test-only-not-a-login', role: 'it' } })
userIds.push(user.id)
const people: Person[] = []
for (let i = 0; i < 2; i++) {
const person = await prisma.person.create({ data: { nickname: `${prefix}-${i}`, real_name: `${prefix}-${i}`, status: 'active' } })
personIds.push(person.id)
people.push(person)
}
await t.test('different-item participant scans and admin checks retain every aggregate; closed task cannot assign', async () => {
const task = await prisma.inventoryTask.create({ data: {
title: prefix, public_token: prefix, created_by: user.id,
participants_count: 2, items_count: 5, unassigned_count: 1
} })
taskIds.push(task.id)
const participants: InventoryTaskParticipant[] = []
for (const person of people) participants.push(await prisma.inventoryTaskParticipant.create({ data: {
task_id: task.id, person_id: person.id, person_name: person.real_name, person_nickname: person.nickname
} }))
const items: InventoryTaskItem[] = []
for (let i = 0; i < 5; i++) {
const equipment = await createEquipment()
items.push(await prisma.inventoryTaskItem.create({ data: {
task_id: task.id, equipment_id: equipment.id, equipment_code: equipment.equipment_code,
equipment_name: equipment.equipment_name, equipment_type: equipment.equipment_type,
participant_id: i < 4 ? participants[i % 2].id : null, is_unassigned: i === 4
} }))
}
const scanned = await contend('inventory_task', task.id, [
() => inventory.submitInventoryScan(task.id, participants[0].id, items[0].equipment_code),
() => inventory.submitInventoryScan(task.id, participants[1].id, items[1].equipment_code)
])
assert.ok(scanned.every((result) => result.status === 'fulfilled'), JSON.stringify(scanned))
assert.equal((await prisma.inventoryTask.findUniqueOrThrow({ where: { id: task.id } })).completed_count, 2)
const checked = await contend('inventory_task', task.id, [
() => inventory.submitAdminInventoryScan(task.id, items[2].equipment_code, user.id),
() => inventory.submitAdminInventoryScan(task.id, items[3].equipment_code, user.id)
])
assert.ok(checked.every((result) => result.status === 'fulfilled'), JSON.stringify(checked))
const aggregate = await prisma.inventoryTask.findUniqueOrThrow({ where: { id: task.id } })
assert.deepEqual([aggregate.items_count, aggregate.completed_count, aggregate.exception_count, aggregate.unassigned_count], [5, 4, 0, 1])
assert.equal(await prisma.inventoryTaskItem.count({ where: { task_id: task.id, check_status: 'checked' } }), 4)
assert.equal(await prisma.inventoryTaskAction.count({ where: { task_id: task.id } }), 4)
const storedParticipants = await prisma.inventoryTaskParticipant.findMany({ where: { task_id: task.id } })
assert.ok(storedParticipants.every((participant) => participant.status === 'completed'))
await inventory.closeInventoryTask(task.id, user.id)
await assert.rejects(inventory.assignUnassignedTaskItem(task.id, items[4].id, people[0].id), /已关闭/)
assert.equal((await prisma.inventoryTaskItem.findUniqueOrThrow({ where: { id: items[4].id } })).is_unassigned, true)
assert.equal(await prisma.inventoryTaskAction.count({ where: { task_id: task.id } }), 5)
assert.equal((await prisma.inventoryTask.findUniqueOrThrow({ where: { id: task.id } })).status, 'closed')
})
await t.test('completion and cancellation contend on one maintenance row; one winner commits a consistent asset tree', async () => {
for (let round = 0; round < 4; round++) {
const root = await createEquipment({ status: '已损坏', available_value: 0 })
const child = await createEquipment({ parent_id: root.id, mount_status: 'mounted' })
const created = response(user.id)
await maintenance.createMaintenance({ ip: '127.0.0.1', body: { equipment_id: root.id, issue_description: prefix, estimated_cost: 10 } } as any, created.res as any)
assert.equal(created.state.status, 200, JSON.stringify(created.state.body))
const maintenanceId = Number(created.state.body.data.id)
const completed = response(user.id)
const cancelled = response(user.id)
const operations = [
async () => maintenance.completeMaintenance({ ip: '127.0.0.1', params: { id: String(maintenanceId) }, body: { repair_result: prefix } } as any, completed.res as any),
async () => maintenance.cancelMaintenance({ ip: '127.0.0.1', params: { id: String(maintenanceId) }, body: { reason: prefix } } as any, cancelled.res as any)
]
await contend('maintenance_record', maintenanceId, round % 2 ? operations.reverse() : operations)
assert.deepEqual([completed.state.status, cancelled.state.status].sort(), [200, 409])
const record = await prisma.maintenanceRecord.findUniqueOrThrow({ where: { id: maintenanceId } })
const rows = await prisma.equipment.findMany({ where: { id: { in: [root.id, child.id] } }, orderBy: { id: 'asc' } })
assert.deepEqual(rows.map((row) => row.status), record.status === 'completed' ? ['可用', '可用'] : ['已损坏', '可用'])
assert.equal(rows[1].parent_id, root.id)
assert.ok(rows.every((row) => row.remaining_quantity === 1 && Number(row.borrowed_value) === 0))
assert.equal(Number(rows[0].available_value), record.status === 'completed' ? 100 : 0)
assert.equal(Number(rows[1].available_value), 100)
assert.equal(record.status, completed.state.status === 200 ? 'completed' : 'cancelled')
}
})
await t.test('real damaged and lost return transactions never recreate available stock or value', async () => {
for (const condition of ['损坏', '丢失']) {
const equipment = await createEquipment({ status: '已借出', remaining_quantity: 0, available_value: 0, borrowed_value: 100 })
const borrow = await prisma.borrowRecord.create({ data: {
equipment_id: equipment.id, equipment_code: equipment.equipment_code, person_id: people[0].id,
person_name: people[0].real_name, person_nickname: people[0].nickname
} })
const returned = response(user.id)
await scanReturn({ ip: '127.0.0.1', body: { equipment_code: equipment.equipment_code, borrow_record_id: borrow.id, return_condition: condition } } as any, returned.res as any)
assert.equal(returned.state.status, 200, JSON.stringify(returned.state.body))
assert.equal(returned.state.body.data.need_confirm, undefined)
const stored = await prisma.equipment.findUniqueOrThrow({ where: { id: equipment.id } })
assert.equal(stored.status, condition === '损坏' ? '已损坏' : '已丢失')
assert.equal(stored.remaining_quantity, condition === '损坏' ? 1 : 0)
assert.equal(Number(stored.available_value), 0)
assert.equal(Number(stored.borrowed_value), 0)
assert.equal(Number(stored.total_value), 100)
const storedBorrow = await prisma.borrowRecord.findUniqueOrThrow({ where: { id: borrow.id } })
assert.equal(storedBorrow.status, '已归还')
assert.equal(storedBorrow.return_condition, condition)
const movements = await prisma.assetTransferRecord.findMany({ where: { root_equipment_id: equipment.id } })
assert.equal(movements.length, 1)
assert.equal(movements[0].action_type, condition === '丢失' ? 'loss' : 'return')
}
})
} finally {
try {
// Exact fixture IDs only; cascading rows belong to these newly created IDs.
await prisma.$transaction(async (tx) => {
await tx.inventoryTask.deleteMany({ where: { id: { in: taskIds } } })
await tx.maintenanceRecord.deleteMany({ where: { equipment_id: { in: equipmentIds } } })
await tx.borrowRecord.deleteMany({ where: { equipment_id: { in: equipmentIds } } })
await tx.assetTransferRecord.deleteMany({ where: { root_equipment_id: { in: equipmentIds } } })
await tx.operationLog.deleteMany({ where: { user_id: { in: userIds } } })
await tx.equipment.updateMany({ where: { id: { in: equipmentIds } }, data: { parent_id: null } })
await tx.equipment.deleteMany({ where: { id: { in: equipmentIds } } })
await tx.user.deleteMany({ where: { id: { in: userIds } } })
await tx.person.deleteMany({ where: { id: { in: personIds } } })
}, { timeout: 10000 })
const leftovers = await Promise.all([
prisma.equipment.count({ where: { id: { in: equipmentIds } } }),
prisma.inventoryTask.count({ where: { id: { in: taskIds } } }),
prisma.user.count({ where: { id: { in: userIds } } }),
prisma.person.count({ where: { id: { in: personIds } } }),
prisma.maintenanceRecord.count({ where: { equipment_id: { in: equipmentIds } } }),
prisma.borrowRecord.count({ where: { equipment_id: { in: equipmentIds } } }),
prisma.assetTransferRecord.count({ where: { root_equipment_id: { in: equipmentIds } } }),
prisma.operationLog.count({ where: { user_id: { in: userIds } } })
])
assert.deepEqual(leftovers, Array(8).fill(0), 'All sample rows and derived records must be removed')
} finally {
await prisma.$disconnect()
}
}
})

View File

@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import sql from 'mssql'
import prisma from '../../utils/prisma'
import { runHrSync } from '../hrSyncService'
test('empty or sharply reduced successful source stops before organizational/person writes and releases sync guard', async () => {
const originals = [sql.connect, prisma.department.findMany, prisma.person.findMany, prisma.$transaction, prisma.operationLog.create]
const envKeys = ['SQLSERVER_HOST', 'SQLSERVER_DATABASE', 'SQLSERVER_USER', 'SQLSERVER_PASSWORD', 'HR_SYNC_MAX_DEACTIVATION_RATIO', 'HR_SYNC_MAX_DEACTIVATION_COUNT']
const originalEnv = envKeys.map((key) => process.env[key])
let rows: any[] = []
let sourceRelease: (() => void) | undefined
let sourceWait: Promise<void> | undefined
let closed = 0
let failureJobs = 0
let domainWrites = 0
for (const key of envKeys.slice(0, 4)) process.env[key] = 'mock-only'
delete process.env.HR_SYNC_MAX_DEACTIVATION_RATIO
delete process.env.HR_SYNC_MAX_DEACTIVATION_COUNT
const mutations = [prisma.department.create, prisma.department.update, prisma.department.updateMany, prisma.person.create, prisma.person.update, prisma.person.updateMany, prisma.person.deleteMany]
const mutationObjects: any[] = [prisma.department, prisma.department, prisma.department, prisma.person, prisma.person, prisma.person, prisma.person]
const mutationKeys = ['create', 'update', 'updateMany', 'create', 'update', 'updateMany', 'deleteMany']
mutationObjects.forEach((object, index) => { object[mutationKeys[index]] = async () => { domainWrites++; throw new Error('domain write must not occur') } })
;(prisma.department.findMany as any) = async () => []
;(prisma.person.findMany as any) = async () => Array.from({ length: 10 }, (_, index) => ({
id: index + 1, source_userid: `u${index}`, nickname: '员工', real_name: '员工', status: 'active',
position: null, department: '研发部', department_sync_bucket: 'formal', main_department_id: null,
mainDepartment: null, personDepartments: []
}))
;(sql.connect as any) = async () => ({
request: () => ({ query: async () => { if (sourceWait) await sourceWait; return { recordset: rows } } }),
close: async () => { closed++ }
})
;(prisma.$transaction as any) = async (callback: any) => callback({
syncJob: { create: async ({ data }: any) => { assert.equal(data.status, 'failed'); failureJobs++; return { id: 1 } } },
user: { findMany: async () => [] }
})
;(prisma.operationLog.create as any) = async () => ({})
try {
await assert.rejects(runHrSync('mock-test'), /源在职人员为空/)
assert.equal(domainWrites, 0)
rows = [{ sourceUserId: 'u0', employeeName: '员工', registerDept: '研发部', mainDepts: '研发部', employeeStatus: '2', registerJobNumber: '001' }]
await assert.rejects(runHrSync('mock-test'), /预计停用 9\/10 人/)
assert.equal(domainWrites, 0)
rows = []
sourceWait = new Promise<void>((resolve) => { sourceRelease = resolve })
const first = runHrSync('mock-test')
await assert.rejects(runHrSync('mock-second'), /正在进行/)
sourceRelease!()
await assert.rejects(first, /源在职人员为空/)
assert.equal(closed, 3)
assert.equal(failureJobs, 3)
assert.equal(domainWrites, 0)
} finally {
;[sql.connect, prisma.department.findMany, prisma.person.findMany, prisma.$transaction, prisma.operationLog.create] = originals as any
mutationObjects.forEach((object, index) => { object[mutationKeys[index]] = mutations[index] })
envKeys.forEach((key, index) => { if (originalEnv[index] === undefined) delete process.env[key]; else process.env[key] = originalEnv[index] })
}
})

View File

@ -0,0 +1,131 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { assignUnassignedTaskItem, closeInventoryTask, submitInventoryException, submitInventoryScan } from '../inventoryService'
const withDatabase = async (run: (database: any) => Promise<void>) => {
const originals = [prisma.$transaction, prisma.inventoryTaskItem.findFirst, prisma.person.findUnique]
let state: any = {
task: { id: 7, status: 'active', completed_count: 0, exception_count: 0 },
items: [
{ id: 1, task_id: 7, participant_id: 101, equipment_code: 'A', check_status: 'pending', is_unassigned: false },
{ id: 2, task_id: 7, participant_id: 102, equipment_code: 'B', check_status: 'pending', is_unassigned: false },
{ id: 3, task_id: 7, participant_id: null, equipment_code: 'C', check_status: 'pending', is_unassigned: true }
],
participants: [{ id: 101, task_id: 7, person_id: 1 }, { id: 102, task_id: 7, person_id: 2 }],
actions: []
}
const clone = (value: any) => JSON.parse(JSON.stringify(value))
let lockQueue = Promise.resolve()
let activeLocks = 0
let maxLocks = 0
let writes = 0
const matches = (item: any, where: any) => Object.entries(where).every(([key, value]) => item[key] === value)
;(prisma.inventoryTaskItem.findFirst as any) = async ({ where }: any) => clone(state.items.find((item: any) => matches(item, where)) || null)
;(prisma.person.findUnique as any) = async ({ where }: any) => ({ id: where.id, real_name: `人员${where.id}`, nickname: '人员', source_userid: `u-${where.id}`, status: 'active', main_department_id: null })
;(prisma.$transaction as any) = async (callback: any) => {
let working: any = null
let release: (() => void) | undefined
const current = () => { assert.ok(working, 'all transaction reads/writes must follow task FOR UPDATE'); return working }
const tx = {
$queryRaw: async (query: any) => {
assert.match(query.sql, /FROM inventory_task.*FOR UPDATE/)
assert.equal(query.values[0], 7)
const previous = lockQueue
lockQueue = new Promise<void>((resolve) => { release = resolve })
await previous
activeLocks++; maxLocks = Math.max(maxLocks, activeLocks)
// MySQL RR snapshots start only after the task row lock is acquired.
working = clone(state)
return [{ id: 7, status: working.task.status }]
},
inventoryTaskItem: {
findFirst: async ({ where }: any) => current().items.find((item: any) => matches(item, where)) || null,
findMany: async ({ where }: any) => current().items.filter((item: any) => matches(item, where)),
count: async ({ where }: any) => current().items.filter((item: any) => matches(item, where)).length,
update: async ({ where, data }: any) => { writes++; const item = current().items.find((item: any) => item.id === where.id); Object.assign(item, data); return item }
},
inventoryTaskParticipant: {
findFirst: async ({ where }: any) => current().participants.find((item: any) => matches(item, where)) || null,
findUnique: async ({ where }: any) => current().participants.find((item: any) => item.id === where.id),
findMany: async () => current().participants,
count: async () => current().participants.length,
update: async ({ where, data }: any) => { writes++; Object.assign(current().participants.find((item: any) => item.id === where.id), data); return {} },
create: async ({ data }: any) => { writes++; const participant = { id: 200 + current().participants.length, ...data }; current().participants.push(participant); return participant }
},
inventoryTask: { update: async ({ data }: any) => { writes++; Object.assign(current().task, data); return current().task } },
inventoryTaskAction: { create: async ({ data }: any) => { writes++; if (database.rejectAction) throw new Error('mock action failure'); current().actions.push(data); return {} } }
}
try {
const result = await callback(tx)
state = working
return result
} finally {
if (release) { activeLocks--; release() }
}
}
const database = {
get state() { return state },
get writes() { return writes },
get maxLocks() { return maxLocks },
rejectAction: false
}
try { await run(database) } finally {
;[prisma.$transaction, prisma.inventoryTaskItem.findFirst, prisma.person.findUnique] = originals as any
}
}
test('concurrent participants serialize task snapshots and retain both checked count and audit actions', async () => {
await withDatabase(async (database) => {
await Promise.all([submitInventoryScan(7, 101, 'A'), submitInventoryScan(7, 102, 'B')])
assert.equal(database.maxLocks, 1)
assert.equal(database.state.items.filter((item: any) => item.check_status === 'checked').length, 2)
assert.equal(database.state.task.completed_count, 2)
assert.equal(database.state.task.exception_count, 0)
assert.equal(database.state.actions.length, 2)
})
})
test('closed task rejects assignment, participant scans and exceptions without mutations', async () => {
await withDatabase(async (database) => {
await closeInventoryTask(7, 9)
const before = structuredClone(database.state)
const writes = database.writes
await assert.rejects(assignUnassignedTaskItem(7, 3, 8), /任务已关闭/)
await assert.rejects(submitInventoryScan(7, 101, 'A'), /任务已关闭/)
await assert.rejects(submitInventoryException(7, 101, 1, 'not_found'), /任务已关闭/)
assert.deepEqual(database.state, before)
assert.equal(database.writes, writes)
})
})
test('close racing a scan is serialized; no result can mutate after closure', async () => {
await withDatabase(async (database) => {
const results = await Promise.allSettled([closeInventoryTask(7, 9), submitInventoryScan(7, 101, 'A')])
assert.equal(results[0].status, 'fulfilled')
assert.equal(results[1].status, 'rejected')
assert.equal(database.state.task.status, 'closed')
assert.equal(database.state.items[0].check_status, 'pending')
assert.equal(database.state.task.completed_count, 0)
})
})
test('concurrent assignment of the same unassigned item has one winner and no orphan participant', async () => {
await withDatabase(async (database) => {
const results = await Promise.allSettled([assignUnassignedTaskItem(7, 3, 8), assignUnassignedTaskItem(7, 3, 9)])
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1)
assert.equal(results.filter((result) => result.status === 'rejected').length, 1)
assert.equal(database.state.participants.length, 3)
assert.equal(database.state.items[2].is_unassigned, false)
assert.equal(database.state.actions.length, 1)
})
})
test('inventory item, counters and audit action commit together; failed action rolls back result', async () => {
await withDatabase(async (database) => {
const before = structuredClone(database.state)
database.rejectAction = true
await assert.rejects(submitInventoryScan(7, 101, 'A'), /mock action failure/)
assert.deepEqual(database.state, before)
})
})

View File

@ -0,0 +1,166 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { randomUUID } from 'crypto'
import { promises as fs } from 'fs'
import path from 'path'
import express from 'express'
import prisma from '../../utils/prisma'
import {
claimNextPrintJob, expireStalePrintClaims, resolveUnknownPrintJob,
updateClaimedPrintJobStatus
} from '../printService'
import { getHealth } from '../../controllers/healthController'
import { PNG_ONE, makePng } from './printFixture'
// Opt-in only. Refuse to run against any shared or production database.
const enabled = process.env.RUN_PRINT_DB_TESTS === 'true'
test('打印领取恢复在独立MySQL库中的并发与幂等验证', { skip: !enabled }, async (t) => {
const url = new URL(process.env.DATABASE_URL || '')
assert.ok(url.protocol === 'mysql:' && ['127.0.0.1', 'localhost'].includes(url.hostname)
&& url.port === '13309' && url.pathname === '/equipment_audit' && !url.search && !url.hash,
'只允许本机专用equipment_audit测试库')
const suffix = randomUUID().slice(0, 8)
const user = await prisma.user.create({ data: { username: `audit-${suffix}`, name: '测试样本', password: 'not-a-real-password' } })
const terminal = await prisma.printTerminal.create({ data: {
name: `Audit ${suffix}`, platform: 'test', machine_name: 'test-only', status: 'online', protocol_version: 2,
terminal_token_hash: randomUUID(), selected_printer_name: 'Test printer', last_seen_at: new Date(),
label_profile_json: JSON.stringify({ paper_size: '60x40mm', width_mm: 60, height_mm: 40, orientation: 'landscape', scale_mode: 'fit' })
} })
const createJob = (data: Record<string, unknown> = {}) => prisma.printJob.create({ data: {
target_terminal_id: terminal.id, requested_by: user.id,
payload_json: JSON.stringify({ job_type: 'asset_label', generated_at: new Date().toISOString(), equipment_ids: [1],
items: [], rendered_labels: [{ equipment_id: 1, image_data_url: PNG_ONE }] }), ...data
} })
try {
await t.test('旧助手缺协议能力时不能领取,两个新请求只有一个领取成功', async () => {
const job = await createJob()
await assert.rejects(claimNextPrintJob(terminal.id), /更新打印助手/)
const claims = await Promise.all([claimNextPrintJob(terminal.id, 2), claimNextPrintJob(terminal.id, 2)])
const winners = claims.filter(claim => claim.job)
assert.equal(winners.length, 1)
assert.equal(winners[0].job?.id, job.id)
assert.ok(winners[0].job?.claim_token)
const stored = await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })
assert.equal(stored.attempt_count, 1)
assert.ok(stored.lease_expires_at && stored.lease_expires_at > new Date())
})
await t.test('旧token不能回写;同token完成重复提交成功,晚到续期不能倒退', async () => {
const job = await createJob({ status: 'printing', claim_token: suffix, claimed_by_terminal_id: terminal.id,
lease_expires_at: new Date(Date.now() + 120000) })
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: 'old' }), /凭据/)
await Promise.all([
updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: suffix }),
updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: suffix })
])
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'printing', claim_token: suffix }), /结束/)
assert.equal((await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })).status, 'completed')
})
await t.test('超时和旧无租约任务进入待核实,不能自动重排;同token晚到完成仍接收', async () => {
const job = await createJob({ status: 'claimed', claim_token: `${suffix}-expired`, claimed_by_terminal_id: terminal.id,
lease_expires_at: new Date(Date.now() - 1000) })
const legacy = await createJob({ status: 'printing', claimed_by_terminal_id: terminal.id })
await expireStalePrintClaims()
for (const id of [job.id, legacy.id]) {
assert.equal((await prisma.printJob.findUniqueOrThrow({ where: { id } })).status, 'result_unknown')
}
assert.equal((await claimNextPrintJob(terminal.id, 2)).job, null)
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: job.claim_token! })
assert.equal((await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })).status, 'completed')
})
await t.test('打印中失败即使retryable也不能整批自动重派', async () => {
const job = await createJob({ status: 'printing', claim_token: `${suffix}-partial`, claimed_by_terminal_id: terminal.id,
lease_expires_at: new Date(Date.now() + 120000) })
const result = await updateClaimedPrintJobStatus(terminal.id, job.id, {
status: 'failed', claim_token: job.claim_token!, retryable: true, error_message: 'partial output'
})
assert.equal(result.job.status, 'result_unknown')
assert.equal((await claimNextPrintJob(terminal.id, 2)).job, null)
})
await t.test('人工处理需明确确认,两个核实请求只有一个成功,旧token不能覆盖重派', async () => {
const token = `${suffix}-resolve`
const job = await createJob({ status: 'result_unknown', claim_token: token, claimed_by_terminal_id: terminal.id })
await assert.rejects(resolveUnknownPrintJob(job.id, user.id, { outcome: 'not_printed', expected_claim_token: token }), /核实/)
const results = await Promise.allSettled([
resolveUnknownPrintJob(job.id, user.id, { confirmed: true, outcome: 'not_printed', expected_claim_token: token }),
resolveUnknownPrintJob(job.id, user.id, { confirmed: true, outcome: 'cancel', expected_claim_token: token })
])
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1)
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: token }), /凭据/)
const final = await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })
assert.ok(['queued', 'cancelled'].includes(final.status))
assert.equal(final.result_confirmed_by, user.id)
assert.ok(final.result_confirmed_at)
await prisma.printJob.delete({ where: { id: job.id } })
})
await t.test('领取后的成品图读取失败保留凭据并条件标记failed', async () => {
const job = await createJob({ payload_json: JSON.stringify({ job_type: 'asset_label', items: [], equipment_ids: [1],
rendered_label_files: [{ equipment_id: 1, storage_key: `missing-${suffix}/label.png`, content_type: 'image/png' }] }) })
const result = await claimNextPrintJob(terminal.id, 2)
assert.equal(result.job, null)
assert.match(result.message, /成品标签图读取失败/)
const final = await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })
assert.equal(final.status, 'failed')
assert.ok(final.claim_token)
assert.equal(final.lease_expires_at, null)
})
await t.test('待核实保留PNG,人工确认未出纸后原图重派,完成后才清理', async () => {
const root = path.resolve(__dirname, '../../../uploads/print-jobs')
const batch = `test-${randomUUID()}`
const directory = path.resolve(root, batch)
const relative = path.relative(root, directory)
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative))
const imagePath = path.join(directory, 'label.png')
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(imagePath, makePng())
try {
const token = `${suffix}-image`
const job = await createJob({ status: 'result_unknown', claim_token: token, claimed_by_terminal_id: terminal.id,
payload_json: JSON.stringify({ job_type: 'asset_label', items: [], equipment_ids: [1],
rendered_label_files: [{ equipment_id: 1, storage_key: `${batch}/label.png`, content_type: 'image/png' }] }) })
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'result_unknown', claim_token: token })
await fs.access(imagePath)
await resolveUnknownPrintJob(job.id, user.id, { confirmed: true, outcome: 'not_printed', expected_claim_token: token })
await fs.access(imagePath)
const claim = await claimNextPrintJob(terminal.id, 2)
assert.equal(claim.job?.id, job.id)
assert.ok(claim.job?.claim_token && claim.job.claim_token !== token)
const payload = claim.job?.payload
assert.ok(payload && 'rendered_labels' in payload)
assert.equal(payload.rendered_labels?.[0].image_data_url, PNG_ONE)
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: token }), /凭据/)
await fs.access(imagePath)
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: claim.job!.claim_token! })
await assert.rejects(fs.access(imagePath))
} finally { await fs.rm(directory, { recursive: true, force: true }) }
})
await t.test('人工确认已出纸可完成历史无token任务,重复人工决定拒绝', async () => {
const job = await createJob({ status: 'result_unknown' })
await resolveUnknownPrintJob(job.id, user.id, { confirmed: true, outcome: 'printed', expected_claim_token: null })
const final = await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })
assert.equal(final.status, 'completed')
assert.equal(final.result_confirmed_by, user.id)
await assert.rejects(resolveUnknownPrintJob(job.id, user.id, { confirmed: true, outcome: 'not_printed', expected_claim_token: null }), /状态已变化/)
})
await t.test('真实GET /api/health能够验证迁移后的业务列和会话表', async () => {
// The web-session table is managed outside the Prisma schema. The fixture
// has only the column used by readiness; no login or real session is used.
await prisma.$executeRawUnsafe('CREATE TABLE IF NOT EXISTS web_session (sid VARCHAR(128) PRIMARY KEY)')
const app = express()
app.get('/api/health', getHealth)
const server = app.listen(0, '127.0.0.1')
try {
await new Promise<void>((resolve) => server.listening ? resolve() : server.once('listening', resolve))
const address = server.address()
assert.ok(address && typeof address !== 'string')
const response = await fetch(`http://127.0.0.1:${address.port}/api/health`)
assert.equal(response.status, 200)
assert.equal((await response.json() as { status: string }).status, 'ok')
} finally { await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())) }
})
} finally {
await prisma.printJob.deleteMany({ where: { requested_by: user.id, target_terminal_id: terminal.id } })
await prisma.printTerminal.delete({ where: { id: terminal.id } })
await prisma.user.delete({ where: { id: user.id } })
await prisma.$disconnect()
}
})

View File

@ -0,0 +1,30 @@
import { deflateSync } from 'zlib'
export const pngChunk = (type: string, content: Buffer) => {
const chunk = Buffer.alloc(content.length + 12)
chunk.writeUInt32BE(content.length, 0)
chunk.write(type, 4, 4, 'ascii')
content.copy(chunk, 8)
let crc = 0xffffffff
for (const value of chunk.subarray(4, chunk.length - 4)) {
crc ^= value
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0)
}
chunk.writeUInt32BE((crc ^ 0xffffffff) >>> 0, chunk.length - 4)
return chunk
}
export const makePng = (width = 1, height = 1, pixels = Buffer.from([0, 0, 0, 0, 255])) => {
const header = Buffer.alloc(13)
header.writeUInt32BE(width, 0)
header.writeUInt32BE(height, 4)
header[8] = 8
header[9] = 6
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk('IHDR', header), pngChunk('IDAT', deflateSync(pixels)), pngChunk('IEND', Buffer.alloc(0))
])
}
export const PNG_ONE = `data:image/png;base64,${makePng().toString('base64')}`
export const PNG_TWO = `data:image/png;base64,${makePng(1, 1, Buffer.from([0, 255, 0, 0, 255])).toString('base64')}`

View File

@ -0,0 +1,45 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { randomBytes } from 'crypto'
import { sanitizeRenderedLabelImages } from '../printService'
import { validateLabelPng, MAX_LABEL_IMAGE_BYTES } from '../../utils/labelPng'
import { makePng, PNG_ONE } from './printFixture'
test('真实PNG可打印,假Base64、截断、CRC错误、尺寸炸弹均拒绝', () => {
validateLabelPng(makePng())
assert.throws(() => validateLabelPng(Buffer.from('fake image')), /PNG/)
assert.throws(() => validateLabelPng(makePng().subarray(0, -1)), /不完整/)
const bad = makePng()
bad[40] ^= 1
assert.throws(() => validateLabelPng(bad), /校验/)
assert.throws(() => validateLabelPng(makePng(100000, 100000)), /尺寸/)
assert.throws(() => validateLabelPng(makePng(1, 1, Buffer.alloc(1_000_000))), /压缩数据/)
assert.throws(() => validateLabelPng(makePng(1, 1, Buffer.from([5, 0, 0, 0, 255]))), /过滤/)
})
test('拒绝每张和任务数量预算超限,保存前完成检查', () => {
assert.throws(() => validateLabelPng(Buffer.alloc(MAX_LABEL_IMAGE_BYTES + 1)), /2 MB/)
assert.throws(() => sanitizeRenderedLabelImages(Array.from({ length: 101 }, (_, i) => ({
equipment_id: i + 1, image_data_url: PNG_ONE
})), Array.from({ length: 101 }, (_, i) => i + 1)), /100/)
})
test('成品图重复编号、缺少或多余资产都拒绝', () => {
const entry = { equipment_id: 1, image_data_url: PNG_ONE }
assert.throws(() => sanitizeRenderedLabelImages([entry, entry], [1, 2]), /重复/)
assert.throws(() => sanitizeRenderedLabelImages([entry], [1, 2]), /数量/)
assert.throws(() => sanitizeRenderedLabelImages([entry], [2]), /缺少/)
})
test('每张均为有效且未超限PNG时,整批解码总量仍受20MB预算限制', () => {
const width = 1024, height = 256, rowBytes = width * 4 + 1
const pixels = randomBytes(rowBytes * height)
for (let row = 0; row < height; row++) pixels[row * rowBytes] = 0
const image = makePng(width, height, pixels)
assert.ok(image.length < MAX_LABEL_IMAGE_BYTES)
validateLabelPng(image)
const imageDataUrl = `data:image/png;base64,${image.toString('base64')}`
assert.throws(() => sanitizeRenderedLabelImages(Array.from({ length: 21 }, (_, i) => ({
equipment_id: i + 1, image_data_url: imageDataUrl
})), Array.from({ length: 21 }, (_, i) => i + 1)), /总大小超限/)
})

View File

@ -1,5 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { PNG_ONE, PNG_TWO } from './printFixture'
import {
normalizeLabelProfile,
planPrintTerminalAdminStatusChange,
@ -306,11 +307,11 @@ test('sanitizeRenderedLabelImages 会按 equipment_ids 顺序重排网页生成
[
{
equipment_id: 2,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0y'
image_data_url: PNG_TWO
},
{
equipment_id: 1,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
image_data_url: PNG_ONE
}
],
[1, 2]
@ -319,11 +320,11 @@ test('sanitizeRenderedLabelImages 会按 equipment_ids 顺序重排网页生成
assert.deepEqual(renderedLabels, [
{
equipment_id: 1,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
image_data_url: PNG_ONE
},
{
equipment_id: 2,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0y'
image_data_url: PNG_TWO
}
])
})
@ -335,7 +336,7 @@ test('sanitizeRenderedLabelImages 在缺少网页成品图时直接报错,避
[
{
equipment_id: 1,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
image_data_url: PNG_ONE
}
],
[1, 2]

View File

@ -622,7 +622,7 @@ export const createAssetMovementRecord = async (
client: TransactionClient,
input: {
items: AssetTreeItem[]
actionType: 'transfer' | 'idle' | 'return'
actionType: 'transfer' | 'idle' | 'return' | 'loss'
toDepartmentId: number | null
toDepartmentName?: string | null
operatedBy: number
@ -677,7 +677,7 @@ export const transferAssetTree = async (
}
const blocked = items.find((item) =>
['已借出', '维护中', '待报废', '已报废'].includes(item.status)
['已借出', '维护中', '待报废', '已报废', '已丢失'].includes(item.status)
)
if (blocked) {
throw new Error(`资产 ${blocked.equipment_code} 当前为${blocked.status},不能调拨`)
@ -751,15 +751,17 @@ export const recordReturnToStorage = async (
rootEquipmentId: number
operatedBy: number
reason?: string | null
returnCondition?: string
}
) => {
const items = await loadAndLockAssetTree(client, input.rootEquipmentId)
assertReturnConditionSupported(items, input.returnCondition || '正常')
const movement = await createAssetMovementRecord(client, {
items,
actionType: 'return',
actionType: input.returnCondition === '丢失' ? 'loss' : 'return',
toDepartmentId: null,
operatedBy: input.operatedBy,
reason: input.reason || '正常归还入仓'
reason: input.returnCondition === '丢失' ? `丢失登记(未入仓)${input.reason ? `;${input.reason}` : ''}` : input.reason || '正常归还入仓'
})
return { items, movement }
}
@ -769,7 +771,7 @@ const updateReturnedRootWithSnapshot = async (
root: AssetTreeItem,
input: {
remainingQuantity: number
status: '可用' | '已借出'
status: '可用' | '已借出' | '已损坏' | '已丢失'
departmentId: number | null
}
) => {
@ -805,10 +807,23 @@ const updateReturnedRootWithSnapshot = async (
}
}
export const assertReturnConditionSupported = (items: AssetTreeItem[], returnCondition: string) => {
if (!['正常', '损坏', '丢失'].includes(returnCondition)) {
throw Object.assign(new Error('归还状况无效'), { status: 422 })
}
if (returnCondition !== '正常' && items.some((item) => item.total_quantity !== 1)) {
throw new AssetTreeConflictError('多数量资产无法准确记录单件损坏或丢失,请先人工核实并拆分台账;原借用单保持未归还')
}
}
export const finalizeNormalReturnState = async (
client: TransactionClient,
root: AssetTreeItem
root: AssetTreeItem,
returnCondition = '正常',
lockedItems?: AssetTreeItem[]
) => {
const items = lockedItems || (returnCondition === '正常' ? [root] : await loadAndLockAssetTree(client, root.id))
assertReturnConditionSupported(items, returnCondition)
if (root.parent_id !== null) {
throw new AssetTreeConflictError('挂载中的子资产不能单独归还')
}
@ -821,6 +836,15 @@ export const finalizeNormalReturnState = async (
throw new AssetTreeConflictError('资产状态与当前库存不一致,请先核查')
}
if (returnCondition !== '正常') {
const status = returnCondition === '损坏' ? '已损坏' as const : '已丢失' as const
const remainingQuantity = returnCondition === '损坏' ? 1 : 0
for (const item of items) {
await updateReturnedRootWithSnapshot(client, item, { remainingQuantity, status, departmentId: null })
}
return { remainingQuantity, borrowedQuantity: 0, status, departmentId: null }
}
const remainingQuantity = root.remaining_quantity + 1
const borrowedQuantity = root.total_quantity - remainingQuantity
const status = remainingQuantity > 0 ? '可用' : '已借出'

View File

@ -1,4 +1,5 @@
import prisma from '../utils/prisma'
import { assertHrSyncSourceSafe } from '../utils/hrSyncSafety'
import sql from 'mssql'
import {
HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM,
@ -1823,10 +1824,14 @@ export const previewHrSync = async () => {
}
}
let hrSyncRunning = false
export const runHrSync = async (
operator = 'system-sync',
triggerMode: SyncTriggerMode = inferTriggerMode(operator)
) => {
if (hrSyncRunning) throw Object.assign(new Error('花名册同步正在进行,请稍后再试'), { status: 409 })
hrSyncRunning = true
let pool: any = null
try {
@ -1838,6 +1843,11 @@ export const runHrSync = async (
pool = await sql.connect(getSqlServerConfig())
const personCollection = await getSourcePersons(pool)
const persons = personCollection.persons
assertHrSyncSourceSafe(
persons.map((person) => person.sourceUserId),
[...beforePersonMap.values()].filter((person) => person.status === 'active').map((person) => person.sourceUserId),
{ maxRatio: process.env.HR_SYNC_MAX_DEACTIVATION_RATIO, maxCount: process.env.HR_SYNC_MAX_DEACTIVATION_COUNT }
)
const businessDepartments = buildBusinessDepartmentCandidates(persons)
const departmentResult = await syncDepartments(businessDepartments)
@ -1938,8 +1948,10 @@ export const runHrSync = async (
throw error
} finally {
if (pool) {
await pool.close()
try {
if (pool) await pool.close()
} finally {
hrSyncRunning = false
}
}
}

View File

@ -1,5 +1,5 @@
import { randomBytes } from 'crypto'
import type { PrismaClient } from '@prisma/client'
import { Prisma, type PrismaClient } from '@prisma/client'
import prisma from '../utils/prisma'
import { buildBusinessDepartmentWhere } from '../utils/organization'
import {
@ -701,6 +701,18 @@ const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({
const createPublicToken = () => randomBytes(24).toString('hex')
const lockOpenInventoryTask = async (tx: Prisma.TransactionClient, taskId: number) => {
const rows = await tx.$queryRaw<Array<{ id: number; status: string }>>(
Prisma.sql`SELECT id, status FROM inventory_task WHERE id = ${taskId} FOR UPDATE`
)
const task = rows[0]
if (!task) throw Object.assign(new Error('盘点任务不存在'), { status: 404 })
if (task.status === INVENTORY_TASK_STATUSES.closed) {
throw Object.assign(new Error('盘点任务已关闭,不能修改盘点结果或分配责任人'), { status: 403 })
}
return task
}
const refreshTaskCounters = async (tx: PrismaClient, taskId: number) => {
const [participantsCount, itemsCount, completedCount, exceptionCount, unassignedCount] = await Promise.all([
tx.inventoryTaskParticipant.count({
@ -1191,37 +1203,18 @@ export const getInventoryTaskDetail = async (taskId: number) => {
}
export const closeInventoryTask = async (taskId: number, operatorUserId: 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) {
return
}
await prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ id: number; status: string }>>(
Prisma.sql`SELECT id, status FROM inventory_task WHERE id = ${taskId} FOR UPDATE`
)
if (!rows[0]) throw new Error('盘点任务不存在')
if (rows[0].status === INVENTORY_TASK_STATUSES.closed) return
await tx.inventoryTask.update({
where: { id: taskId },
data: {
status: INVENTORY_TASK_STATUSES.closed,
closed_at: new Date()
}
data: { status: INVENTORY_TASK_STATUSES.closed, closed_at: new Date() }
})
await tx.inventoryTaskAction.create({
data: {
task_id: taskId,
action_type: 'task_closed',
operator_user_id: operatorUserId
}
data: { task_id: taskId, action_type: 'task_closed', operator_user_id: operatorUserId }
})
})
}
@ -1488,22 +1481,18 @@ const updateTaskItemStatus = async (
check_status: string
exception_type?: string | null
exception_note?: string | null
action_type: string
action_payload: Record<string, unknown>
}
) => {
const item = await prisma.inventoryTaskItem.findFirst({
where: {
id: itemId,
task_id: taskId,
participant_id: participantId
}
) => prisma.$transaction(async (tx) => {
// Serialize task mutations before any aggregate snapshot is established.
// Closing uses the same row lock, so no scan can commit after task closure.
await lockOpenInventoryTask(tx, taskId)
const item = await tx.inventoryTaskItem.findFirst({
where: { id: itemId, task_id: taskId, participant_id: participantId }
})
if (!item) {
throw new Error('盘点资产不在当前人员范围内')
}
await prisma.$transaction(async (tx) => {
await tx.inventoryTaskItem.update({
if (!item) throw new Error('盘点资产不在当前人员范围内')
const updated = await tx.inventoryTaskItem.update({
where: { id: itemId },
data: {
check_status: payload.check_status,
@ -1513,89 +1502,45 @@ const updateTaskItemStatus = async (
exception_note: payload.exception_note ?? null
}
})
await tx.inventoryTaskParticipant.update({
where: { id: participantId },
data: {
last_action_at: new Date()
}
where: { id: participantId }, data: { last_action_at: new Date() }
})
await refreshParticipantStatus(tx as unknown as PrismaClient, participantId)
await refreshTaskCounters(tx as unknown as PrismaClient, taskId)
await tx.inventoryTaskAction.create({
data: {
task_id: taskId, participant_id: participantId, item_id: item.id,
action_type: payload.action_type, action_payload: JSON.stringify(payload.action_payload)
}
})
}
return updated
})
export const submitInventoryScan = async (taskId: number, participantId: number, equipmentCode: string) => {
const normalizedCode = equipmentCode.trim()
if (!normalizedCode) {
throw new Error('资产编码不能为空')
}
if (!normalizedCode) throw new Error('资产编码不能为空')
const item = await prisma.inventoryTaskItem.findFirst({
where: {
task_id: taskId,
equipment_code: normalizedCode
}
where: { task_id: taskId, equipment_code: normalizedCode }
})
if (!item) {
throw new Error('该资产不在本次盘点任务范围内')
}
if (item.participant_id !== participantId) {
throw new Error('该资产不在你的盘点范围内')
}
await updateTaskItemStatus(taskId, participantId, item.id, {
if (!item) throw new Error('该资产不在本次盘点任务范围内')
if (item.participant_id !== participantId) throw new Error('该资产不在你的盘点范围内')
return updateTaskItemStatus(taskId, participantId, item.id, {
check_status: INVENTORY_ITEM_CHECK_STATUS.checked,
exception_type: null,
exception_note: null
exception_type: null, exception_note: null,
action_type: 'item_checked', action_payload: { equipment_code: normalizedCode }
})
await prisma.inventoryTaskAction.create({
data: {
task_id: taskId,
participant_id: participantId,
item_id: item.id,
action_type: 'item_checked',
action_payload: JSON.stringify({
equipment_code: normalizedCode
})
}
})
return item
}
export const submitInventoryException = async (
taskId: number,
participantId: number,
itemId: number,
exceptionType: string,
exceptionNote?: string | null
taskId: number, participantId: number, itemId: number,
exceptionType: string, exceptionNote?: string | null
) => {
if (!['not_found', 'not_with_me'].includes(exceptionType)) {
throw new Error('异常类型无效')
}
await updateTaskItemStatus(taskId, participantId, itemId, {
if (!['not_found', 'not_with_me'].includes(exceptionType)) throw new Error('异常类型无效')
return updateTaskItemStatus(taskId, participantId, itemId, {
check_status: INVENTORY_ITEM_CHECK_STATUS.exception,
exception_type: exceptionType,
exception_note: exceptionNote?.trim() || null
})
await prisma.inventoryTaskAction.create({
data: {
task_id: taskId,
participant_id: participantId,
item_id: itemId,
exception_type: exceptionType, exception_note: exceptionNote?.trim() || null,
action_type: 'item_exception_reported',
action_payload: JSON.stringify({
exception_type: exceptionType,
exception_note: exceptionNote?.trim() || null
})
}
action_payload: { exception_type: exceptionType, exception_note: exceptionNote?.trim() || null }
})
}
@ -1641,18 +1586,10 @@ const updateTaskItemStatusByAdmin = async (
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) => {
return prisma.$transaction(async (tx) => {
await lockOpenInventoryTask(tx, taskId)
const item = await tx.inventoryTaskItem.findFirst({ where: { id: itemId, task_id: taskId } })
if (!item) throw new Error('该资产不在本次盘点任务范围内')
await tx.inventoryTaskItem.update({
where: { id: item.id },
data: {
@ -1686,10 +1623,7 @@ const updateTaskItemStatusByAdmin = async (
operator_user_id: operatorUserId
}
})
})
return prisma.inventoryTaskItem.findUnique({
where: { id: item.id }
return tx.inventoryTaskItem.findUnique({ where: { id: item.id } })
})
}
@ -1739,24 +1673,14 @@ export const assignUnassignedTaskItem = async (
itemId: number,
personId: number
) => {
const [task, item, person] = await Promise.all([
prisma.inventoryTask.findUnique({
where: { id: taskId },
select: { id: true }
}),
prisma.inventoryTaskItem.findFirst({
where: {
id: itemId,
task_id: taskId
}
}),
prisma.person.findUnique({
const person = await prisma.person.findUnique({
where: { id: personId },
select: {
id: true,
real_name: true,
nickname: true,
source_userid: true,
status: true,
department: true,
main_department_id: true,
mainDepartment: {
@ -1767,21 +1691,15 @@ export const assignUnassignedTaskItem = async (
}
}
})
])
if (!task) {
throw new Error('盘点任务不存在')
}
if (!item || !item.is_unassigned) {
throw new Error('待处理资产不存在')
}
if (!person) {
throw new Error('目标人员不存在')
if (!person || person.status !== 'active') {
throw new Error('目标人员不存在或已停用')
}
await prisma.$transaction(async (tx) => {
await lockOpenInventoryTask(tx, taskId)
const item = await tx.inventoryTaskItem.findFirst({ where: { id: itemId, task_id: taskId } })
if (!item || !item.is_unassigned) throw new Error('待处理资产不存在或已被分配,请刷新后重试')
let participant = await tx.inventoryTaskParticipant.findFirst({
where: {
task_id: taskId,

View File

@ -1,6 +1,7 @@
import { promises as fs } from 'fs'
import path from 'path'
import { createHash, randomBytes, randomUUID } from 'crypto'
import { validateLabelPng, MAX_LABELS_PER_JOB, MAX_LABEL_JOB_BYTES, MAX_LABEL_IMAGE_BYTES } from '../utils/labelPng'
import type { Prisma, PrintJob, PrintTerminal } from '@prisma/client'
import prisma from '../utils/prisma'
@ -90,6 +91,7 @@ export interface PairPrintTerminalInput {
}
export interface PrintHeartbeatInput {
protocol_version?: number
name?: string | null
platform?: string | null
machine_name?: string | null
@ -99,7 +101,8 @@ export interface PrintHeartbeatInput {
}
export interface PrintJobStatusUpdateInput {
status: 'printing' | 'completed' | 'failed'
status: 'printing' | 'completed' | 'failed' | 'result_unknown'
claim_token: string
error_message?: string | null
retryable?: boolean
}
@ -122,6 +125,7 @@ export const PRINT_JOB_STATUS = {
printing: 'printing',
completed: 'completed',
failed: 'failed',
resultUnknown: 'result_unknown',
cancelled: 'cancelled'
} as const
@ -142,7 +146,7 @@ const DEFAULT_LABEL_PROFILE: NormalizedPrintLabelProfile = {
const PRINT_TERMINAL_STALE_MS = 90_000
const PRINT_PAIR_CODE_TTL_MINUTES = 10
const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50
const MAX_RETRY_ATTEMPTS = 3
export const PRINT_CLAIM_LEASE_MS = 120_000
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
@ -476,6 +480,10 @@ const sanitizeEquipmentIds = (value: unknown) => {
throw new PrintServiceError('请选择要打印的资产', 422)
}
if (value.length > MAX_LABELS_PER_JOB || uniqueIds.length > MAX_LABELS_PER_JOB) {
throw new PrintServiceError(`每次最多打印 ${MAX_LABELS_PER_JOB} 张标签,请分批发送`, 422)
}
return uniqueIds
}
@ -503,15 +511,16 @@ const decodePngDataUrl = (value: unknown) => {
}
const base64 = normalized.slice(PNG_DATA_URL_PREFIX.length)
if (base64.length > Math.ceil(MAX_LABEL_IMAGE_BYTES / 3) * 4) {
throw new PrintServiceError('单张标签图片不能超过 2 MB', 422)
}
try {
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0) {
throw new Error('empty image')
}
validateLabelPng(buffer)
return buffer
} catch {
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
} catch (error) {
throw new PrintServiceError(error instanceof Error ? error.message : '网页成品标签图格式无效', 422)
}
}
@ -524,6 +533,10 @@ export const sanitizeRenderedLabelImages = (
}
const imageMap = new Map<number, AssetLabelRenderedImagePayload>()
if (value.length > MAX_LABELS_PER_JOB) {
throw new PrintServiceError(`每次最多打印 ${MAX_LABELS_PER_JOB} 张标签,请分批发送`, 422)
}
let totalBytes = 0
for (const item of value) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
@ -540,7 +553,10 @@ export const sanitizeRenderedLabelImages = (
}
const imageDataUrl = String((item as { image_data_url?: unknown }).image_data_url || '').trim()
decodePngDataUrl(imageDataUrl)
totalBytes += decodePngDataUrl(imageDataUrl).length
if (totalBytes > MAX_LABEL_JOB_BYTES) {
throw new PrintServiceError('标签图片总大小超限,请减少标签数量后发送', 422)
}
imageMap.set(equipmentId, {
equipment_id: equipmentId,
@ -809,6 +825,7 @@ const mapTerminalRecord = (
id: terminal.id,
name: terminal.name,
platform: terminal.platform,
protocol_version: terminal.protocol_version,
machine_name: terminal.machine_name,
status: parsed.effectiveStatus,
is_default: normalizedDefaultFlag,
@ -872,6 +889,10 @@ const mapPrintJobRecord = async (
attempt_count: job.attempt_count,
error_message: job.error_message,
claimed_at: job.claimed_at,
claim_token: job.claim_token,
lease_expires_at: job.lease_expires_at,
result_confirmed_by: job.result_confirmed_by,
result_confirmed_at: job.result_confirmed_at,
started_at: job.started_at,
completed_at: job.completed_at,
created_at: job.created_at,
@ -1315,6 +1336,7 @@ export const listPrintJobs = async (
status?: string | null
) => {
const safePage = Math.max(1, Number(page) || 1)
await expireStalePrintClaims()
const safePageSize = Math.min(
MAX_PRINT_JOB_LIST_PAGE_SIZE,
Math.max(1, Number(pageSize) || 10)
@ -1571,6 +1593,7 @@ export const heartbeatPrintTerminalAgent = async (
machine_name: sanitizeText(input.machine_name) || existing.machine_name,
status: PRINT_TERMINAL_STATUS.online,
last_seen_at: new Date(),
protocol_version: input.protocol_version === 2 ? 2 : 1,
selected_printer_name:
sanitizeText(input.selected_printer_name) || existing.selected_printer_name,
printer_snapshot_json:
@ -1611,7 +1634,29 @@ const buildRequesterSelection = {
name: true
} as const
export const claimNextPrintJob = async (terminalId: number) => {
export const expireStalePrintClaims = async (now = new Date()) => {
// A lost acknowledgment may follow actual output. Never blindly requeue.
return prisma.printJob.updateMany({
where: {
status: { in: [PRINT_JOB_STATUS.claimed, PRINT_JOB_STATUS.printing] },
OR: [
{ lease_expires_at: { lte: now } },
{ lease_expires_at: null }
]
},
data: {
status: PRINT_JOB_STATUS.resultUnknown,
lease_expires_at: null,
error_message: '打印助手失联或旧任务缺少领取凭据,请核实是否已出纸后处理'
}
})
}
export const claimNextPrintJob = async (terminalId: number, protocolVersion?: number) => {
if (protocolVersion !== 2) {
throw new PrintServiceError('请先更新打印助手,再领取打印任务', 426)
}
await expireStalePrintClaims()
const terminal = await getPrintTerminalById(terminalId)
const terminalView = mapTerminalRecord({
...terminal,
@ -1649,6 +1694,10 @@ export const claimNextPrintJob = async (terminalId: number) => {
status: PRINT_JOB_STATUS.claimed,
claimed_by_terminal_id: terminalId,
claimed_at: new Date(),
claim_token: randomUUID(),
lease_expires_at: new Date(Date.now() + PRINT_CLAIM_LEASE_MS),
result_confirmed_by: null,
result_confirmed_at: null,
attempt_count: {
increment: 1
}
@ -1699,12 +1748,16 @@ export const claimNextPrintJob = async (terminalId: number) => {
? `任务成品标签图读取失败,请在系统里重新发送打印:${error.message}`
: '任务成品标签图读取失败,请在系统里重新发送打印'
await prisma.printJob.update({
await prisma.printJob.updateMany({
where: {
id: claimedJob.id
id: claimedJob.id,
status: PRINT_JOB_STATUS.claimed,
claimed_by_terminal_id: terminalId,
claim_token: claimedJob.claim_token
},
data: {
status: PRINT_JOB_STATUS.failed,
lease_expires_at: null,
error_message: errorMessage
}
})
@ -1722,6 +1775,12 @@ export const updateClaimedPrintJobStatus = async (
jobId: number,
input: PrintJobStatusUpdateInput
) => {
if (!(['printing', 'completed', 'failed', 'result_unknown'] as string[]).includes(input.status)) {
throw new PrintServiceError('打印任务状态无效', 422)
}
const claimToken = sanitizeText(input.claim_token)
if (!claimToken) throw new PrintServiceError('缺少领取凭据,请更新打印助手', 422)
await expireStalePrintClaims()
const existing = await prisma.printJob.findUnique({
where: {
id: jobId
@ -1747,14 +1806,23 @@ export const updateClaimedPrintJobStatus = async (
throw new PrintServiceError('该任务不属于当前打印终端', 403)
}
if (existing.claimed_by_terminal_id && existing.claimed_by_terminal_id !== terminalId) {
throw new PrintServiceError('该任务已被其他终端领取', 409)
if (existing.claimed_by_terminal_id !== terminalId || existing.claim_token !== claimToken) {
throw new PrintServiceError('领取凭据已失效,请核实任务结果', 409)
}
const now = new Date()
const errorMessage = sanitizeText(input.error_message)
let data: Prisma.PrintJobUncheckedUpdateInput
// Repeat result submission after a network failure is safe. It never dispatches
// paper again and never lets a late printing/failed callback regress completion.
if (existing.status === input.status && input.status !== PRINT_JOB_STATUS.printing) {
return { job: await mapPrintJobRecord(existing), message: '该结果已接收' }
}
if (!(['claimed', 'printing', 'result_unknown'] as string[]).includes(existing.status)) {
throw new PrintServiceError('当前任务已结束,不能覆盖已有结果', 409)
}
let data: Prisma.PrintJobUpdateManyMutationInput
let message = '状态已更新'
if (input.status === PRINT_JOB_STATUS.printing) {
@ -1767,6 +1835,7 @@ export const updateClaimedPrintJobStatus = async (
data = {
status: PRINT_JOB_STATUS.printing,
started_at: existing.started_at || now,
lease_expires_at: new Date(now.getTime() + PRINT_CLAIM_LEASE_MS),
error_message: null
}
message = '任务已标记为打印中'
@ -1775,7 +1844,7 @@ export const updateClaimedPrintJobStatus = async (
!([
PRINT_JOB_STATUS.claimed,
PRINT_JOB_STATUS.printing,
PRINT_JOB_STATUS.completed
PRINT_JOB_STATUS.resultUnknown
] as string[]).includes(existing.status)
) {
throw new PrintServiceError('当前任务状态不能更新为已完成', 409)
@ -1785,41 +1854,42 @@ export const updateClaimedPrintJobStatus = async (
status: PRINT_JOB_STATUS.completed,
started_at: existing.started_at || now,
completed_at: now,
lease_expires_at: null,
error_message: null
}
message = '任务已完成'
} else {
if (
!([PRINT_JOB_STATUS.claimed, PRINT_JOB_STATUS.printing] as string[]).includes(existing.status)
) {
throw new PrintServiceError('当前任务状态不能更新为失败', 409)
const resultUnknown = input.status === PRINT_JOB_STATUS.resultUnknown ||
existing.status !== PRINT_JOB_STATUS.claimed || Boolean(existing.started_at)
data = {
status: resultUnknown ? PRINT_JOB_STATUS.resultUnknown : PRINT_JOB_STATUS.failed,
lease_expires_at: null,
error_message: errorMessage || (resultUnknown ? '打印结果待核实,请确认是否已出纸' : '打印未开始,任务失败')
}
message = resultUnknown ? '结果待核实,任务不会自动重印' : '任务已标记为失败'
}
const shouldRequeue = Boolean(input.retryable) && existing.attempt_count < MAX_RETRY_ATTEMPTS
if (shouldRequeue) {
data = {
status: PRINT_JOB_STATUS.queued,
claimed_by_terminal_id: null,
claimed_at: null,
started_at: null,
error_message: errorMessage || '打印失败,已重新排队'
}
message = '打印失败,任务已重新排队'
} else {
data = {
status: PRINT_JOB_STATUS.failed,
started_at: existing.started_at || now,
error_message: errorMessage || '打印失败'
}
message = '任务已标记为失败'
}
}
const updated = await prisma.printJob.update({
const updateResult = await prisma.printJob.updateMany({
where: {
id: jobId
id: jobId,
target_terminal_id: terminalId,
claimed_by_terminal_id: terminalId,
claim_token: claimToken,
status: existing.status,
...(input.status === PRINT_JOB_STATUS.printing ? { lease_expires_at: { gt: now } } : {})
},
data,
data
})
if (updateResult.count !== 1) {
// A concurrent identical completion may have won this compare-and-swap.
const current = await prisma.printJob.findUnique({ where: { id: jobId } })
if (!(input.status === PRINT_JOB_STATUS.completed && current?.status === PRINT_JOB_STATUS.completed &&
current.claim_token === claimToken && current.claimed_by_terminal_id === terminalId)) {
throw new PrintServiceError('任务状态已变化,请重新核实结果', 409)
}
}
const updated = await prisma.printJob.findUniqueOrThrow({
where: { id: jobId },
include: {
targetTerminal: {
select: buildTargetTerminalSelection
@ -1833,7 +1903,7 @@ export const updateClaimedPrintJobStatus = async (
}
})
if (updated.status === PRINT_JOB_STATUS.completed || updated.status === PRINT_JOB_STATUS.failed) {
if (updateResult.count === 1 && updated.status === PRINT_JOB_STATUS.completed) {
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(updated.payload_json))
}
@ -1843,6 +1913,55 @@ export const updateClaimedPrintJobStatus = async (
}
}
export const getPrintAgentJobState = async (terminalId: number, jobId: number) => {
await expireStalePrintClaims()
const job = await prisma.printJob.findUnique({ where: { id: jobId } })
if (!job) throw new PrintServiceError('打印任务不存在', 404)
if (job.target_terminal_id !== terminalId) throw new PrintServiceError('任务不属于当前终端', 403)
return { id: job.id, status: job.status, claim_token: job.claim_token }
}
export const resolveUnknownPrintJob = async (
jobId: number,
actorId: number,
input: { confirmed?: boolean; outcome?: string; expected_claim_token?: string | null }
) => {
if (input.confirmed !== true || !['printed', 'not_printed', 'cancel'].includes(input.outcome || '') ||
!Object.prototype.hasOwnProperty.call(input, 'expected_claim_token') ||
(input.expected_claim_token !== null && typeof input.expected_claim_token !== 'string')) {
throw new PrintServiceError('请核实出纸情况后明确选择处理结果', 422)
}
await expireStalePrintClaims()
const existing = await prisma.printJob.findUnique({ where: { id: jobId } })
if (!existing) throw new PrintServiceError('打印任务不存在', 404)
const now = new Date()
const outcome = input.outcome
const nextStatus = outcome === 'printed' ? PRINT_JOB_STATUS.completed :
outcome === 'not_printed' ? PRINT_JOB_STATUS.queued : PRINT_JOB_STATUS.cancelled
const result = await prisma.printJob.updateMany({
where: { id: jobId, status: PRINT_JOB_STATUS.resultUnknown, claim_token: input.expected_claim_token },
data: {
status: nextStatus,
lease_expires_at: null,
result_confirmed_by: actorId,
result_confirmed_at: now,
error_message: outcome === 'not_printed' ? '管理员已核实未出纸,重新排队' : null,
...(outcome === 'printed' ? { completed_at: now } : {
claim_token: null,
claimed_by_terminal_id: null,
claimed_at: null,
started_at: null,
completed_at: null
})
}
})
if (result.count !== 1) throw new PrintServiceError('任务状态已变化,请刷新并重新核实', 409)
if (outcome !== 'not_printed') {
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json))
}
return { id: jobId, status: nextStatus }
}
export const reportPrintAgentTestPrint = async (
terminalId: number,
input: PrintAgentTestPrintInput

View File

@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseBusinessDate } from '../businessDate'
test('legacy local dates are Shanghai time regardless of process timezone, and offset ISO stays exact', () => {
const original = process.env.TZ
try {
for (const timezone of ['UTC', 'Asia/Shanghai', 'America/New_York']) {
process.env.TZ = timezone
assert.equal(parseBusinessDate('2026-10-09T18:30')!.toISOString(), '2026-10-09T10:30:00.000Z')
assert.equal(parseBusinessDate('2026-10-09')!.toISOString(), '2026-10-08T16:00:00.000Z')
assert.equal(parseBusinessDate('2026-10-09T18:30:01+08:00')!.toISOString(), '2026-10-09T10:30:01.000Z')
assert.equal(parseBusinessDate('2026-10-09T10:30:01.123Z')!.toISOString(), '2026-10-09T10:30:01.123Z')
}
} finally {
if (original === undefined) delete process.env.TZ
else process.env.TZ = original
}
})
test('invalid calendar dates, times, unsupported formats and values reject rather than silently erase a deadline', () => {
for (const value of ['2026-02-30', '2026-13-01', '2026-10-09T24:00', '2026-10-09T10:60', '10/09/2026', 'abc', true, 0, {}, '2026-10-09T18:30:00+25:00']) {
assert.throws(() => parseBusinessDate(value), (error: any) => error.status === 422)
}
assert.equal(parseBusinessDate(null), null)
assert.equal(parseBusinessDate(''), null)
})

View File

@ -34,6 +34,7 @@ test('可用和已借出记录应按剩余数量拆分,保证在册总量可
maintenance: 0,
pendingDisposal: 0,
damaged: 0,
lost: 0,
other: 0,
disposed: 0
})
@ -45,6 +46,7 @@ test('可用和已借出记录应按剩余数量拆分,保证在册总量可
{ status: '待报废', total_quantity: 1, remaining_quantity: 1 },
{ status: '已损坏', total_quantity: 1, remaining_quantity: 1 },
{ status: '自定义状态', total_quantity: 1, remaining_quantity: 1 },
{ status: '已丢失', total_quantity: 4, remaining_quantity: 0 },
{ status: '已报废', total_quantity: 5, remaining_quantity: 5 }
])
@ -55,6 +57,7 @@ test('可用和已借出记录应按剩余数量拆分,保证在册总量可
maintenance: 2,
pendingDisposal: 1,
damaged: 1,
lost: 4,
other: 1,
disposed: 5
})

View File

@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { assertHrSyncSourceSafe } from '../hrSyncSafety'
const existing = Array.from({ length: 100 }, (_, index) => `employee-${index}`)
test('HR rejects empty or duplicate identities before a destructive sync', () => {
for (const source of [[], [''], ['employee-1', 'employee-1']]) {
assert.throws(() => assertHrSyncSourceSafe(source, existing), (error: any) => error.status === 409 && /未修改组织或人员/.test(error.message))
}
})
test('HR uses missing existing identities so a same-size replacement does not conceal abnormal deactivation', () => {
assert.doesNotThrow(() => assertHrSyncSourceSafe(existing.slice(20), existing))
assert.throws(() => assertHrSyncSourceSafe(existing.slice(21), existing), /预计停用 21\/100 人/)
const replaced = [...existing.slice(21), ...Array.from({ length: 21 }, (_, index) => `new-${index}`)]
assert.throws(() => assertHrSyncSourceSafe(replaced, existing), /预计停用 21\/100 人/)
})
test('HR protects absolute count and accepts configured validated bounds for legitimate changes', () => {
const large = Array.from({ length: 1000 }, (_, index) => `employee-${index}`)
assert.throws(() => assertHrSyncSourceSafe(large.slice(51), large), /预计停用 51\/1000 人/)
assert.doesNotThrow(() => assertHrSyncSourceSafe(existing.slice(30), existing, { maxRatio: '0.35', maxCount: '40' }))
assert.doesNotThrow(() => assertHrSyncSourceSafe(['first'], []))
for (const settings of [{ maxRatio: '1' }, { maxRatio: 'bad' }, { maxRatio: '-0.1' }, { maxCount: '1.5' }, { maxCount: '-1' }]) {
assert.throws(() => assertHrSyncSourceSafe(existing, existing, settings), /阈值配置无效/)
}
})

View File

@ -0,0 +1,59 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import express from 'express'
import { once } from 'node:events'
import { requirePermission } from '../../middleware/auth'
import { PERMISSIONS } from '../permissions'
import { parseStandardJsonBody, parseStandardFormBody, parsePrintLabelJsonBody } from '../requestBody'
test('HTTP请求体限制保留大标签容量,权限不足时不解析图片JSON', async (t) => {
const app = express()
app.use(parseStandardJsonBody, parseStandardFormBody)
// A local fixture supplies permissions only; no login, database or terminal.
app.use((req, res, next) => {
res.locals.user = { permissions: req.headers['x-test-print'] === 'yes' ? [PERMISSIONS.ASSETS_PRINT_LABELS] : [] }
next()
})
const router = express.Router()
router.post('/asset-labels', requirePermission(PERMISSIONS.ASSETS_PRINT_LABELS), parsePrintLabelJsonBody,
(req, res) => res.json({ length: req.body?.image?.length || 0 }))
app.use('/api/print-jobs', router)
app.post('/api/ordinary', (req, res) => res.json({ length: req.body?.image?.length || 0 }))
app.use((error: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.status || 500).json({ error: error.type })
})
const server = app.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
assert.ok(address && typeof address !== 'string')
const base = `http://127.0.0.1:${address.port}`
const post = (pathname: string, body: string, allowed = true, contentType = 'application/json') => fetch(base + pathname, {
method: 'POST', headers: { 'content-type': contentType, 'x-test-print': allowed ? 'yes' : 'no' }, body
})
const sixMb = JSON.stringify({ image: 'x'.repeat(6 * 1024 * 1024) })
try {
await t.test('普通JSON与表单超过5MB返回413', async () => {
assert.equal((await post('/api/ordinary', sixMb)).status, 413)
assert.equal((await post('/api/ordinary', sixMb, true, 'application/x-www-form-urlencoded')).status, 413)
})
await t.test('没有打印权限时畸形JSON直接403,不先解析成400', async () => {
for (const pathname of ['/api/print-jobs/asset-labels', '/API/PRINT-JOBS/ASSET-LABELS/?source=test', '/api/print-jobs//asset-labels']) {
assert.equal((await post(pathname, '{invalid', false)).status, 403)
}
})
await t.test('授权标签6MB能解析,大小写、尾斜杠和query与Express路由一致', async () => {
for (const pathname of ['/api/print-jobs/asset-labels', '/API/PRINT-JOBS/ASSET-LABELS/?source=test', '/api/print-jobs//asset-labels']) {
const response = await post(pathname, sixMb)
assert.equal(response.status, 200)
assert.equal((await response.json() as { length: number }).length, 6 * 1024 * 1024)
}
})
await t.test('近似路径不能享受大请求例外,授权标签仍有30MB上限', async () => {
assert.equal((await post('/api/print-jobs/asset-labels-extra', sixMb)).status, 413)
assert.equal((await post('/api/print-jobs/asset-labels', JSON.stringify({ image: 'x'.repeat(30 * 1024 * 1024) }))).status, 413)
})
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})

View File

@ -2,7 +2,7 @@ import type { Prisma, PrismaClient } from '@prisma/client'
type EquipmentClient = PrismaClient | Prisma.TransactionClient
const NON_AVAILABLE_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏'])
const NON_AVAILABLE_STATUSES = new Set(['维护中', '待报废', '已报废', '已损坏', '已丢失'])
const toSafeNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') {

View File

@ -0,0 +1,18 @@
// Explicit offsets are preferred. Legacy local inputs are always Shanghai time,
// independent of the backend process timezone.
export const parseBusinessDate = (value: unknown, field = '日期'): Date | null => {
if (value === undefined || value === null || value === '') return null
if (typeof value !== 'string') throw Object.assign(new Error(`${field}格式无效`), { status: 422 })
const input = value.trim()
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?)?$/.exec(input)
if (!match) throw Object.assign(new Error(`${field}格式无效`), { status: 422 })
const [, year, month, day, hour = '00', minute = '00', second = '00', fraction = '', offset] = match
const calendar = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
if (calendar.getUTCFullYear() !== Number(year) || calendar.getUTCMonth() + 1 !== Number(month)
|| calendar.getUTCDate() !== Number(day) || Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) {
throw Object.assign(new Error(`${field}格式无效`), { status: 422 })
}
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}${fraction}${offset || '+08:00'}`)
if (!Number.isFinite(date.getTime())) throw Object.assign(new Error(`${field}格式无效`), { status: 422 })
return date
}

View File

@ -10,6 +10,7 @@ export type InventoryStatusBreakdown = {
maintenance: number
pendingDisposal: number
damaged: number
lost: number
other: number
disposed: number
}
@ -77,6 +78,7 @@ export const getInventoryStatusBreakdown = (
maintenance: 0,
pendingDisposal: 0,
damaged: 0,
lost: 0,
other: 0,
disposed: 0
}
@ -84,6 +86,9 @@ export const getInventoryStatusBreakdown = (
if (status === '已报废') {
return { ...empty, disposed: totalQuantity }
}
if (status === '已丢失') {
return { ...empty, lost: totalQuantity }
}
empty.current = totalQuantity
@ -119,6 +124,7 @@ export const summarizeInventoryStatusRows = (
summary.maintenance += current.maintenance
summary.pendingDisposal += current.pendingDisposal
summary.damaged += current.damaged
summary.lost += current.lost
summary.other += current.other
summary.disposed += current.disposed
return summary
@ -130,6 +136,7 @@ export const summarizeInventoryStatusRows = (
maintenance: 0,
pendingDisposal: 0,
damaged: 0,
lost: 0,
other: 0,
disposed: 0
}

View File

@ -0,0 +1,23 @@
export const assertHrSyncSourceSafe = (
sourceUserIds: string[],
existingActiveSourceUserIds: string[],
settings: { maxRatio?: string; maxCount?: string } = {}
) => {
const fail = (message: string): never => {
throw Object.assign(new Error(`花名册同步已停止:${message};未修改组织或人员,请核实上游数据和筛选规则`), { status: 409 })
}
if (!sourceUserIds.length) fail('源在职人员为空')
const incoming = new Set(sourceUserIds)
if (incoming.size !== sourceUserIds.length || sourceUserIds.some((id) => !id.trim())) {
fail('源人员标识缺失或重复')
}
const maxRatio = settings.maxRatio === undefined ? 0.2 : Number(settings.maxRatio)
const maxCount = settings.maxCount === undefined ? 50 : Number(settings.maxCount)
if (!Number.isFinite(maxRatio) || maxRatio < 0 || maxRatio >= 1
|| !Number.isSafeInteger(maxCount) || maxCount < 0) fail('减员保护阈值配置无效')
const existing = new Set(existingActiveSourceUserIds)
const missing = [...existing].filter((id) => !incoming.has(id)).length
if (missing > maxCount || (existing.size > 0 && missing / existing.size > maxRatio)) {
fail(`预计停用 ${missing}/${existing.size} 人,超过减员保护阈值(${Math.round(maxRatio * 100)}% 或 ${maxCount} 人)`)
}
}

View File

@ -0,0 +1,93 @@
import { inflateSync } from 'zlib'
export const MAX_LABELS_PER_JOB = 100
export const MAX_LABEL_IMAGE_BYTES = 2 * 1024 * 1024
export const MAX_LABEL_JOB_BYTES = 20 * 1024 * 1024
const MAX_LABEL_PIXELS = 4_000_000
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const crc32 = (bytes: Buffer) => {
let crc = 0xffffffff
for (const byte of bytes) {
crc ^= byte
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0)
}
}
return (crc ^ 0xffffffff) >>> 0
}
// Canvas labels are non-interlaced PNGs. Check the container, decoded size and
// scanline filters before persisting untrusted images or handing them to clients.
export const validateLabelPng = (buffer: Buffer) => {
if (buffer.length > MAX_LABEL_IMAGE_BYTES) {
throw new Error('单张标签图片不能超过 2 MB')
}
if (buffer.length < 57 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
throw new Error('标签图片不是有效的 PNG 文件')
}
let offset = 8
let width = 0
let height = 0
let rowBytes = 0
let ended = false
let dataEnded = false
let paletteSeen = false
let colorType = -1
const imageData: Buffer[] = []
while (offset < buffer.length) {
if (offset + 12 > buffer.length) throw new Error('PNG 文件不完整')
const size = buffer.readUInt32BE(offset)
const end = offset + 12 + size
if (end > buffer.length) throw new Error('PNG 文件不完整')
const type = buffer.toString('ascii', offset + 4, offset + 8)
const bytes = buffer.subarray(offset + 8, end - 4)
if (crc32(buffer.subarray(offset + 4, end - 4)) !== buffer.readUInt32BE(end - 4)) {
throw new Error('PNG 文件校验失败')
}
if (offset === 8 && type !== 'IHDR') throw new Error('PNG 缺少图片尺寸信息')
if (type === 'IHDR') {
if (offset !== 8 || size !== 13) throw new Error('PNG 图片头无效')
width = bytes.readUInt32BE(0)
height = bytes.readUInt32BE(4)
const bitDepth = bytes[8]
colorType = bytes[9]
const samples = new Map([[0, 1], [2, 3], [3, 1], [4, 2], [6, 4]]).get(colorType)
const depths = colorType === 0 ? [1, 2, 4, 8, 16] : colorType === 3 ? [1, 2, 4, 8] : [8, 16]
if (!width || !height || width > 4096 || height > 4096 || width * height > MAX_LABEL_PIXELS) {
throw new Error('标签图片尺寸超限,请使用网页生成的标签图')
}
if (!samples || !depths.includes(bitDepth) || bytes[10] !== 0 || bytes[11] !== 0 || bytes[12] !== 0) {
throw new Error('PNG 图片格式不受支持,请使用网页生成的标签图')
}
rowBytes = Math.ceil(width * samples * bitDepth / 8)
} else if (type === 'PLTE') {
if (paletteSeen || imageData.length || size === 0 || size > 768 || size % 3 !== 0) {
throw new Error('PNG 调色板无效')
}
paletteSeen = true
} else if (type === 'IDAT') {
if (dataEnded || (colorType === 3 && !paletteSeen)) throw new Error('PNG 图片数据无效')
imageData.push(bytes)
} else if (type === 'IEND') {
if (size || end !== buffer.length || !imageData.length) throw new Error('PNG 文件结束标记无效')
ended = true
} else {
if (imageData.length) dataEnded = true
if (type[0] === type[0].toUpperCase()) throw new Error('PNG 包含不支持的关键数据块')
}
offset = end
}
if (!ended) throw new Error('PNG 文件不完整')
const expectedBytes = (rowBytes + 1) * height
let decoded: Buffer
try {
decoded = inflateSync(Buffer.concat(imageData), { maxOutputLength: expectedBytes })
} catch {
throw new Error('PNG 压缩数据无效或超过图片尺寸')
}
if (decoded.length !== expectedBytes) throw new Error('PNG 像素数据与尺寸不匹配')
for (let row = 0; row < height; row += 1) {
if (decoded[row * (rowBytes + 1)] > 4) throw new Error('PNG 像素过滤方式无效')
}
}

View File

@ -0,0 +1,60 @@
import type { AssetTreeItem } from '../services/assetMovementService'
type MaintenanceSnapshotItem = {
id: number
parent_id: number | null
status: string
total_quantity: number
remaining_quantity: number
}
export class MaintenanceSnapshotConflictError extends Error {
status = 409
}
export const buildMaintenanceSnapshot = (tree: AssetTreeItem[]) => JSON.stringify({
version: 1,
assets: tree.map(({ id, parent_id, status, total_quantity, remaining_quantity }) => ({
id, parent_id, status, total_quantity, remaining_quantity
}))
})
export const validateMaintenanceSnapshot = (
rawSnapshot: string | null,
tree: AssetTreeItem[],
rootId: number
): MaintenanceSnapshotItem[] => {
const invalidSnapshot = () => new MaintenanceSnapshotConflictError('维修单缺少有效的送修前快照,请人工核实原状态后处理')
let snapshot: any
try {
snapshot = JSON.parse(rawSnapshot || '')
} catch {
throw invalidSnapshot()
}
if (snapshot?.version !== 1 || !Array.isArray(snapshot.assets) || snapshot.assets.length === 0) {
throw invalidSnapshot()
}
const assets = snapshot.assets as MaintenanceSnapshotItem[]
const ids = new Set<number>()
for (const item of assets) {
if (!item || !Number.isSafeInteger(item.id) || item.id <= 0 || ids.has(item.id)
|| !(item.parent_id === null || (Number.isSafeInteger(item.parent_id) && item.parent_id > 0))
|| !['可用', '已损坏'].includes(item.status)
|| !Number.isSafeInteger(item.total_quantity) || item.total_quantity < 0
|| !Number.isSafeInteger(item.remaining_quantity) || item.remaining_quantity < 0
|| item.remaining_quantity > item.total_quantity) {
throw invalidSnapshot()
}
ids.add(item.id)
}
if (!assets.some((item) => item.id === rootId && item.parent_id === null)) throw invalidSnapshot()
if (assets.length !== tree.length || tree.some((current) => {
const previous = assets.find((item) => item.id === current.id)
return !previous || previous.parent_id !== current.parent_id || current.status !== '维护中'
|| previous.total_quantity !== current.total_quantity
|| previous.remaining_quantity !== current.remaining_quantity
})) {
throw new MaintenanceSnapshotConflictError('资产树状态、库存或挂载关系已变化,请人工核实后处理')
}
return assets
}

View File

@ -0,0 +1,15 @@
import express, { type RequestHandler } from 'express'
const parseStandardJson = express.json({ limit: '5mb' })
// Only the label endpoint needs space for base64 PNGs. Its router parses JSON
// after authentication and the existing print permission guard.
export const parseStandardJsonBody: RequestHandler = (req, res, next) => {
if (req.method === 'POST' && /^\/api\/print-jobs\/{1,2}asset-labels\/?$/i.test(req.path)) {
return next()
}
return parseStandardJson(req, res, next)
}
export const parseStandardFormBody = express.urlencoded({ extended: true, limit: '5mb' })
export const parsePrintLabelJsonBody = express.json({ limit: '30mb' })

View File

@ -0,0 +1,52 @@
import { createHash } from 'crypto'
import { buildRecursiveBorrowSnapshot, type AssetTreeItem } from '../services/assetMovementService'
type BorrowSnapshotRecord = {
id: number
equipment_id: number
children_snapshot?: string | null
updated_at?: Date
}
export const getReturnChildrenDiff = (items: AssetTreeItem[], record: BorrowSnapshotRecord) => {
const current = buildRecursiveBorrowSnapshot(items)
let borrowed: Array<{ id: number; parent_id?: number | null; parent_equipment_id?: number | null; [key: string]: unknown }> = []
if (record.children_snapshot) {
try {
borrowed = JSON.parse(record.children_snapshot)
if (!Array.isArray(borrowed) || borrowed.some((item) => !item || !Number.isSafeInteger(item.id) || item.id <= 0)
|| new Set(borrowed.map((item) => item.id)).size !== borrowed.length) throw new Error()
} catch {
throw Object.assign(new Error('借用记录的配件快照无效,请人工核实后归还'), { status: 409 })
}
}
const placement = (item: { parent_id?: number | null; parent_equipment_id?: number | null }) =>
item.parent_id ?? item.parent_equipment_id ?? items[0].id
const previousById = new Map(borrowed.map((item) => [item.id, item]))
const currentById = new Map(current.map((item) => [item.id, item]))
const removed = borrowed.filter((item) => {
const next = currentById.get(item.id)
return !next || placement(item) !== placement(next)
})
const added = current.filter((item) => {
const previous = previousById.get(item.id)
return !previous || placement(previous) !== placement(item)
})
return removed.length || added.length ? { removed, added } : null
}
export const buildReturnConfirmationToken = (items: AssetTreeItem[], record: BorrowSnapshotRecord) =>
createHash('sha256').update(JSON.stringify({
borrow_record: {
id: record.id,
equipment_id: record.equipment_id,
updated_at: record.updated_at?.getTime(),
children_snapshot: record.children_snapshot ?? null
},
asset_tree: items.map((item) => ({
id: item.id, parent_id: item.parent_id, department_id: item.department_id,
status: item.status, mount_status: item.mount_status,
total_quantity: item.total_quantity, remaining_quantity: item.remaining_quantity,
unit_price: item.unit_price.toString(), updated_at: item.updated_at.getTime()
}))
})).digest('hex')

View File

@ -0,0 +1,75 @@
# 2026年10月9日 固定资产系统问题修复与验证记录
本轮已在本地修复原审计第 2–9、13–20 项业务与使用流程问题,并处理第 26 项中可兼容升级的应用依赖。前后端、两种打印助手和隔离 MySQL 回归通过。以下为提交前的本地验收记录;记录时尚未提交或发布,该修复阶段未连接生产、修改真实资产、重发通知或操作实体打印机。
按用户要求,账号安全、发布与备份相关整改不做。本轮开始时记录的 12 个认证、部署及配置文件 SHA256 在结束时一致;已有工作区改动保留。原始问题及当时生产证据见[审查清单](<D:/AI Code/GuDingZiChan/docs/2026-10-09_项目问题与隐患审查.md>),不能把本地修复视为生产已经生效。
## 已修复行为
| 原审计项 | 修复结果 |
| --- | --- |
| 2 详情错位 | 切换资产时重建详情页面,异步请求按当前操作范围校验;旧请求不能覆盖新资产或已关闭的归还弹窗。资料未加载完成时不能提交操作。 |
| 3 损坏、丢失归还 | 损坏归还保留实物库存,但状态为“已损坏”、可用价值为 0;丢失归还记“已丢失”和 loss 流水,剩余数量为 0。普通与批量归还一致,统计单独列出损失数量。已丢失资产不能通过调拨、报废或修改数量恢复可用状态。 |
| 4–5 维修树与并发 | 未结束维修期间禁止挂载、卸载。完成与取消维修使用维修单和整树锁,并核对关系、状态、版本;竞争操作只能有一方成功,不覆盖另一方已经改变的状态。 |
| 6 HR 同步 | 写入组织和人员前拒绝空源、缺失或重复人员标识、异常减员;同一进程不能重叠执行同步。默认停用超过现有同步人数的 20% 或超过 50 人即停止,阈值配置无效也停止。 |
| 7–9 打印一致性与恢复 | 每次领取获得独立凭据和租约,状态更新采用条件写入。过期任务进入“结果待核实”,不自动重印;完成结果可以幂等补报,旧凭据和晚到失败不能覆盖完成或新派单。Electron 和 .NET 助手在出纸前持久化阶段,重启只对账,不再次执行未核实任务。 |
| 13 会话过期循环 | 获取用户失败时同步清理 Pinia 和持久化认证状态,稳定进入登录页,不再与首页循环。后端登录、密码和会话撤销策略未改。 |
| 14 跨页选择 | 缓存全部选中资产,跨页借还不遗漏前页选择;单项操作只移除成功项,批量失败项继续保留供处理。 |
| 15 时间偏移 | 浏览器提交带时区的 ISO 时间;后端兼容历史无时区输入,并按上海时区解析,避免 UTC 服务器造成 8 小时偏移。 |
| 16 Excel 导入 | 保留工作表实际行号,包括非 A1 起始区域及空行;数量 0、非数字和非法价格逐行报错,不默认为 1 或在序列化时变成 0。 |
| 17–18 盘点状态与计数 | 修改明细、分配和更新汇总在同一事务中先锁定任务;已关闭任务拒绝再分配,并发扫描和管理员核验不会丢失汇总或审计动作。 |
| 19 配件归还确认 | 借出时为空的配件集合仍与当前树比对;归还确认绑定当前树和版本,确认后树发生变化会返回冲突,不能沿用旧确认。各普通归还页面都处理确认流程。 |
| 20 标签图片与请求大小 | 校验 PNG 签名、块顺序、CRC、尺寸、解压字节数和像素过滤格式;每任务最多 100 张,单图最多 2 MiB,总图最多 20 MiB。普通 JSON/form 最多 5 MiB;标签 JSON 在现有登录与打印权限检查后解析,上限 30 MiB,容纳 base64 开销。 |
多数量资产的损坏、丢失归还若无法用现有单一状态准确表达,会返回 409 并保留原记录,要求单独核实,避免把部分损坏的资产误记为整批可用。丢失资产的历史购入成本继续保留,实物数量与可用价值排除损失,页面注明统计口径。
打印管理员处理“结果待核实”时,必须选择已出纸、未出纸或取消,并勾选已核实;没有预选结果。未出纸才允许重新派单。未知结果保留原 PNG,新派单换领取凭据;完成或取消后才清理图片。客户端校验服务器返回的任务 ID、凭据和状态,409、空响应或未知状态都不会直接丢弃恢复记录。Electron 同时只允许一个应用实例运行。
HR 阈值可通过 `HR_SYNC_MAX_DEACTIVATION_RATIO` 和 `HR_SYNC_MAX_DEACTIVATION_COUNT` 配置。当前重叠保护仅限同一进程;本轮没有引入多实例调度或分布式锁。
## 验证结果
| 验证 | 结果与覆盖范围 |
| --- | --- |
| 前后端 `npm run build` | 均通过;前端仍有统计相关 chunk 约 1.13 MB 的构建提示,未据此认定实际页面卡顿。 |
| 后端全套测试 | 285 项通过,2 个实库套件默认跳过;常规测试使用不可用的本机数据库地址,避免误连真实库。 |
| 本机 MySQL 打印回归 | 10/10 通过:竞争领取、重复完成、旧凭据、租约失效、部分出纸未知、人工核实竞争、PNG 保留与清理、真实 `GET /api/health`。 |
| 本机 MySQL 业务回归 | 5/5 通过:连接范围门禁、并发盘点与核验、关闭后分配拒绝、维修完成/取消竞争 4 轮、损坏与丢失归还。 |
| 前端专项回归 | 24/24 通过:路由、请求竞态、跨页选择、会话失效、时间、导入与归还确认,以及既有借还和标签展示。 |
| 请求体真实 HTTP 回归 | 5/5 通过:普通 JSON/form 大小拒绝、权限检查先于标签解析、授权大标签、大小写/尾斜杠/中间双斜杠/查询串,以及近似路径与打印上限。已计入后端全套数量。 |
| Electron 助手 | 29/29 通过,覆盖持久化恢复、回写断网、部分打印失败和无效服务器回读。 |
| .NET 助手 | 实际 Windows 项目构建 0 警告、0 错误;7 组恢复测试通过。 |
| Prisma | 客户端生成通过;新增打印迁移 SQL 在独立测试库基线执行成功,健康检查读取新增业务列成功。 |
| 浏览器 | 使用本机模拟数据操作真实前端:跨页两项均进入批量弹窗,打印核实必须主动选择并确认,390px 手机视口弹窗可用,丢失资产禁调拨,401 后稳定进入登录页。未向生产提交任何动作。 |
| 工作区检查 | `git diff --check` 通过;排除的 12 个文件与本轮开始时哈希相同。 |
两套实库测试显式启用才运行,连接限定本机专用端口和 `equipment_audit` 数据库,拒绝其他目标和连接参数覆盖。测试样本及衍生记录已按确切 ID 清理并核查零残留;本轮创建的数据库容器、浏览器页和模拟服务已移除或停止。
前端回归在 `frontend` 目录执行:
```powershell
node --test scripts/test-audit-functional-fixes.ts scripts/test-audit-routing.mjs scripts/test-borrow-scan-state.ts scripts/test-return-scan-state.ts scripts/test-notification-quick-return.ts scripts/test-phase-two-presentation.ts scripts/test-local-batch-print.ts
```
后端全部 `src/**/*.test.ts` 使用 `ts-node/register` 执行;实库测试分别通过 `RUN_PRINT_DB_TESTS=true`、`RUN_BUSINESS_DB_TESTS=true` 启用。Electron 使用 `npm test`,.NET 使用 `dotnet build` 和 `RecoveryTests` 控制台项目。
## 依赖处理与保留边界
前端生产依赖审计由 10 项包级告警降至 0;`xlsx` 按 [SheetJS 官方安装说明](https://docs.sheetjs.com/docs/getting-started/installation/nodejs/)切换到官方 0.20.3 分发包。后端在现有兼容范围内更新,删除无使用入口的 `multer` 及其类型包;ExcelJS 的 uuid 子依赖固定到修复版本 11.1.1,并验证 XLSX 写入、读取和条件格式往返。[uuid 安全公告](https://github.com/advisories/GHSA-w5hq-g745-h8pq)
后端生产依赖告警由 27 项降至 11 项,不能宣称清零:
- 8 项属于保留的 Keycloak 认证依赖链,含 6 项 high、2 项 low;本轮不做认证链跨主版本调整。
- 3 项 moderate 来自 `sprintf-js → tedious → mssql` 同一依赖链。[上游公告](https://github.com/advisories/GHSA-hp3w-g68c-fv3c)尚无修复版本。已检查当前 SQL 驱动内使用点均为固定格式串,没有发现本项目把不可信数据用作格式串的入口;这不等于依赖告警消失。
第 26 项因此仅完成应用依赖中已验证的兼容修复。Node 容器运行时及容器资源、日志配置属于用户排除的发布配置,保持原状。
账号登录限流、密码强度、改密撤销会话、Keycloak 身份映射、旧盘点链接身份策略、打印 HTTP 长期令牌限制,以及数据库账号和端口、发布回退、备份内容/权限/保留策略均未整改。对应原清单第 1、10–12、21–25 项及 3 个条件性安全风险继续保留。
## 后续使用条件
新增打印字段位于[增量迁移](<D:/AI Code/GuDingZiChan/backend/prisma/migrations/20261009_print_claim_recovery/migration.sql>)。未来上线需要数据库迁移和支持协议 2 的新版助手配套;旧助手领取任务会得到更新提示。此次只完成源码、构建和测试,没有发布安装包或部署服务。
本机验证了新增增量 SQL,未证明现有历史迁移能从空库完整部署;早期迁移依赖预先存在的基础表,该发布基线缺口按用户要求保留。历史维修没有可靠快照时仍拒绝猜测还原;旧借用配件快照按既有空集合语义比较当前树,不凭空补造历史配件。
尚未验收实体打印出纸、真实用户登录流程和生产流量表现。当前恢复设计减少重复出纸风险,但打印机提交与服务端写入不能形成同一事务;“结果待核实”仍需要核实实物后决定,不能承诺实体打印恰好一次。

View File

@ -0,0 +1,139 @@
# 2026年10月9日 固定资产系统问题与隐患审查
本轮列出 26 项整改问题:11 项 P1、15 项 P2,另有 3 项依赖配置或历史数据的条件性风险。优先处理误操作资产、虚假可用库存、HR 空源停用、打印状态一致性,以及本地修复未上线的问题。
代码基准为 `main@843babd` 加当前未提交工作区,包含 10 月 7 日的本地整改。生产证据采自 10 月 9 日固定资产系统的只读核查。本轮新增审计文档,未修改业务代码、数据库数据或部署,未补发消息。
P1 表示可能造成错误资产操作、数据损坏、核心流程中断或明显安全风险,应优先整改;P2 表示影响一致性、安全、恢复能力和使用体验。代码缺陷与“已经在线上造成损失”分别判断,后者不能仅凭 mock 推断。
## 当前生产状态
| 项目 | 10 月 9 日核查结果 | 含义 |
| --- | --- | --- |
| 维修快照 | `maintenance_record.original_tree_snapshot` 列不存在 | 本地快照整改尚未在生产生效 |
| 健康检查 | 已部署文件中没有新 `healthController.js`;健康接口仍固定返回 `Server is running` | HTTP 200 不能证明业务表、迁移和数据库可用 |
| 数据库 | 应用使用 root;3306 绑定宿主机 IPv4 和 IPv6 全网卡 | 最小权限和端口收敛尚未完成;当前电脑直连 3306 超时,不能据此断言公网已开放或所有来源都被阻断 |
| 运行时 | Node.js `v20.20.2`,进程 UID 为 0,时区 UTC,MySQL 默认隔离级别 `REPEATABLE-READ` | 日期偏移条件已具备;并发审查需考虑旧快照;运行账号权限过大 |
| 通知 | 通知中心 70 条已读、12 条未读,没有 failed;另有 5 条旧通道停用记录 | 暂无新的通知中心失败;历史旧通道记录不应当作需要补发的新故障 |
| 证书 | 严格 TLS 校验通过,到期为北京时间 2027 年 3 月 23 日 07:59:59 | 上次证书替换有效,当前没有过期问题 |
| 打印 | 162 条完成、15 条排队,最早排队任务创建于 3 月 30 日 | 存在长期积压;排队状态本身不证明已领取任务卡死 |
| 备份权限 | 最新标准备份目录及所有祖先目录为 755,数据库和上传归档为 644 | 同服务器其他本地账号可读取备份 |
生产当前不使用公开默认数据库密码。本轮不把上次已消除的默认密码问题再次认定为现存事实。
## P1 问题
### 1 维修和健康检查的本地整改尚未上线
生产没有维修快照字段,也没有新健康检查控制器;已部署维修、报废控制器与本地构建文件的 SHA256 不同。本地构建通过并不等于生产使用了这套代码,至少维修快照及真实健康检查仍未生效。
应把已完成的本地修复纳入可审查的发布版本,执行备份、迁移预检、增量迁移和发布验收。历史维修记录不能推测回填快照。相关文件:[维修控制器](<D:/AI Code/GuDingZiChan/backend/src/controllers/maintenanceController.ts:375>)、[健康控制器](<D:/AI Code/GuDingZiChan/backend/src/controllers/healthController.ts:1>)、[快照迁移](<D:/AI Code/GuDingZiChan/backend/prisma/migrations/20261007_maintenance_original_tree_snapshot/migration.sql:1>)。
### 2 切换资产详情后显示旧资产却操作新资产
详情只在 `onMounted` 加载,没有监听路由 ID;同组件复用时,页面继续显示资产 A,删除和维修却取新 URL 中资产 B 的 ID。顶部全局搜索从 A 进入 B 即可触发,存在删除错误资产、错误登记维修和保存错位资料的风险。
Vue 内存路由复现结果:URL 为 `/equipment/edit/2`,显示 ID 为 1,删除目标为 2,加载次数仍为 1。应在 ID 变化时重载并清空旧状态,操作目标与当前展示对象保持一致。[详情加载](<D:/AI Code/GuDingZiChan/frontend/src/views/equipment/EquipmentForm.vue:1312>)、[删除目标](<D:/AI Code/GuDingZiChan/frontend/src/views/equipment/EquipmentForm.vue:1251>)、[路由容器](<D:/AI Code/GuDingZiChan/frontend/src/layouts/MainLayout.vue:124>)。
### 3 损坏或丢失归还仍增加可用库存
归还状况只记在借用单,库存更新统一加 1 并恢复“可用”,没有让损坏或丢失进入对应资产状态。内存 mock 中,两种归还均成功,可用库存变为 1、可用价值恢复,借用单却仍记录损坏或丢失。
应先确定损坏、丢失的正式业务状态,再统一普通和批量归还规则;不能用备注替代库存状态。[归还状态计算](<D:/AI Code/GuDingZiChan/backend/src/services/assetMovementService.ts:824>)、[普通归还](<D:/AI Code/GuDingZiChan/backend/src/controllers/borrowController.ts:335>)、[批量归还](<D:/AI Code/GuDingZiChan/backend/src/controllers/batchController.ts:78>)。
### 4 维修中卸载原损坏配件会直接变可用
“维护中”父资产允许卸载,卸载后的子树只能设置为“可用”,没有核查未完成维修单及送修前状态。原损坏配件整树送修后被拆下,会绕过维修成为可借资产;之后取消维修又因树关系变化返回 409。
内存 mock 已复现父资产仍维护中、配件脱离后可用。应限制维修期间的资产树变更,或建立可审计的维修拆装流程并保留配件状态。[允许状态](<D:/AI Code/GuDingZiChan/backend/src/controllers/mountController.ts:20>)、[拆下后状态](<D:/AI Code/GuDingZiChan/backend/src/controllers/mountController.ts:38>)、[卸载入口](<D:/AI Code/GuDingZiChan/backend/src/controllers/mountController.ts:294>)。
### 5 完成维修可能覆盖已卸载并重新借出的配件
完成维修没有锁定整棵资产树,随后对子项仅按 ID 覆盖状态。在可重复读事务先建立快照、另一事务卸载并重新借出配件的交错下,完成事务可能从旧快照读出原配件,将其覆盖为可用,而新借用单仍未归还。
已用内存 mock 模拟该交错;生产默认隔离级别确为 `REPEATABLE-READ`,但尚未做隔离实库并发验证。应采用一致的整树锁顺序,并在写入时核对关系、状态和版本。[维修完成](<D:/AI Code/GuDingZiChan/backend/src/controllers/maintenanceController.ts:300>)、[子项无条件更新](<D:/AI Code/GuDingZiChan/backend/src/utils/assetValue.ts:171>)。
### 6 HR 成功空源可能停用全部同步人员
源查询成功但人员结果为空时,流程仍用 `source_userid notIn []` 停用所有同步人员,再删除没有借用或盘点历史的人员。上游 ETL 空窗、错误筛选和异常数据都可能被误认为人员整体离职。
原函数内存调用已捕获空源仍执行停用和清理的条件;未据此认定线上已经发生。应在写入前校验源完整性、空集和人数骤减,并把大批停用作为需要复核的变更。[批量停用](<D:/AI Code/GuDingZiChan/backend/src/services/hrSyncService.ts:1664>)、[执行顺序](<D:/AI Code/GuDingZiChan/backend/src/services/hrSyncService.ts:1838>)、[清理删除](<D:/AI Code/GuDingZiChan/backend/src/services/hrSyncService.ts:1267>)。
### 7 打印状态并发回写可把完成任务重新排队
打印回写先读取并检查状态,随后只按任务 ID 更新。完成与可重试失败同时读到 `printing` 时,完成先提交,失败仍可把任务改回 `queued`;完成路径还可能已清理标签图片。
mock 最终状态为 `queued`,两次更新条件都只有 ID。应使用包含原状态、领取终端和版本的条件更新,保持完成状态不可倒退,并协调图片清理。[回写入口](<D:/AI Code/GuDingZiChan/backend/src/services/printService.ts:1725>)、[更新条件](<D:/AI Code/GuDingZiChan/backend/src/services/printService.ts:1818>)。
### 8 打印领取后缺少失联恢复
领取后终端断网或崩溃,任务停在 `claimed/printing`;领取入口只读取 `queued`,管理员也只能取消 `queued`,没有租约到期回收或领取任务的人工核实入口。
应增加领取租约和心跳、失联状态及人工恢复入口。对可能已经出纸的任务,恢复时必须区分结果未知和明确未打印,避免盲目重新排队。[领取](<D:/AI Code/GuDingZiChan/backend/src/services/printService.ts:1614>)、[取消限制](<D:/AI Code/GuDingZiChan/backend/src/services/printService.ts:1390>)、[任务模型](<D:/AI Code/GuDingZiChan/backend/prisma/schema.prisma:234>)。
### 9 .NET 打印助手把结果回写失败误报为打印失败
实际打印和 `completed` 请求位于同一个 try 中。打印已提交,但完成回写请求未到服务器时,catch 又上报 `failed`,使“已打印但回写未知”变成“打印失败”;管理员补发可能重复出纸。
应分别处理实际打印与状态回写,持久化待回写结果,重启后继续对账。[打印与完成回写](<D:/AI Code/GuDingZiChan/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentRuntime.cs:365>)、[失败回写](<D:/AI Code/GuDingZiChan/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentRuntime.cs:381>)。此项为代码控制流证据,未用实体打印机触发。
### 10 本地登录缺少限流且允许极弱密码
应用的本地登录没有账号或 IP 的失败计数、退避和锁定;后端创建及重置密码只要求非空,单字符密码可被接受。生产当前使用 local 模式,因此该登录路径启用;是否另有外围 WAF 限流没有核实。
应在应用层建立密码强度规则及登录限流,保留审计,同时避免把攻击者的失败尝试变成永久锁死正常账号。[登录](<D:/AI Code/GuDingZiChan/backend/src/routes/auth.ts:49>)、[重置校验](<D:/AI Code/GuDingZiChan/backend/src/controllers/userController.ts:461>)。
### 11 发布覆盖旧源码后才做部署备份
发布脚本先删除并替换服务端 backend、frontend、scripts 和配置,再调用 deploy.sh 中的“发布前备份”。因此标准备份保存的是新配置,旧源码已被删除;后续构建或上线失败没有自动回到上一版本的步骤。
应在覆盖前保存旧版本和配置,保留可回退的镜像标识,再采用原子切换;发布失败需有明确恢复路径。[先覆盖文件](<D:/AI Code/GuDingZiChan/scripts/publish-to-server.ps1:269>)、[后调用部署](<D:/AI Code/GuDingZiChan/scripts/publish-to-server.ps1:315>)、[备份配置内容](<D:/AI Code/GuDingZiChan/scripts/backup-equipment.sh:104>)。
## P2 问题
| 编号 | 问题与影响 | 证据与整改方向 |
| --- | --- | --- |
| 12 | 重置密码不撤销旧会话;被盗会话可在改密后继续使用 | [密码更新](<D:/AI Code/GuDingZiChan/backend/src/controllers/userController.ts:470>)仅改密码,[会话认证](<D:/AI Code/GuDingZiChan/backend/src/middleware/auth.ts:27>)不校验认证版本。mock 确认旧会话仍通过。引入认证版本或按用户撤销会话 |
| 13 | 会话过期后,前端在登录与首页之间循环 | [路由守卫](<D:/AI Code/GuDingZiChan/frontend/src/router/index.ts:183>)捕获失败但不清 Pinia 认证状态。真实守卫提取复现循环。统一 401 和会话失效的清理流程 |
| 14 | 跨页批量借出和归还静默漏掉前页所选资产 | [编码查询](<D:/AI Code/GuDingZiChan/frontend/src/views/equipment/EquipmentList.vue:1437>)只查当前页,选中 ID 却跨页保留。源码提取复现只提交第 2 页资产。维护完整选中对象或按 ID 批量获取 |
| 15 | 预计归还时间在生产偏移 8 小时,影响逾期判断 | [前端提交](<D:/AI Code/GuDingZiChan/frontend/src/views/borrow/BorrowScan.vue:566>)传无时区日期,[后端解析](<D:/AI Code/GuDingZiChan/backend/src/controllers/borrowController.ts:35>)按服务器时区解释。生产为 UTC;北京时间 12 点会保存为 12:00Z,回显 20 点。提交含明确时区的 ISO 时间 |
| 16 | Excel 数量 0 或非数字被改成 1,错误行被丢弃且行号错位 | [导入转换](<D:/AI Code/GuDingZiChan/frontend/src/views/equipment/EquipmentList.vue:1531>)使用默认数量和 filter。源码提取复现 3 行仅提交 2 行。保留原始行号,逐行报告错误,禁止静默改数 |
| 17 | 已关闭盘点任务仍可分配责任人,改动已结束结果 | [分配入口](<D:/AI Code/GuDingZiChan/backend/src/services/inventoryService.ts:1743>)查询任务仅选 ID,未核查状态。closed 任务 mock 分配成功。事务内校验并锁定任务状态 |
| 18 | 不同参与人并发盘点会覆盖汇总计数 | [汇总](<D:/AI Code/GuDingZiChan/backend/src/services/inventoryService.ts:704>)先聚合后覆盖,[调用](<D:/AI Code/GuDingZiChan/backend/src/services/inventoryService.ts:1524>)缺少任务级串行保护。mock 明细 checked=2 而汇总为 1;尚未隔离实库验证。采用一致锁序与正确隔离读,或安全增量计数 |
| 19 | 借出时无配件的普通归还跳过后来新增配件的确认 | [差异检查](<D:/AI Code/GuDingZiChan/backend/src/controllers/borrowController.ts:274>)遇 null 快照直接认为无差异。mock 更新根和新配件却不要求确认。区分可靠空集合与缺失快照,对当前资产树做完整比对 |
| 20 | 打印图片只校验 Data URL,缺少真实 PNG、尺寸和单任务数量限制 | [校验](<D:/AI Code/GuDingZiChan/backend/src/services/printService.ts:495>)接受伪 PNG,后续落盘;[请求解析](<D:/AI Code/GuDingZiChan/backend/src/index.ts:61>)在认证前允许 50 MB。应限制解码字节、像素和标签数量,并适度限制公开端点请求体 |
| 21 | 数据库运行账号权限过大,生产端口仍绑定全网卡 | [Compose](<D:/AI Code/GuDingZiChan/docker-compose.yml:32>)仍使用 root;生产实际映射 3306 与 3000。应用故障或入侵会扩大数据库影响面;公网直连未证实。分离迁移与运行账号,取消无必要端口暴露 |
| 22 | 生产数据库及上传备份可被其他本地账号读取 | 最新标准备份数据文件 644,全部父目录 755;[备份脚本](<D:/AI Code/GuDingZiChan/scripts/backup-equipment.sh:88>)无限制 umask。设置目录 700、文件 600,核查并修正存量归档权限 |
| 23 | 标准备份不足以独立恢复整套服务 | [备份内容](<D:/AI Code/GuDingZiChan/scripts/backup-equipment.sh:104>)不含环境配置、证书、对应版本源码或镜像。只持有标准备份时不能独立重建服务。分开保管加密配置和证书,记录版本及镜像,并做隔离恢复演练 |
| 24 | KEEP_BACKUPS=0 被接受并删除全部标准备份,包括刚生成的一份 | [参数校验](<D:/AI Code/GuDingZiChan/scripts/backup-equipment.sh:25>)接受 0,[清理](<D:/AI Code/GuDingZiChan/scripts/backup-equipment.sh:45>)删除全部。纯 mock 替换 rm 后已复现。要求正整数并在清理前确认至少保留一份完整备份 |
| 25 | 容器缺少内存限制和日志轮转,可能耗尽共享主机资源 | 生产 equipment 容器 Memory=0,json-file 日志配置为空;[Compose](<D:/AI Code/GuDingZiChan/docker-compose.yml:1>)没有资源或日志限制。应按实际容量配置限额和 max-size/max-file,并监测磁盘;本轮未证明已经耗尽 |
| 26 | 依赖与运行时需要安全更新 | 本地生产依赖扫描后端报告 27 项包级告警、前端 10 项;告警数不等于可利用漏洞数。xlsx 0.18.5 在实际导入路径被使用,生产 Node 20 已处于官方 EOL。需逐条判断可达性、升级并回归;不要盲目采用 audit 建议的跨主版本降级 |
第 26 项的直接证据:[xlsx 锁定版本](<D:/AI Code/GuDingZiChan/frontend/package-lock.json:3958>)、[实际读取文件](<D:/AI Code/GuDingZiChan/frontend/src/views/equipment/EquipmentList.vue:1513>)。SheetJS 官方确认读取特制文件时的[原型污染](https://cdn.sheetjs.com/advisories/CVE-2023-30533)及[正则拒绝服务](https://cdn.sheetjs.com/advisories/CVE-2024-22363)问题覆盖该版本。Node.js 官方[版本状态表](https://nodejs.org/en/about/previous-releases)已将 Node 20 标为 EOL。
## 条件性风险
1. **Keycloak 用户名映射可能继承本地高权限身份。** [身份映射](<D:/AI Code/GuDingZiChan/backend/src/utils/authIdentity.ts:93>)按用户名匹配,没有绑定 issuer 和 subject。只有启用 Keycloak 且非可信用户能注册或改成对应本地用户名时才成立。当前生产是 local 模式,不能说线上已发生该越权。
2. **旧 legacy 盘点共享链接可选择他人身份。** [旧身份确认](<D:/AI Code/GuDingZiChan/backend/src/services/inventoryService.ts:1404>)依赖 token 和参与人 ID。当前生产唯一 legacy 任务已关闭,活跃任务为 dingtalk 模式;若将历史任务重新开放或恢复为可操作状态,需要先迁移身份验证。
3. **打印助手允许 HTTP 携带长期令牌。** [Electron 地址配置](<D:/AI Code/GuDingZiChan/print-agent/src/config/storage.js:32>)和 [.NET 地址配置](<D:/AI Code/GuDingZiChan/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentRuntime.cs:482>)未强制 HTTPS,客户端随后附带 Bearer。纯 mock 已证实此行为;默认 HTTPS 不触发,当前实际终端配置未核实。生产地址应拒绝 HTTP,开发例外单独控制。
## 修复顺序与验收
1. 先处理资产详情错位、损坏或丢失归还、维修拆装与完成并发、HR 空源保护。验收必须覆盖错误资产 ID、损坏库存、树关系变更、并发借出以及空源和人数骤减。
2. 修正打印回写条件、失联恢复和 .NET 结果回写,区分已打印、未打印、结果未知;增加持久化恢复和重复上报场景。
3. 补齐密码规则、登录限流及改密撤销会话,同时修复前端 401 循环。
4. 收敛备份权限、修正覆盖前备份和回退流程,再把已有本地维修、权限、健康检查及部署整改发布到生产。新增数据库字段走增量迁移,不自动猜测旧记录。
5. 处理跨页选择、日期时区、导入错误行、盘点状态和计数,以及依赖与容器资源配置。并发问题在隔离 MySQL 环境验证,不向生产注入测试数据。
## 已执行验证及范围
- 前端 `npm run build`、后端 `npm run build` 均通过。
- 后端现有 251 项测试通过;执行时数据库地址设为不可用的本机审计地址,避免连接真实数据库。
- Electron 打印助手现有 18 项测试通过。
- 备份保留、报废迁移预检、数据库部署配置 3 个 shell 测试脚本分别通过。
- 资产路由复用、跨页选择、认证循环、导入转换,以及归还、维修、HR、打印、盘点相关触发路径使用源码提取或内存 mock 验证。并发 mock 不能替代隔离实库验证,具体限制已随条目标明。
- 生产只读核查限定固定资产目录、equipment 容器及 equipment_management 数据库,未操作同机其他系统。
本轮未完成实体打印验收、带真实登录态的浏览器流程验收和隔离实库并发压测。现有测试通过说明已覆盖场景正常,不能排除清单中的未覆盖分支。前端构建中统计相关共享 chunk 约 1.12 MB,移动端加载性能也值得进一步测量;构建体积本身不证明页面已经卡顿。

View File

@ -1,9 +1,11 @@
{
"version": "0.2.6",
"published_at": "2026-03-28T23:59:00+08:00",
"version": "0.2.7",
"published_at": "2026-10-09T13:58:38+08:00",
"filename": "print-agent-setup.exe",
"notes": "打印助手最新稳定版,已修复远程批量打印时网页成品标签图没有被打印助手实际使用的问题;现在优先直接打印网页最终 PNG,批量标签的版式与网页预览保持一致,并同步放宽大批量任务的请求容量限制。",
"notes": "打印助手 0.2.7,支持断网后补报结果、重启恢复及打印结果核实。配合本次系统更新使用,请升级后再接单;安装器内置 .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"
"relativePath": "downloads/print-agent/latest/print-agent-setup.exe",
"sha256": "b6707f6ecffdcaaf5fa9d841bb342d9945d30728e1f9cd18133b4026c178e030",
"protocol_version": 2
}

View File

@ -0,0 +1,11 @@
{
"version": "0.2.7",
"published_at": "2026-10-09T13:58:38+08:00",
"filename": "print-agent-setup-0.2.7.exe",
"notes": "打印助手 0.2.7,支持断网后补报结果、重启恢复及打印结果核实。配合本次系统更新使用,请升级后再接单;安装器内置 .NET 8 运行时。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/releases/0.2.7/print-agent-setup-0.2.7.exe",
"relativePath": "downloads/print-agent/releases/0.2.7/print-agent-setup-0.2.7.exe",
"sha256": "b6707f6ecffdcaaf5fa9d841bb342d9945d30728e1f9cd18133b4026c178e030",
"protocol_version": 2
}

View File

@ -85,8 +85,8 @@ server {
root /usr/share/nginx/html;
index index.html;
ssl_certificate /etc/letsencrypt/live/asset.jiansun.vip/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/asset.jiansun.vip/privkey.pem;
ssl_certificate /etc/letsencrypt/equipment-management/asset.jiansun.vip/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/equipment-management/asset.jiansun.vip/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@
"qrcode": "^1.5.4",
"vue": "^3.5.24",
"vue-router": "^4.6.3",
"xlsx": "^0.18.5"
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",

View File

@ -0,0 +1,123 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { toApiDateTime } from '../src/utils/dateTime.ts'
import { clearSessionState } from '../src/auth/sessionState.ts'
import { createRequestScope } from '../src/utils/requestScope.ts'
import { indexEquipmentSelection, resolveSelectedEquipmentCodes } from '../src/utils/equipmentSelection.ts'
import { parseEquipmentImportRows } from '../src/utils/equipmentImport.ts'
import { getReturnCompletionText, submitReturnWithConfirmation } from '../src/utils/returnConfirmation.ts'
test('local browser datetime becomes an unambiguous instant and survives server timezone changes', () => {
const previousTimezone = process.env.TZ
try {
process.env.TZ = 'Asia/Shanghai'
const payload = toApiDateTime('2026-10-09T12:00')
assert.equal(payload, '2026-10-09T04:00:00.000Z')
process.env.TZ = 'UTC'
assert.equal(new Date(payload!).toISOString(), payload)
assert.equal(toApiDateTime(''), undefined)
assert.throws(() => toApiDateTime('not-a-date'), /无效/)
} finally {
if (previousTimezone === undefined) delete process.env.TZ
else process.env.TZ = previousTimezone
}
})
test('failed user refresh clears both live authentication and the persisted token', () => {
const removed: string[] = []
const state = { user: { id: 4 }, token: 'session' as string | null, isAuthenticated: true }
clearSessionState(state, { removeItem: (key) => { removed.push(key) } })
assert.deepEqual(state, { user: null, token: null, isAuthenticated: false })
assert.deepEqual(removed, ['token'])
})
test('late requests cannot replace a newer detail or mutate a disposed view', async () => {
const scope = createRequestScope()
let displayed = ''
let finishFirst: () => void = () => {}
const first = scope.begin()
const delayed = new Promise<void>((resolve) => { finishFirst = resolve }).then(() => {
if (scope.isCurrent(first)) displayed = 'asset A'
})
const second = scope.begin()
if (scope.isCurrent(second)) displayed = 'asset B'
finishFirst()
await delayed
assert.equal(displayed, 'asset B')
scope.dispose()
assert.equal(scope.isCurrent(second), false)
})
test('all selected asset codes survive page changes, including nested accessories', () => {
const pageOne = indexEquipmentSelection([{ id: 1, equipment_code: 'A', equipment_name: 'A', children: [
{ id: 2, equipment_code: 'A-CHILD', equipment_name: 'child', parent_id: 1 }
] }])
const pageTwo = indexEquipmentSelection([{ id: 21, equipment_code: 'B', equipment_name: 'B' }])
const selected = new Map([...pageOne, ...pageTwo])
assert.deepEqual(resolveSelectedEquipmentCodes([1, 2, 21], selected), ['A', 'A-CHILD', 'B'])
assert.throws(() => resolveSelectedEquipmentCodes([1, 999, 21], selected), /#999/)
})
test('Excel data validation preserves invalid rows and original row numbers through empty rows', () => {
const rows = parseEquipmentImportRows([
['name', 'type'],
['zero', 'computer', '', '', '', 100, 0],
[],
['missing type', '', '', '', '', 50, 2],
['bad quantity', 'computer', '', '', '', 20, 'oops'],
['negative', 'computer', '', '', '', 10, -2],
['fraction', 'computer', '', '', '', 10, 1.5]
])
assert.equal(rows.length, 5)
assert.deepEqual(rows.map((row) => row.source_row), [2, 4, 5, 6, 7])
assert.deepEqual(rows.map((row) => row.total_quantity), [0, 2, 0, -2, 1.5])
assert.equal(rows[1]?.equipment_type, '')
const shiftedHeader = parseEquipmentImportRows([['name', 'type'], ['asset', 'computer', '', '', '', 10, 1]], 2)
assert.equal(shiftedHeader[0]?.source_row, 4)
})
test('invalid nonempty Excel prices survive the real JSON transport for server rejection', () => {
const payload = JSON.parse(JSON.stringify(parseEquipmentImportRows([
['name', 'type'],
['invalid price', 'computer', '', '', '', ' bad-price ', 1],
['infinite price', 'computer', '', '', '', 'Infinity', 1],
['zero price', 'computer', '', '', '', 0, 1],
['valid price', 'computer', '', '', '', '12.50', 1],
['empty price', 'computer', '', '', '', '', 1]
])))
assert.deepEqual(payload.map((row: { unit_price?: number | string }) => row.unit_price), ['bad-price', 'Infinity', 0, 12.5, undefined])
assert.equal(payload[0].source_row, 2)
assert.equal(Number.isFinite(Number(payload[0].unit_price)), false)
assert.equal(Number.isFinite(Number(payload[1].unit_price)), false)
})
test('return confirmation acknowledges each current tree and does not report success for another need_confirm', async () => {
const tokens: Array<string | undefined> = []
let prompts = 0
const result = await submitReturnWithConfirmation({ equipment_code: 'A' }, async (payload) => {
tokens.push(payload.confirmation_token)
if (tokens.length === 1) return { data: { need_confirm: true, confirmation_token: 'tree-v1' } }
if (tokens.length === 2) return { data: { need_confirm: true, confirmation_token: 'tree-v2' } }
return { data: { need_confirm: false } }
}, () => { prompts += 1; return true })
assert.deepEqual(tokens, [undefined, 'tree-v1', 'tree-v2'])
assert.equal(prompts, 2)
assert.equal(result?.need_confirm, false)
})
test('declining differences leaves the return unsubmitted and missing confirmation tokens fail closed', async () => {
let submissions = 0
const cancelled = await submitReturnWithConfirmation({}, async () => {
submissions += 1
return { data: { need_confirm: true, confirmation_token: 'tree-v1' } }
}, () => false)
assert.equal(cancelled, null)
assert.equal(submissions, 1)
await assert.rejects(() => submitReturnWithConfirmation({}, async () => ({ data: { need_confirm: true } }), () => true), /确认信息不完整/)
})
test('lost and damaged completion messages cannot claim available stock was restored', () => {
assert.equal(getReturnCompletionText('丢失'), '已登记丢失(未入仓)')
assert.equal(getReturnCompletionText('损坏'), '已归还,状态为已损坏')
assert.equal(getReturnCompletionText('正常'), '已完成归还')
})

View File

@ -0,0 +1,190 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import ts from 'typescript'
import { createRenderer, h, onMounted, ref, nextTick } from 'vue'
import { createRouter, createMemoryHistory, RouterView, useRoute } from 'vue-router'
import { clearSessionState } from '../src/auth/sessionState.ts'
import { createRequestScope } from '../src/utils/requestScope.ts'
import { parseEquipmentImportRows } from '../src/utils/equipmentImport.ts'
import * as XLSX from 'xlsx'
test('the real route guard reaches login once after an expired session, without dashboard bounce', async () => {
const source = fs.readFileSync(new URL('../src/router/index.ts', import.meta.url), 'utf8')
const guardSource = source.slice(source.indexOf('router.beforeEach(async ') + 18, source.lastIndexOf('\n})') + 2)
const executable = ts.transpileModule(`const guard = ${guardSource}`.replace('import.meta.env.VITE_AUTH_MODE', "'local'"), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
const state = {
user: { id: 4 }, token: 'session', isAuthenticated: true,
refreshUser: async () => { throw new Error('401 session expired') },
clearSession: () => clearSessionState(state, { removeItem: () => {} })
}
const guard = new Function('useAuthStore', 'resolveAuthMode', 'keycloak', `${executable}; return guard;`)(() => state, () => 'local', {})
let nextTarget
await guard({ path: '/equipment', meta: { requiresAuth: true } }, {}, (target) => { nextTarget = target })
assert.equal(nextTarget, '/login')
nextTarget = 'not-called'
await guard({ path: '/login', meta: { requiresAuth: false } }, {}, (target) => { nextTarget = target })
assert.equal(nextTarget, undefined)
assert.equal(state.isAuthenticated, false)
})
test('changing detail IDs remounts the detail view with the key used by MainLayout', async () => {
const layout = fs.readFileSync(new URL('../src/layouts/MainLayout.vue', import.meta.url), 'utf8')
assert.match(layout, /<router-view\s+:key="route\.path"/)
const renderer = createRenderer({
createElement: (type) => ({ type, children: [] }), createText: (text) => ({ text }), createComment: (text) => ({ text }),
setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text },
parentNode: (node) => node.parent || null, nextSibling: () => null, patchProp: () => {},
insert: (node, parent) => { parent.children.push(node); node.parent = parent },
remove: (node) => { if (node.parent) node.parent.children = node.parent.children.filter((item) => item !== node) }
})
let currentDetail
let mountedCount = 0
const Detail = { setup() {
const route = useRoute()
const displayedId = ref(null)
onMounted(() => { mountedCount += 1; displayedId.value = Number(route.params.id) })
currentDetail = { displayedId, targetId: () => Number(route.params.id) }
return () => h('div', String(displayedId.value))
} }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/equipment/edit/:id', component: Detail }] })
await router.push('/equipment/edit/1')
const app = renderer.createApp({ setup() { const route = useRoute(); return () => h(RouterView, { key: route.path }) } })
app.use(router)
app.mount({ children: [] })
await nextTick()
await router.push('/equipment/edit/2')
await nextTick()
assert.equal(currentDetail.displayedId.value, 2)
assert.equal(currentDetail.targetId(), 2)
assert.equal(mountedCount, 2)
app.unmount()
})
test('print resolution requires a checked outcome and submits the originally inspected claim token', async () => {
const source = fs.readFileSync(new URL('../src/views/settings/components/PrintJobManagement.vue', import.meta.url), 'utf8')
const start = source.indexOf('const submitResolution = async () =>')
const end = source.indexOf('\nonMounted(', start)
const compiled = ts.transpileModule(source.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
const canResolve = { value: true }
const resolutionJob = { value: { id: 83, claim_token: 'inspected-claim' } }
const resolutionOutcome = { value: 'not_printed' }
const resolutionVerified = { value: false }
const busyJobId = { value: null }
const submissions = []
let refreshes = 0
const submit = new Function('canResolve', 'resolutionJob', 'resolutionOutcome', 'resolutionVerified', 'busyJobId', 'resolvePrintJob', 'fetchJobs', 'alert', `${compiled}; return submitResolution;`)(
canResolve, resolutionJob, resolutionOutcome, resolutionVerified, busyJobId,
async (id, payload) => { submissions.push({ id, payload }) }, async () => { refreshes += 1 }, () => {}
)
await submit()
assert.equal(submissions.length, 0)
resolutionVerified.value = true
canResolve.value = false
await submit()
assert.equal(submissions.length, 0)
canResolve.value = true
await submit()
assert.deepEqual(submissions, [{ id: 83, payload: { confirmed: true, outcome: 'not_printed', expected_claim_token: 'inspected-claim' } }])
assert.equal(resolutionJob.value, null)
assert.equal(busyJobId.value, null)
assert.equal(refreshes, 1)
})
const extractListFunction = (startMarker, endMarker, bindings) => {
const source = fs.readFileSync(new URL('../src/views/equipment/EquipmentList.vue', import.meta.url), 'utf8')
const start = source.indexOf(`const ${startMarker}`)
const end = source.indexOf(`const ${endMarker}`, start)
const compiled = ts.transpileModule(source.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
return new Function(...Object.keys(bindings), `${compiled}; return ${startMarker};`)(...Object.values(bindings))
}
for (const delayedStage of ['detail', 'records', 'fallback']) {
test(`opening asset B supersedes a slow asset A return lookup at ${delayedStage}`, async () => {
const returnDialogScope = createRequestScope()
const selectedIds = { value: [1] }
const returnAssetInfo = { value: null }
const returnMode = { value: 'batch' }
const returnAnomalyInfo = { value: null }
const returnSelectedRecordId = { value: null }
const returnRecordKeyword = { value: '' }
const showBatchReturnModal = { value: false }
let finishA
let notifyReached
const reached = new Promise((resolve) => { notifyReached = resolve })
const delayed = () => { notifyReached(); return new Promise((resolve) => { finishA = resolve }) }
const detail = (id) => ({ data: { id, equipment_code: id === 1 ? 'A' : 'B', equipment_name: id === 1 ? 'Asset A' : 'Asset B' } })
const records = (code) => ({ data: { can_return: true, equipment_code: code, equipment_name: code, borrow_records: [{ id: code === 'A' ? 101 : 202 }] } })
const bindings = {
returnDialogScope, selectedIds, returnAssetInfo, returnMode, returnAnomalyInfo,
returnSelectedRecordId, returnRecordKeyword, showBatchReturnModal,
returnSubmitting: { value: false }, borrowSubmitting: { value: false },
clearSingleActionContext: () => {},
resetReturnForm: () => { returnDialogScope.begin(); returnAssetInfo.value = null; returnSelectedRecordId.value = null },
canQuickReturnInline: () => false,
buildManualReturnRecordDisplay: (record) => String(record.id),
alert: (message) => { throw new Error(`unexpected alert: ${message}`) },
getEquipmentDetail: async (id) => id === 1 && delayedStage === 'detail' ? delayed() : detail(id),
getBorrowRecordByCode: async (code) => {
if (code === 'A' && delayedStage === 'records') return delayed()
if (code === 'A' && delayedStage === 'fallback') return { data: { ...records('A').data, can_return: false } }
return records(code)
},
searchReturnAssets: async () => delayed()
}
const open = extractListFunction('openBatchReturnModalInternal', 'openBatchReturnModal =', bindings)
const pendingA = open()
await reached
selectedIds.value = [2]
await open()
assert.equal(returnAssetInfo.value.equipment_code, 'B')
assert.equal(returnSelectedRecordId.value, 202)
finishA(delayedStage === 'detail' ? detail(1) : delayedStage === 'records' ? records('A') : { data: [{ equipment_id: 1, can_return: false }] })
await pendingA
assert.equal(returnAssetInfo.value.equipment_code, 'B')
assert.equal(returnSelectedRecordId.value, 202)
assert.equal(returnMode.value, 'record-select')
assert.equal(returnAnomalyInfo.value, null)
})
}
test('successful single action preserves prior cross-page selections and removes only its successful asset', () => {
const selectedIds = { value: [30] }
const singleActionContext = { value: { previousSelectedIds: [1, 21, 30] } }
const selectionEquipment = { value: new Map([[1, { equipment_code: 'A' }], [21, { equipment_code: 'B' }], [30, { equipment_code: 'Z' }]]) }
const finish = extractListFunction('finishSuccessfulAssetAction', 'restoreSelectionAfterSingleActionCancel', {
selectedIds, singleActionContext, selectionEquipment,
clearSingleActionContext: () => { singleActionContext.value = null }
})
finish(new Set(['Z']))
assert.deepEqual(selectedIds.value, [1, 21])
assert.equal(singleActionContext.value, null)
})
test('a workbook starting at A3 reports its data row as actual Excel row 4', () => {
const sheet = {
A3: { t: 's', v: 'name' }, B3: { t: 's', v: 'type' },
A4: { t: 's', v: 'asset' }, B4: { t: 's', v: 'computer' }, G4: { t: 'n', v: 1 },
'!ref': 'A3:G4'
}
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, 'assets')
const reread = XLSX.read(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }), { type: 'buffer' })
const actual = reread.Sheets.assets
const rows = XLSX.utils.sheet_to_json(actual, { header: 1, blankrows: true })
assert.equal(parseEquipmentImportRows(rows, XLSX.utils.decode_range(actual['!ref']).s.r)[0].source_row, 4)
})
test('sending more than 100 remote labels refuses before generating or creating any real task', async () => {
const source = fs.readFileSync(new URL('../src/components/BarcodePrint.vue', import.meta.url), 'utf8')
const start = source.indexOf('const handleRemotePrint = async () =>')
const end = source.indexOf('const handleCancelRemoteJob', start)
const compiled = ts.transpileModule(source.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
const notices = []
const remotePrint = new Function('props', 'equipmentList', 'alert', `${compiled}; return handleRemotePrint;`)(
{ equipmentIds: Array.from({ length: 101 }, (_, index) => index + 1) }, { value: [] }, (message) => { notices.push(message) }
)
await remotePrint()
assert.equal(notices.length, 1)
assert.match(notices[0], /100/)
})

View File

@ -1,4 +1,5 @@
import request from './index'
import { toApiDateTime } from '@/utils/dateTime'
import type { ApiResponse } from '@/types'
export interface BatchResult {
@ -20,7 +21,7 @@ export const batchBorrow = (data: {
is_long_term?: boolean
notes?: string
}) => {
return request.post<ApiResponse<BatchResult>>('/batch/borrow', data).then((response) => response.data)
return request.post<ApiResponse<BatchResult>>('/batch/borrow', { ...data, expected_return_time: data.is_long_term ? undefined : toApiDateTime(data.expected_return_time) }).then((response) => response.data)
}
// 批量归还

View File

@ -13,12 +13,13 @@ interface PersonImportData {
}
interface EquipmentImportData {
source_row?: number
equipment_name: string
equipment_type: string
equipment_model?: string
department?: string
storage_location?: string
unit_price?: number
unit_price?: number | string
total_quantity: number
supplier?: string
purchase_date?: string

View File

@ -1,4 +1,5 @@
import api from './index'
import { toApiDateTime } from '@/utils/dateTime'
import type {
BorrowRecord,
PaginatedResponse,
@ -46,7 +47,7 @@ export const borrowEquipment = async (data: {
notes?: string
is_long_term?: boolean
}) => {
const response = await api.post<ApiResponse<BorrowRecord>>('/borrow', data)
const response = await api.post<ApiResponse<BorrowRecord>>('/borrow', { ...data, expected_return_time: data.is_long_term ? undefined : toApiDateTime(data.expected_return_time) })
return response.data
}
@ -58,7 +59,7 @@ export const scanBorrow = async (data: {
notes?: string
is_long_term?: boolean
}) => {
const response = await api.post<ApiResponse<ScanBorrowResult>>('/borrow/scan', data)
const response = await api.post<ApiResponse<ScanBorrowResult>>('/borrow/scan', { ...data, expected_return_time: data.is_long_term ? undefined : toApiDateTime(data.expected_return_time) })
return response.data
}
@ -78,7 +79,7 @@ export const scanBorrowBatch = async (data: {
status: string
message: string
}>
}>>('/borrow/scan-batch', data)
}>>('/borrow/scan-batch', { ...data, expected_return_time: data.is_long_term ? undefined : toApiDateTime(data.expected_return_time) })
return response.data
}
@ -91,6 +92,7 @@ export const returnEquipment = async (data: {
return_condition?: string
notes?: string
confirm_children_diff?: boolean
confirmation_token?: string
}) => {
const response = await api.post<ApiResponse<BorrowRecord>>('/return', data)
return response.data
@ -104,6 +106,7 @@ export const scanReturn = async (data: {
person_id?: number
borrow_record_id?: number
confirm_children_diff?: boolean
confirmation_token?: string
}) => {
const response = await api.post<
ApiResponse<{
@ -114,6 +117,8 @@ export const scanReturn = async (data: {
status: string
person_id?: number
need_confirm?: boolean
message?: string
confirmation_token?: string
children_diff?: {
removed: Array<Record<string, any>>
added: Array<Record<string, any>>

View File

@ -155,6 +155,7 @@ const normalizePrintTerminal = (value: unknown): PrintTerminal => {
name: toNullableString(record.name) || toNullableString(record.machine_name) || `终端 ${toNumber(record.id)}`,
platform: toNullableString(record.platform),
machine_name: toNullableString(record.machine_name),
protocol_version: record.protocol_version == null ? null : toNumber(record.protocol_version),
status: toNullableString(record.status) || 'offline',
is_default: toBoolean(record.is_default),
last_seen_at: toNullableString(record.last_seen_at),
@ -221,6 +222,8 @@ const normalizePrintJob = (value: unknown): PrintJob => {
error_message: toNullableString(record.error_message),
attempt_count: toNumber(record.attempt_count, 0),
claimed_at: toNullableString(record.claimed_at),
claim_token: toNullableString(record.claim_token),
lease_expires_at: toNullableString(record.lease_expires_at),
started_at: toNullableString(record.started_at),
completed_at: toNullableString(record.completed_at),
created_at: toNullableString(record.created_at),
@ -374,6 +377,15 @@ export const cancelQueuedPrintJob = async (id: number) => {
return normalizePrintJob(response.data.data)
}
export const resolvePrintJob = async (id: number, payload: {
confirmed: true
outcome: 'printed' | 'not_printed' | 'cancel'
expected_claim_token: string | null
}) => {
const response = await api.post<ApiResponse<unknown>>(`/print-jobs/${id}/resolve`, payload)
return normalizePrintJob(response.data.data)
}
export const setDefaultPrintTerminal = async (id: number) => {
const response = await api.patch<ApiResponse<unknown>>(`/print-terminals/${id}/default`)
return normalizePrintTerminal(response.data.data)

View File

@ -0,0 +1,12 @@
export interface SessionState {
user: unknown
token: string | null
isAuthenticated: boolean
}
export const clearSessionState = (state: SessionState, storage: Pick<Storage, 'removeItem'>) => {
state.user = null
state.token = null
state.isAuthenticated = false
storage.removeItem('token')
}

View File

@ -254,6 +254,10 @@ const remoteStatusDescription = computed(() => {
return '当前还没有设置默认打印电脑,请联系管理员完成终端配置。'
}
if (defaultTerminal.value.status !== 'disabled' && (defaultTerminal.value.protocol_version || 0) < 2) {
return '打印助手需要升级。发送的任务会排队等待兼容的助手接单,请联系管理员更新办公室电脑。'
}
if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) {
return `默认打印电脑 ${defaultTerminal.value.name} 在线,发送后会直接交给 ${remotePrinterName.value}。`
}
@ -282,13 +286,14 @@ const getJobStatusText = (status?: string | null) => {
if (status === 'completed') return '已完成'
if (status === 'failed') return '失败'
if (status === 'cancelled') return '已取消'
if (status === 'result_unknown') return '结果待核实'
return status || '未知'
}
const getJobStatusBadgeClass = (status?: string | null) => {
if (status === 'completed') return 'badge-success'
if (status === 'failed') return 'badge-error'
if (status === 'queued' || status === 'claimed' || status === 'printing') return 'badge-warning'
if (status === 'queued' || status === 'claimed' || status === 'printing' || status === 'result_unknown') return 'badge-warning'
return 'badge-neutral'
}
@ -729,6 +734,10 @@ const fetchEquipment = async () => {
}
const handleRemotePrint = async () => {
if (props.equipmentIds.length > 100 || equipmentList.value.length > 100) {
alert('每次远程打印最多支持 100 张标签,请减少本次选择后再发送。')
return
}
if (equipmentList.value.length === 0) {
alert('没有可发送的标签')
return
@ -756,6 +765,11 @@ const handleRemotePrint = async () => {
throw new Error('标签图片生成不完整,请稍后重试')
}
const imageBytes = images.map((image) => Math.ceil((image.split(',')[1]?.length || 0) * 3 / 4))
if (imageBytes.some((size) => size > 2 * 1024 * 1024) || imageBytes.reduce((sum, size) => sum + size, 0) > 20 * 1024 * 1024) {
throw new Error('标签图片超过远程打印大小限制,请减少本次选择后重试。')
}
const renderedLabels = equipmentList.value.map((equipment, index) => ({
equipment_id: equipment.id,
image_data_url: images[index]

View File

@ -118,7 +118,9 @@ const getStatusClass = (status: string) => {
'已借出': 'badge-warning',
'维护中': 'badge-neutral',
'待报废': 'badge-warning',
'已报废': 'badge-error'
'已报废': 'badge-error',
'已损坏': 'badge-error',
'已丢失': 'badge-error'
}
return map[status] || 'badge-neutral'
}

View File

@ -121,7 +121,7 @@
<!-- 页面内容 -->
<div class="flex-1 overflow-auto px-3 py-4 sm:px-4">
<router-view />
<router-view :key="route.path" />
</div>
</main>
</div>

View File

@ -184,7 +184,9 @@ router.beforeEach(async (to, _from, next) => {
try {
await authStore.refreshUser()
} catch {
if (authMode === 'local' || authStore.token === 'session') {
const usedSession = authStore.token === 'session'
authStore.clearSession()
if (authMode === 'local' || usedSession) {
next('/login')
} else {
await keycloak.login()

View File

@ -4,6 +4,7 @@ import keycloak from '@/auth/keycloak'
import api from '@/api'
import { resolveAuthMode } from '@/utils/authMode'
import { normalizeUser } from '@/auth/user'
import { clearSessionState } from '@/auth/sessionState'
interface AuthState {
user: User | null
@ -19,6 +20,9 @@ export const useAuthStore = defineStore('auth', {
}),
actions: {
clearSession() {
clearSessionState(this, localStorage)
},
setUser(user: User) {
this.user = normalizeUser(user)
this.isAuthenticated = true
@ -49,10 +53,7 @@ export const useAuthStore = defineStore('auth', {
async logout() {
const usedSession = this.token === 'session'
const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
this.user = null
this.token = null
this.isAuthenticated = false
localStorage.removeItem('token')
this.clearSession()
try {
await api.post('/auth/logout')

View File

@ -2,7 +2,7 @@
export type EquipmentType = string
// 资产状态
export type EquipmentStatus = '可用' | '已借出' | '维护中' | '已损坏' | '待报废' | '已报废'
export type EquipmentStatus = '可用' | '已借出' | '维护中' | '已损坏' | '已丢失' | '待报废' | '已报废'
export interface BorrowAnomalyInfo {
state: 'none' | 'returnable' | 'borrow_record_missing'
@ -600,6 +600,7 @@ export interface DashboardStats {
maintenance_equipment: number
pending_disposal_equipment: number
damaged_equipment: number
lost_equipment?: number
other_equipment: number
disposed_equipment: number
borrowed_without_active_record: number
@ -615,7 +616,8 @@ export interface DashboardStats {
today_return_value: number
generated_at: string
business_timezone: 'Asia/Shanghai'
inventory_scope: 'current_excluding_disposed'
inventory_scope: 'current_excluding_disposed' | 'current_excluding_disposed_and_lost'
value_scope?: 'book_value_excluding_disposed_including_lost'
inventory_by_type: Array<{
equipment_type: string
total_quantity: number
@ -624,6 +626,7 @@ export interface DashboardStats {
maintenance_quantity: number
pending_disposal_quantity: number
damaged_quantity: number
lost_quantity?: number
other_quantity: number
mountable_available_quantity: number
is_child_type: boolean
@ -632,6 +635,7 @@ export interface DashboardStats {
// 资产价值统计接口
export interface ValueStatistics {
value_scope?: 'book_value_excluding_disposed_including_lost'
total_value: number
borrowed_value: number
available_value: number
@ -833,6 +837,7 @@ export type PrintJobStatus =
| 'completed'
| 'failed'
| 'cancelled'
| 'result_unknown'
export interface PrintTerminalPrinter {
name: string
@ -873,6 +878,7 @@ export interface PrintTerminal {
name: string
platform?: string | null
machine_name?: string | null
protocol_version?: number | null
status: PrintTerminalStatus | string
is_default: boolean
last_seen_at?: string | null
@ -917,6 +923,8 @@ export interface PrintJob {
error_message?: string | null
attempt_count?: number
claimed_at?: string | null
claim_token?: string | null
lease_expires_at?: string | null
started_at?: string | null
completed_at?: string | null
created_at?: string | null

View File

@ -0,0 +1,7 @@
// datetime-local represents the user's browser timezone. Convert it before crossing the API boundary.
export const toApiDateTime = (value?: string): string | undefined => {
if (!value?.trim()) return undefined
const date = new Date(value)
if (!Number.isFinite(date.getTime())) throw new Error('日期时间无效,请重新选择')
return date.toISOString()
}

View File

@ -0,0 +1,42 @@
export interface EquipmentImportRow {
source_row: number
equipment_name: string
equipment_type: string
equipment_model?: string
department?: string
storage_location?: string
unit_price?: number | string
total_quantity: number
supplier?: string
purchase_date?: string
description?: string
}
const optionalText = (value: unknown) => value === undefined || value === null || String(value).trim() === ''
? undefined
: String(value).trim()
// Keep invalid data rows for the server's validation; skip only entirely empty rows.
export const parseEquipmentImportRows = (rows: unknown[][], headerRowOffset = 0): EquipmentImportRow[] => rows
.slice(1)
.map((cols, index) => ({ cols, source_row: headerRowOffset + index + 2 }))
.filter(({ cols }) => cols.some((value) => optionalText(value) !== undefined))
.map(({ cols, source_row }) => {
const quantity = Number(cols[6])
const priceText = optionalText(cols[5])
const price = priceText === undefined ? undefined : Number(priceText)
return {
source_row,
equipment_name: String(cols[0] ?? '').trim(),
equipment_type: String(cols[1] ?? '').trim(),
equipment_model: optionalText(cols[2]),
department: optionalText(cols[3]),
storage_location: optionalText(cols[4]),
// Invalid nonempty prices must survive JSON instead of NaN becoming null/default zero.
unit_price: price === undefined ? undefined : Number.isFinite(price) ? price : priceText,
total_quantity: Number.isFinite(quantity) ? quantity : 0,
supplier: optionalText(cols[7]),
purchase_date: optionalText(cols[8]),
description: optionalText(cols[9])
}
})

View File

@ -0,0 +1,27 @@
export interface SelectableEquipment {
id: number
equipment_code: string
equipment_name: string
status?: string
parent_id?: number | null
children?: SelectableEquipment[]
}
export const indexEquipmentSelection = (items: SelectableEquipment[]) => {
const indexed = new Map<number, SelectableEquipment>()
const visit = (item: SelectableEquipment, parentId?: number) => {
indexed.set(item.id, parentId === undefined ? item : { ...item, parent_id: item.parent_id ?? parentId })
item.children?.forEach((child) => visit(child, item.id))
}
items.forEach((item) => visit(item))
return indexed
}
export const resolveSelectedEquipmentCodes = (
selectedIds: number[],
indexed: Map<number, SelectableEquipment>
) => selectedIds.map((id) => {
const code = indexed.get(id)?.equipment_code
if (!code) throw new Error(`资产 #${id} 的资料未加载,请重新选择后再操作`)
return code
})

View File

@ -0,0 +1,9 @@
export const createRequestScope = () => {
let sequence = 0
let disposed = false
return {
begin: () => ++sequence,
isCurrent: (version: number) => !disposed && version === sequence,
dispose: () => { disposed = true; sequence += 1 }
}
}

View File

@ -0,0 +1,28 @@
interface ConfirmationResult {
need_confirm?: boolean
confirmation_token?: string
message?: string
}
export const getReturnCompletionText = (condition: string) => condition === '丢失'
? '已登记丢失(未入仓)'
: condition === '损坏' ? '已归还,状态为已损坏' : '已完成归还'
// Each acknowledgement is tied to the asset tree the server just returned.
export const submitReturnWithConfirmation = async <P, R extends ConfirmationResult>(
payload: P,
submit: (payload: P & { confirmation_token?: string }) => Promise<{ data?: R }>,
confirm: (message: string) => boolean
): Promise<R | null> => {
let confirmationToken: string | undefined
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await submit({ ...payload, confirmation_token: confirmationToken })
const result = response.data
if (!result) throw new Error('归还接口未返回结果,请刷新核实状态')
if (!result.need_confirm) return result
if (!result.confirmation_token) throw new Error('配件差异确认信息不完整,请刷新后重试')
if (!confirm(result.message || '配件与借出记录不一致或原始快照缺失,请核对实物后确认是否继续入库。')) return null
confirmationToken = result.confirmation_token
}
throw new Error('配件状态持续变化,请刷新资产后重新确认')
}

View File

@ -367,7 +367,7 @@ const personSearchLoading = ref(false)
const canSubmit = computed(() => {
return canSubmitBorrowScan({
hasEquipment: Boolean(equipmentInfo.value),
hasEquipment: Boolean(equipmentInfo.value && equipmentInfo.value.status !== '已丢失'),
selectedPersonId: selectedPersonId.value,
loading: loading.value
})
@ -397,6 +397,7 @@ const getStatusBadgeClass = (status: string) => {
case '维护中':
return 'badge-error'
case '已报废':
case '已丢失':
return 'badge-neutral'
default:
return 'badge-neutral'
@ -554,6 +555,7 @@ const handleManualSubmit = async () => {
}
const handleSubmit = async () => {
if (!canSubmit.value) return
if (!equipmentInfo.value || selectedPersonId.value === null || Number.isNaN(selectedPersonId.value)) {
return
}

View File

@ -341,6 +341,7 @@
</template>
<script setup lang="ts">
import { getReturnCompletionText, submitReturnWithConfirmation } from '@/utils/returnConfirmation'
import { ref, onMounted, nextTick, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { getBorrowRecordByCode, scanReturn, searchReturnAssets } from '@/api/borrow'
@ -781,46 +782,20 @@ const handleSubmit = async () => {
loading.value = true
try {
let confirmChildrenDiff = false
if (hasChildrenDiff.value) {
confirmChildrenDiff = window.confirm('检测到配件与借出时不一致,确认继续入库吗?')
if (!confirmChildrenDiff) {
loading.value = false
return
}
}
const response = await scanReturn({
const result = await submitReturnWithConfirmation({
equipment_code: borrowRecordInfo.value.equipment_code,
return_condition: returnInfo.value.return_condition,
notes: returnInfo.value.notes || undefined,
person_id: record.person_id,
borrow_record_id: record.id,
confirm_children_diff: confirmChildrenDiff
})
if (response.data?.need_confirm) {
const confirmed = window.confirm('后端检测到配件与借出时不一致,确认继续入库吗?')
if (!confirmed) {
loading.value = false
return
}
await scanReturn({
equipment_code: borrowRecordInfo.value.equipment_code,
return_condition: returnInfo.value.return_condition,
notes: returnInfo.value.notes || undefined,
person_id: record.person_id,
borrow_record_id: record.id,
confirm_children_diff: true
})
}
borrow_record_id: record.id
}, scanReturn, (message) => window.confirm(message))
if (!result) return
playBeepSound()
returnSuccessCount.value += 1
pageNotice.value = {
type: 'success',
message: `${borrowRecordInfo.value.equipment_name} 已完成归还,可以继续扫码下一件。`
message: `${borrowRecordInfo.value.equipment_name} ${getReturnCompletionText(returnInfo.value.return_condition)},可以继续扫码下一件。`
}
resetCurrentFlow()
clearManualAssetSearch()

View File

@ -93,8 +93,8 @@
<section class="card">
<div class="list-header">
<div>
<h3 class="section-title">当前在册状态</h3>
<p class="mt-1 text-xs text-text-tertiary">不含已完成报废资产,状态合计等于在册总量</p>
<h3 class="section-title">当前实物状态</h3>
<p class="mt-1 text-xs text-text-tertiary">图表不含已报废和已丢失;丢失数量另列,账面成本保留。</p>
</div>
<span class="badge badge-neutral">已报废历史 {{ formatNumber(stats.disposed_equipment) }}</span>
</div>
@ -111,8 +111,8 @@
<section class="card">
<div class="list-header">
<div>
<h3 class="section-title">在册资产结构</h3>
<p class="mt-1 text-xs text-text-tertiary">按资产类型统计,不含已完成报废资产</p>
<h3 class="section-title">实物在册结构</h3>
<p class="mt-1 text-xs text-text-tertiary">按资产类型统计,实物数量不含已报废和已丢失</p>
</div>
<span class="badge badge-neutral">TOP 5</span>
</div>
@ -430,7 +430,7 @@ const dashboardMetaCards = computed(() => {
]
}
return [
{ label: '在册资产', value: numberValue(data?.total_equipment), subvalue: data ? `档案 ${formatNumber(data.root_asset_records)} · 配件 ${formatNumber(data.accessory_equipment)}` : '统计读取失败', valueClass: '' },
{ label: '实物在册', value: numberValue(data?.total_equipment), subvalue: data ? `档案 ${formatNumber(data.root_asset_records)} · 配件 ${formatNumber(data.accessory_equipment)}` : '统计读取失败', valueClass: '' },
{ label: '待审批', value: pendingValue, subvalue: pendingSubvalue, valueClass: pendingDisposalsError.value ? 'text-status-error' : Number(pendingDisposals.value || 0) > 0 ? 'text-status-warning' : '' },
{ label: '失败投递', value: deliveryStatusError.value || failedDeliveries.value === null ? '—' : formatNumber(failedDeliveries.value), subvalue: deliveryStatusError.value ? '状态读取失败' : failedDeliveries.value === null ? '正在读取' : '钉钉通知', valueClass: deliveryStatusError.value || Number(failedDeliveries.value || 0) > 0 ? 'text-status-error' : failedDeliveries.value === null ? '' : 'text-status-success' },
{ label: '利用率', value: data ? formatPercent(data.usage_rate) : '—', subvalue: data ? `价值 ${formatPercent(data.value_usage_rate)}` : '统计读取失败', valueClass: '' }
@ -441,7 +441,7 @@ const statCards = computed(() => {
const data = stats.value
return [
{
title: '在册资产',
title: '实物在册',
value: formatNumber(data?.total_equipment || 0),
icon: CubeIcon,
color: 'primary' as const,
@ -481,12 +481,13 @@ const statusSummaryItems = computed(() => {
{ label: '维修中', value: data.maintenance_equipment, valueClass: 'text-status-error', always: true },
{ label: '待报废', value: data.pending_disposal_equipment, valueClass: 'text-status-warning', always: false },
{ label: '已损坏', value: data.damaged_equipment, valueClass: 'text-status-error', always: false },
{ label: '已丢失', value: data.lost_equipment || 0, valueClass: 'text-status-error', always: false },
{ label: '其他状态', value: data.other_equipment, valueClass: 'text-text-primary', always: false }
].filter((item) => item.always || item.value > 0)
})
const statusChartData = computed(() => statusSummaryItems.value
.filter((item) => item.value > 0)
.filter((item) => item.value > 0 && item.label !== '已丢失')
.map((item) => ({
value: item.value,
name: item.label,
@ -497,6 +498,7 @@ const statusChartData = computed(() => statusSummaryItems.value
维修中: '#ef4444',
待报废: '#8b5cf6',
已损坏: '#dc2626',
已丢失: '#b91c1c',
其他状态: '#6b7280'
}[item.label]
}
@ -519,6 +521,7 @@ const getCategoryBreakdown = (item: DashboardStats['inventory_by_type'][number])
if (item.maintenance_quantity > 0) parts.push(`维修 ${formatNumber(item.maintenance_quantity)}`)
if (item.pending_disposal_quantity > 0) parts.push(`待报废 ${formatNumber(item.pending_disposal_quantity)}`)
if (item.damaged_quantity > 0) parts.push(`损坏 ${formatNumber(item.damaged_quantity)}`)
if ((item.lost_quantity || 0) > 0) parts.push(`丢失 ${formatNumber(item.lost_quantity || 0)}`)
if (item.other_quantity > 0) parts.push(`其他 ${formatNumber(item.other_quantity)}`)
return parts.join(' · ')
}

View File

@ -82,6 +82,7 @@
<option :value="department.id">{{ department.name }}</option>
</template>
</select>
<p v-if="isEdit && currentEquipment?.status === '已丢失'" class="text-xs text-text-secondary">该资产已登记丢失,请先核实实物及后续处理,暂不能调拨使用部门。</p>
</div>
<div class="field-stack">
@ -510,7 +511,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, reactive, watch } from 'vue'
import { createRequestScope } from '@/utils/requestScope'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { PERMISSIONS } from '@/auth/permissions'
@ -609,6 +611,13 @@ const showDisposalForm = ref(false)
const showBarcodePrint = ref(false)
type EquipmentDetail = Equipment & { borrow_records?: EquipmentCurrentBorrow[] }
const currentEquipment = ref<EquipmentDetail | null>(null)
const equipmentRequestScope = createRequestScope()
const maintenanceRequestScope = createRequestScope()
const disposalRequestScope = createRequestScope()
const detailLoading = ref(false)
const detailReady = computed(() => !isEdit.value || (
!detailLoading.value && currentEquipment.value?.id === Number(route.params.id)
))
const selectedDisposalItemIds = ref<number[]>([])
const departmentTransferLoading = ref(false)
const disposalReturnLoading = ref(false)
@ -630,7 +639,7 @@ interface DisposalTreeItem {
const canCreate = computed(() => authStore.can(PERMISSIONS.ASSETS_CREATE))
const canCorrect = computed(() => authStore.can(PERMISSIONS.ASSETS_CORRECT))
const canDelete = computed(() => authStore.can(PERMISSIONS.ASSETS_DELETE))
const canDelete = computed(() => detailReady.value && authStore.can(PERMISSIONS.ASSETS_DELETE))
const canTransfer = computed(() => authStore.can(PERMISSIONS.ASSETS_TRANSFER))
const canMount = computed(() => authStore.can(PERMISSIONS.ASSETS_MOUNT))
const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE))
@ -678,6 +687,7 @@ const disposalBlockReason = computed(() => {
return '该资产已有进行中的报废申请,请在下方报废记录中查看当前进度。'
}
if (equipment.status === '已报废') return '该资产已经完成报废,不能重复发起申请。'
if (equipment.status === '已丢失') return '该资产已登记丢失,请先核实实物及后续处理。'
return ''
})
const disposalPrerequisiteTitle = computed(() =>
@ -690,10 +700,11 @@ const canQuickReturnBeforeDisposal = computed(() =>
Boolean(activeBorrowRecord.value)
)
const canOpenDisposalAction = computed(() =>
Boolean(canApplyDisposal.value && currentEquipment.value && !currentEquipment.value.parent_id && currentEquipment.value.status !== '已报废')
Boolean(detailReady.value && canApplyDisposal.value && currentEquipment.value && !currentEquipment.value.parent_id && !['已报废', '已丢失'].includes(currentEquipment.value.status))
)
const canEditBasic = computed(() => {
if (!isEdit.value) return canCreate.value
if (!detailReady.value) return false
if (!canCorrect.value) return false
if (canOverrideCorrectionRestrictions.value) return true
if (authStore.user?.role === 'it') {
@ -706,15 +717,15 @@ const canEditEquipmentCode = computed(() => {
return canEditBasic.value && currentEquipment.value?.can_correct_code === true
})
const canChangeDepartmentDirectly = computed(() => {
if (!isEdit.value || !canTransfer.value || departmentTransferLoading.value) return false
return !currentEquipment.value?.parent_id
if (!isEdit.value || !detailReady.value || !canTransfer.value || departmentTransferLoading.value) return false
return !currentEquipment.value?.parent_id && currentEquipment.value?.status !== '已丢失'
})
const canStartMaintenance = computed(() => {
if (!canMaintain.value || !currentEquipment.value || currentEquipment.value.parent_id) return false
return !['已借出', '维护中', '待报废', '已报废'].includes(currentEquipment.value.status)
if (!detailReady.value || !canMaintain.value || !currentEquipment.value || currentEquipment.value.parent_id) return false
return !['已借出', '维护中', '待报废', '已报废', '已丢失'].includes(currentEquipment.value.status)
})
const canStartDisposal = computed(() => {
if (!canApplyDisposal.value || !currentEquipment.value) return false
if (!detailReady.value || !canApplyDisposal.value || !currentEquipment.value) return false
return !disposalBlockReason.value
})
@ -977,8 +988,12 @@ const updateCanBeParent = () => {
const fetchEquipmentData = async (throwOnError = false) => {
if (!isEdit.value) return
const equipmentId = Number(route.params.id)
const requestVersion = equipmentRequestScope.begin()
detailLoading.value = true
try {
const response = await getEquipmentDetail(Number(route.params.id))
const response = await getEquipmentDetail(equipmentId)
if (!equipmentRequestScope.isCurrent(requestVersion) || equipmentId !== Number(route.params.id)) return
if (response.data) {
currentEquipment.value = response.data
let purchaseDate = ''
@ -1006,23 +1021,33 @@ const fetchEquipmentData = async (throwOnError = false) => {
updateCanBeParent()
}
} catch (error) {
if (!equipmentRequestScope.isCurrent(requestVersion) || equipmentId !== Number(route.params.id)) return
currentEquipment.value = null
console.error('获取资产详情失败:', error)
if (throwOnError) throw error
} finally {
if (equipmentRequestScope.isCurrent(requestVersion)) detailLoading.value = false
}
}
const fetchMaintenanceRecords = async () => {
if (!isEdit.value || !canMaintain.value) return
const equipmentId = Number(route.params.id)
const requestVersion = maintenanceRequestScope.begin()
try {
const response = await getMaintenanceList({ equipment_id: Number(route.params.id), page: 1, pageSize: 100 })
const response = await getMaintenanceList({ equipment_id: equipmentId, page: 1, pageSize: 100 })
if (!maintenanceRequestScope.isCurrent(requestVersion) || equipmentId !== Number(route.params.id)) return
if (response.data) maintenanceRecords.value = response.data.list || []
} catch (error) { console.error('获取维修记录失败:', error) }
}
const fetchDisposalRecords = async () => {
if (!isEdit.value || !canViewDisposals.value) return
const equipmentId = Number(route.params.id)
const requestVersion = disposalRequestScope.begin()
try {
const response = await getEquipmentDisposalHistory(Number(route.params.id))
const response = await getEquipmentDisposalHistory(equipmentId)
if (!disposalRequestScope.isCurrent(requestVersion) || equipmentId !== Number(route.params.id)) return
disposalRecords.value = response.data?.data || []
} catch (error) { console.error('获取报废记录失败:', error) }
}
@ -1031,6 +1056,11 @@ const handleDepartmentChange = async () => {
if (!isEdit.value || !currentEquipment.value) return
const currentDepartmentId = currentEquipment.value.department_id ?? undefined
if (currentEquipment.value.status === '已丢失') {
form.value.department_id = currentDepartmentId
alert('该资产已登记丢失,请先核实实物及后续处理,暂不能调拨使用部门。')
return
}
const targetDepartmentId = form.value.department_id ? Number(form.value.department_id) : undefined
if (targetDepartmentId === currentDepartmentId) return
@ -1240,7 +1270,7 @@ const handleSubmit = async () => {
const handleCancel = () => router.push('/equipment')
const handleDelete = async () => {
if (!isEdit.value) return
if (!isEdit.value || !canDelete.value) return
const confirmed = confirm(
'仅当这条资产只是刚录错编码、还没进入借出、挂载、维修、报废或盘点流程时,才适合删除后重建。确定继续删除吗?'
@ -1310,6 +1340,7 @@ watch(
)
onMounted(async () => {
const mountedPath = route.path
await Promise.all([
loadStorageLocations(),
loadAssetTypes(),
@ -1319,6 +1350,8 @@ onMounted(async () => {
fetchDisposalRecords()
])
if (route.path !== mountedPath || !detailReady.value) return
if (route.query.action === 'maintenance' && canStartMaintenance.value) {
showMaintenanceForm.value = true
}
@ -1326,4 +1359,10 @@ onMounted(async () => {
openDisposalForm()
}
})
onBeforeUnmount(() => {
equipmentRequestScope.dispose()
maintenanceRequestScope.dispose()
disposalRequestScope.dispose()
})
</script>

View File

@ -56,6 +56,8 @@
<option value="维护中">维护中</option>
<option value="待报废">待报废</option>
<option value="已报废">已报废</option>
<option value="已损坏">已损坏</option>
<option value="已丢失">已丢失</option>
</select>
</div>
@ -735,7 +737,7 @@
<p v-if="selectedBorrowedCount" class="mt-2 text-xs text-status-error">选中项中有 {{ selectedBorrowedCount }} 项仍在借用,请先完成归还。</p>
</div>
<p v-if="selectedTransferBlockedCount" class="text-xs text-status-error">
选中项中有 {{ selectedTransferBlockedCount }} 项处于维修、待报废或已报废状态,不能调拨。
选中项中有 {{ selectedTransferBlockedCount }} 项资料不完整或处于维修、待报废、报废、丢失状态,不能调拨。
</p>
<div class="field-stack">
<label class="field-label">调拨原因 *</label>
@ -760,13 +762,17 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { PERMISSIONS } from '@/auth/permissions'
import { ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
import * as XLSX from 'xlsx'
import { getEquipmentList, deleteEquipment, scanEquipment } from '@/api/equipment'
import { getEquipmentList, getEquipmentDetail, deleteEquipment, scanEquipment } from '@/api/equipment'
import { indexEquipmentSelection, resolveSelectedEquipmentCodes, type SelectableEquipment } from '@/utils/equipmentSelection'
import { parseEquipmentImportRows } from '@/utils/equipmentImport'
import { createRequestScope } from '@/utils/requestScope'
import { getReturnCompletionText } from '@/utils/returnConfirmation'
import { getPersonOptions, type PersonOption } from '@/api/person'
import { getDepartmentOptions } from '@/api/department'
import { getAssetTypeOptions } from '@/api/assetType'
@ -821,6 +827,10 @@ const currentPage = ref(1)
const pageSize = ref(20)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const selectedIds = ref<number[]>([])
const selectionEquipment = ref(new Map<number, SelectableEquipment>())
const listRequestScope = createRequestScope()
const returnDialogScope = createRequestScope()
const selectedEquipment = computed(() => selectedIds.value.map((id) => selectionEquipment.value.get(id)))
const expandedIds = ref(new Set<number>())
const departments = ref<Department[]>([])
@ -915,16 +925,17 @@ const returnAnomalyInfo = ref<{
// 归还确认状态(用于处理配件差异等需要二次确认的情况)
const returnNeedConfirm = ref(false)
const returnConfirmationToken = ref('')
const returnChildrenDiffInfo = ref<{
removed: Array<Record<string, any>>
added: Array<Record<string, any>>
} | null>(null)
const batchTransferForm = ref({ target_type: 'department' as 'department' | 'idle', department_id: '', reason: '' })
const mountedChildIds = computed(() => new Set(list.value.flatMap((equipment) => (equipment.children || []).map((child) => child.id))))
const selectedBorrowedCount = computed(() => list.value.filter((equipment) => selectedIds.value.includes(equipment.id) && equipment.status === '已借出').length)
const selectedTransferBlockedCount = computed(() => list.value.filter((equipment) =>
selectedIds.value.includes(equipment.id) && ['维护中', '待报废', '已报废'].includes(equipment.status)
const mountedChildIds = computed(() => new Set([...selectionEquipment.value.values()].filter((equipment) => equipment.parent_id).map((equipment) => equipment.id)))
const selectedBorrowedCount = computed(() => selectedEquipment.value.filter((equipment) => equipment?.status === '已借出').length)
const selectedTransferBlockedCount = computed(() => selectedEquipment.value.filter((equipment) =>
!equipment || ['维护中', '待报废', '已报废', '已丢失'].includes(equipment.status || '')
).length)
const canSubmitTransfer = computed(() => Boolean(
batchTransferForm.value.reason.trim() &&
@ -1012,7 +1023,7 @@ const canSubmitReturn = computed(() => {
// 根据资产ID获取简要信息
const getEquipmentBrief = (id: number): string => {
const equipment = list.value.find(e => e.id === id)
const equipment = selectionEquipment.value.get(id)
if (!equipment) return `ID:${id}`
if (equipment.equipment_name.length <= 10) {
return equipment.equipment_name
@ -1022,23 +1033,6 @@ const getEquipmentBrief = (id: number): string => {
: equipment.equipment_code.slice(0, 12) + '...'
}
// 获取资产编码
const getEquipmentCode = (id: number): string => {
const equipment = list.value.find(e => e.id === id)
if (equipment) {
return equipment.equipment_code
}
for (const parentEquipment of list.value) {
const childEquipment = parentEquipment.children?.find(child => child.id === id)
if (childEquipment) {
return childEquipment.equipment_code
}
}
return ''
}
// 借出人员搜索
const fetchBorrowPersons = async (keyword = '') => {
const requestId = ++latestBorrowPersonRequestId
@ -1132,6 +1126,7 @@ const resetBorrowForm = () => {
}
const resetReturnForm = () => {
returnDialogScope.begin()
returnForm.value = {
return_condition: '正常',
notes: ''
@ -1142,6 +1137,7 @@ const resetReturnForm = () => {
returnSelectedRecordId.value = null
returnAnomalyInfo.value = null
returnNeedConfirm.value = false
returnConfirmationToken.value = ''
returnChildrenDiffInfo.value = null
}
@ -1149,6 +1145,12 @@ const clearSingleActionContext = () => {
singleActionContext.value = null
}
const finishSuccessfulAssetAction = (successfulCodes: Set<string>) => {
const originalSelection = singleActionContext.value?.previousSelectedIds || selectedIds.value
selectedIds.value = originalSelection.filter((id) => !successfulCodes.has(selectionEquipment.value.get(id)?.equipment_code || ''))
clearSingleActionContext()
}
const restoreSelectionAfterSingleActionCancel = (mode: 'borrow' | 'return') => {
if (singleActionContext.value?.mode === mode) {
selectedIds.value = [...singleActionContext.value.previousSelectedIds]
@ -1157,6 +1159,7 @@ const restoreSelectionAfterSingleActionCancel = (mode: 'borrow' | 'return') => {
}
const closeBatchBorrowModal = () => {
if (borrowSubmitting.value) return
showBatchBorrowModal.value = false
resetBorrowForm()
borrowSubmitting.value = false
@ -1164,6 +1167,7 @@ const closeBatchBorrowModal = () => {
}
const closeBatchReturnModal = () => {
if (returnSubmitting.value) return
showBatchReturnModal.value = false
resetReturnForm()
returnSubmitting.value = false
@ -1171,6 +1175,8 @@ const closeBatchReturnModal = () => {
}
const openBatchBorrowModal = () => {
if (borrowSubmitting.value || returnSubmitting.value) return
returnDialogScope.begin()
clearSingleActionContext()
resetBorrowForm()
fetchBorrowPersons()
@ -1179,10 +1185,12 @@ const openBatchBorrowModal = () => {
// 智能判断归还模式并打开弹窗
const openBatchReturnModalInternal = async (options: { preserveSingleActionContext?: boolean } = {}) => {
if (returnSubmitting.value || borrowSubmitting.value) return
if (!options.preserveSingleActionContext) {
clearSingleActionContext()
}
resetReturnForm()
const requestVersion = returnDialogScope.begin()
// 多资产选择:使用批量模式
if (selectedIds.value.length > 1) {
@ -1193,7 +1201,17 @@ const openBatchReturnModalInternal = async (options: { preserveSingleActionConte
// 单资产选择:检查是否需要记录选择或异常处理
const equipmentId = selectedIds.value[0]
const equipment = list.value.find(e => e.id === equipmentId)
if (!equipmentId) return
const isCurrentReturn = () => returnDialogScope.isCurrent(requestVersion) && selectedIds.value.length === 1 && selectedIds.value[0] === equipmentId
let equipment: Equipment | undefined
try {
equipment = (await getEquipmentDetail(equipmentId)).data
if (!isCurrentReturn()) return
} catch (error: any) {
if (!isCurrentReturn()) return
alert(error?.message || '读取选中资产失败')
return
}
if (!equipment) {
alert('未找到选中的资产')
@ -1210,12 +1228,14 @@ const openBatchReturnModalInternal = async (options: { preserveSingleActionConte
// 需要加载借用记录
try {
const response = await getBorrowRecordByCode(equipment.equipment_code)
if (!isCurrentReturn()) return
if (response.data) {
const { can_return, borrow_records, ...assetInfo } = response.data
if (!can_return) {
// 检查是否是异常资产
const searchResponse = await searchReturnAssets({ keyword: equipment.equipment_code })
if (!isCurrentReturn()) return
const anomalyAsset = searchResponse.data?.find(
(a: ReturnSearchAssetCandidate) => a.equipment_id === equipmentId &&
(a.search_state === 'borrow_record_missing' || !a.can_return)
@ -1252,9 +1272,11 @@ const openBatchReturnModalInternal = async (options: { preserveSingleActionConte
showBatchReturnModal.value = true
}
} catch (error: any) {
if (!isCurrentReturn()) return
// 检查是否是异常资产
try {
const searchResponse = await searchReturnAssets({ keyword: equipment.equipment_code })
if (!isCurrentReturn()) return
const anomalyAsset = searchResponse.data?.find(
(a: ReturnSearchAssetCandidate) => a.equipment_id === equipmentId &&
(a.search_state === 'borrow_record_missing' || !a.can_return)
@ -1274,8 +1296,7 @@ const openBatchReturnModalInternal = async (options: { preserveSingleActionConte
} catch {
// 忽略
}
alert(error.message || '加载借用记录失败')
if (isCurrentReturn()) alert(error.message || '加载借用记录失败')
}
}
@ -1295,15 +1316,18 @@ const openAnomalyHandlerFromReturn = () => {
}
const startSingleAction = (mode: 'borrow' | 'return', equipment: Equipment) => {
returnDialogScope.begin()
selectionEquipment.value.set(equipment.id, equipment)
singleActionContext.value = {
mode,
previousSelectedIds: [...selectedIds.value],
previousSelectedIds: [...(singleActionContext.value?.previousSelectedIds || selectedIds.value)],
equipment
}
selectedIds.value = [equipment.id]
}
const fetchData = async () => {
const requestVersion = listRequestScope.begin()
const params = {
page: currentPage.value,
pageSize: pageSize.value,
@ -1315,10 +1339,19 @@ const fetchData = async () => {
storageLocationId: filters.value.storageLocationId ? Number(filters.value.storageLocationId) : undefined,
departmentId: filters.value.departmentId ? Number(filters.value.departmentId) : undefined
}
try {
const res = await getEquipmentList(params)
if (!listRequestScope.isCurrent(requestVersion)) return
if (res.data) {
list.value = res.data.list
total.value = res.data.total
// Retain selected assets from other pages, replacing data for the page just fetched.
const retained = new Map([...selectionEquipment.value].filter(([id]) => selectedIds.value.includes(id) || singleActionContext.value?.previousSelectedIds.includes(id)))
for (const [id, equipment] of indexEquipmentSelection(res.data.list)) retained.set(id, equipment)
selectionEquipment.value = retained
}
} catch (error: any) {
if (listRequestScope.isCurrent(requestVersion)) alert(error?.message || '读取资产列表失败')
}
}
@ -1513,7 +1546,7 @@ const handleFileImport = async (e: Event) => {
const workbook = XLSX.read(buffer, { type: 'array' })
const sheetName = workbook.SheetNames[0]
const sheet = workbook.Sheets[sheetName]
const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 })
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, blankrows: true })
if (rows.length < 2) {
alert('文件内容为空或格式不正确')
@ -1521,18 +1554,8 @@ const handleFileImport = async (e: Event) => {
return
}
const equipments = rows.slice(1).map((cols) => ({
equipment_name: String(cols[0] || '').trim(),
equipment_type: String(cols[1] || '').trim(),
equipment_model: cols[2] ? String(cols[2]).trim() : undefined,
department: cols[3] ? String(cols[3]).trim() : undefined,
storage_location: cols[4] ? String(cols[4]).trim() : undefined,
unit_price: cols[5] ? Number(cols[5]) : undefined,
total_quantity: Number(cols[6]) || 1,
supplier: cols[7] ? String(cols[7]).trim() : undefined,
purchase_date: cols[8] ? String(cols[8]).trim() : undefined,
description: cols[9] ? String(cols[9]).trim() : undefined
})).filter((item) => item.equipment_name && item.equipment_type && item.total_quantity > 0)
const headerRowOffset = sheet['!ref'] ? XLSX.utils.decode_range(sheet['!ref']).s.r : 0
const equipments = parseEquipmentImportRows(rows, headerRowOffset)
if (equipments.length === 0) {
alert('没有有效的导入数据')
@ -1558,12 +1581,12 @@ const handleFileImport = async (e: Event) => {
// 提交借出 - 使用 scanBorrow API
const submitBatchBorrow = async () => {
if (!borrowSelectedPersonId.value || selectedIds.value.length === 0) return
if (borrowSubmitting.value || !borrowSelectedPersonId.value || selectedIds.value.length === 0) return
borrowSubmitting.value = true
try {
const equipmentCodes = selectedIds.value.map(id => getEquipmentCode(id)).filter(Boolean)
const equipmentCodes = resolveSelectedEquipmentCodes(selectedIds.value, selectionEquipment.value)
if (equipmentCodes.length === 0) {
alert('未找到有效的资产编码')
@ -1586,8 +1609,7 @@ const submitBatchBorrow = async () => {
alert(`借出成功!资产 ${response.data.equipment_name} 已出库。`)
showBatchBorrowModal.value = false
resetBorrowForm()
selectedIds.value = []
clearSingleActionContext()
finishSuccessfulAssetAction(new Set([equipmentCodes[0]]))
fetchData()
}
} else {
@ -1615,8 +1637,8 @@ const submitBatchBorrow = async () => {
alert(message)
showBatchBorrowModal.value = false
resetBorrowForm()
selectedIds.value = []
clearSingleActionContext()
const successfulCodes = new Set(results.filter((item) => item.status === 'success').map((item) => item.equipment_code))
finishSuccessfulAssetAction(successfulCodes)
fetchData()
}
}
@ -1635,11 +1657,11 @@ const submitBatchReturn = async () => {
try {
// 如果已经处于需要确认的状态,直接提交带确认标志
const confirmChildrenDiff = returnNeedConfirm.value
const confirmationToken = returnNeedConfirm.value ? returnConfirmationToken.value : undefined
if (returnMode.value === 'batch') {
// 批量归还模式
const equipmentCodes = selectedIds.value.map(id => getEquipmentCode(id)).filter(Boolean)
const equipmentCodes = resolveSelectedEquipmentCodes(selectedIds.value, selectionEquipment.value)
if (equipmentCodes.length === 0) {
alert('未找到有效的资产编码')
@ -1652,23 +1674,24 @@ const submitBatchReturn = async () => {
equipment_code: equipmentCodes[0],
return_condition: returnForm.value.return_condition,
notes: returnForm.value.notes || undefined,
confirm_children_diff: confirmChildrenDiff || undefined
confirmation_token: confirmationToken
})
if (response.data) {
// 检查是否需要二次确认(配件差异等)
if (response.data.need_confirm && !confirmChildrenDiff) {
if (response.data.need_confirm) {
returnNeedConfirm.value = true
returnConfirmationToken.value = response.data.confirmation_token || ''
if (!returnConfirmationToken.value) throw new Error('配件差异确认信息不完整,请刷新后重试')
returnChildrenDiffInfo.value = response.data.children_diff || null
returnSubmitting.value = false
return
}
alert(`归还成功!资产 ${response.data.equipment_name} 已入库。`)
alert(`资产 ${response.data.equipment_name} ${getReturnCompletionText(returnForm.value.return_condition)}。`)
showBatchReturnModal.value = false
resetReturnForm()
selectedIds.value = []
clearSingleActionContext()
finishSuccessfulAssetAction(new Set([equipmentCodes[0]]))
fetchData()
}
} else {
@ -1682,7 +1705,7 @@ const submitBatchReturn = async () => {
if (response.data) {
const { success_count, failed_count, results } = response.data
let message = `批量归还完成:成功 ${success_count} 项,失败 ${failed_count} 项`
let message = `批量${returnForm.value.return_condition === '丢失' ? '丢失登记完成(未入仓)' : '归还完成'}:成功 ${success_count} 项,失败 ${failed_count} 项`
const failedItems = results.filter(r => r.status !== 'success')
if (failedItems.length > 0) {
@ -1692,8 +1715,8 @@ const submitBatchReturn = async () => {
alert(message)
showBatchReturnModal.value = false
resetReturnForm()
selectedIds.value = []
clearSingleActionContext()
const successfulCodes = new Set(results.filter((item) => item.status === 'success').map((item) => item.equipment_code))
finishSuccessfulAssetAction(successfulCodes)
fetchData()
}
}
@ -1705,23 +1728,24 @@ const submitBatchReturn = async () => {
notes: returnForm.value.notes || undefined,
person_id: returnSelectedRecord.value.person_id,
borrow_record_id: returnSelectedRecord.value.id,
confirm_children_diff: confirmChildrenDiff || undefined
confirmation_token: confirmationToken
})
if (response.data) {
// 检查是否需要二次确认(配件差异等)
if (response.data.need_confirm && !confirmChildrenDiff) {
if (response.data.need_confirm) {
returnNeedConfirm.value = true
returnConfirmationToken.value = response.data.confirmation_token || ''
if (!returnConfirmationToken.value) throw new Error('配件差异确认信息不完整,请刷新后重试')
returnChildrenDiffInfo.value = response.data.children_diff || null
returnSubmitting.value = false
return
}
alert(`归还成功!资产 ${response.data.equipment_name} 已入库。`)
alert(`资产 ${response.data.equipment_name} ${getReturnCompletionText(returnForm.value.return_condition)}。`)
showBatchReturnModal.value = false
resetReturnForm()
selectedIds.value = []
clearSingleActionContext()
finishSuccessfulAssetAction(new Set([response.data.equipment_code]))
fetchData()
}
}
@ -1744,7 +1768,8 @@ const submitBatchTransfer = async () => {
alert(`批量调拨完成:成功 ${res.data?.success_count || 0} 项,失败 ${res.data?.failed_count || 0} 项`)
showBatchTransferModal.value = false
batchTransferForm.value = { target_type: 'department', department_id: '', reason: '' }
selectedIds.value = []
const successfulIds = new Set(res.data?.results.filter((item) => item.status === 'success').map((item) => item.equipment_id) || [])
selectedIds.value = selectedIds.value.filter((id) => !successfulIds.has(id))
fetchData()
} catch (error: any) {
alert(error?.message || '资产调拨失败')
@ -1840,7 +1865,9 @@ const getStatusBadgeClass = (status: string) => {
'已借出': 'badge-warning',
'维护中': 'badge-error',
'待报废': 'badge-warning',
'已报废': 'badge-neutral'
'已报废': 'badge-neutral',
'已损坏': 'badge-error',
'已丢失': 'badge-error'
}
return map[status] || 'badge-neutral'
}
@ -1865,10 +1892,10 @@ const canReturnSingle = (equipment: Equipment) => {
const canHandleBorrowAnomaly = (equipment: Equipment) => canBorrow.value && hasBorrowAnomaly(equipment)
const canMaintainAsset = (equipment: Equipment) => canMaintain.value && !equipment.parent_id && !['已借出', '维护中', '待报废', '已报废'].includes(equipment.status)
const canMaintainAsset = (equipment: Equipment) => canMaintain.value && !equipment.parent_id && !['已借出', '维护中', '待报废', '已报废', '已丢失'].includes(equipment.status)
const canDisposeAsset = (equipment: Equipment) =>
canApplyDisposal.value && !equipment.parent_id && equipment.status !== '已报废'
canApplyDisposal.value && !equipment.parent_id && !['已报废', '已丢失'].includes(equipment.status)
const getDisposalActionLabel = (equipment: Equipment, variant: 'short' | 'long') => {
if (equipment.status === '已借出') return variant === 'short' ? '入库后报废' : '入库后申请报废'
@ -1877,6 +1904,7 @@ const getDisposalActionLabel = (equipment: Equipment, variant: 'short' | 'long')
}
const openSingleBorrowModal = (equipment: Equipment) => {
if (borrowSubmitting.value || returnSubmitting.value) return
startSingleAction('borrow', equipment)
resetBorrowForm()
fetchBorrowPersons()
@ -1884,7 +1912,9 @@ const openSingleBorrowModal = (equipment: Equipment) => {
}
const openSingleReturnAction = async (equipment: Equipment) => {
if (borrowSubmitting.value || returnSubmitting.value) return
startSingleAction('return', equipment)
resetReturnForm()
// 简单情况直接走批量模式
if (canQuickReturnInline(equipment)) {
@ -1944,4 +1974,10 @@ onMounted(() => {
fetchOptions()
fetchData()
})
onBeforeUnmount(() => {
listRequestScope.dispose()
returnDialogScope.dispose()
if (borrowPersonSearchTimer) window.clearTimeout(borrowPersonSearchTimer)
})
</script>

View File

@ -465,6 +465,7 @@
</template>
<script setup lang="ts">
import { getReturnCompletionText, submitReturnWithConfirmation } from '@/utils/returnConfirmation'
import { computed, onMounted, ref, watch } from 'vue'
import { formatDateTime } from '@/utils/format'
import type {
@ -920,24 +921,13 @@ const submitQuickReturn = async () => {
quickReturnSubmitting.value = true
try {
const response = await scanReturn(payload)
if (response.data?.need_confirm) {
const confirmed = window.confirm('系统检测到配件与借出时不一致,确认继续入库吗?')
if (!confirmed) {
quickReturnSubmitting.value = false
return
}
await scanReturn({
...payload,
confirm_children_diff: true
})
}
const result = await submitReturnWithConfirmation(payload, scanReturn, (message) => window.confirm(message))
if (!result) return
markQuickReturnAssetProcessed(quickReturnAsset.value)
quickReturnNotice.value = {
type: 'success',
message: `${quickReturnBorrowInfo.value.equipment_name} 已为 ${record.person_nickname} 完成入库。你现在可以继续处理下一件。`
message: `${quickReturnBorrowInfo.value.equipment_name} ${getReturnCompletionText(payload.return_condition)}(使用者:${record.person_nickname})。你现在可以继续处理下一件。`
}
closeQuickReturnPanel()
} catch (error: any) {

View File

@ -19,7 +19,7 @@
<div class="page-meta">
<p class="page-meta__label">已完成</p>
<p class="page-meta__value">{{ completedCount }}</p>
<p class="page-meta__subvalue">成功出纸</p>
<p class="page-meta__subvalue">打印助手已回报完成</p>
</div>
<div class="page-meta">
<p class="page-meta__label">失败</p>
@ -42,12 +42,13 @@
<option value="completed">已完成</option>
<option value="failed">失败</option>
<option value="cancelled">已取消</option>
<option value="result_unknown">结果待核实</option>
</select>
</div>
<div class="field-stack">
<label class="field-label">当前说明</label>
<p class="text-sm leading-6 text-text-secondary">
这里只展示最近远程打印任务。排队中的任务会等待默认打印电脑上线;当前前端先不做手动改派。
排队任务会等待打印电脑上线。结果待核实的任务已停止自动重印,请先核对打印机队列和实际出纸情况。
</p>
</div>
</div>
@ -113,6 +114,9 @@
{{ busyJobId === job.id ? '取消中...' : '取消任务' }}
</button>
</div>
<div v-if="job.status === 'result_unknown' && canResolve" class="mt-4 flex justify-end">
<button class="btn-secondary" :disabled="busyJobId === job.id" @click="openResolution(job)">核实打印结果</button>
</div>
</article>
<p v-if="!loading && jobs.length === 0" class="rounded-xl border border-border bg-bg-elevated px-4 py-8 text-center text-sm text-text-tertiary">
@ -161,6 +165,7 @@
>
{{ busyJobId === job.id ? '取消中' : '取消任务' }}
</button>
<button v-else-if="job.status === 'result_unknown' && canResolve" class="table-action" :disabled="busyJobId === job.id" @click="openResolution(job)">核实打印结果</button>
<span v-else class="text-xs text-text-tertiary">-</span>
</td>
</tr>
@ -180,12 +185,38 @@
</div>
</div>
</section>
<div v-if="resolutionJob" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<section class="card max-h-[90vh] w-full max-w-lg space-y-4 overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="print-resolution-title">
<h3 id="print-resolution-title" class="section-title">核实打印任务 #{{ resolutionJob.id }}</h3>
<p class="text-sm text-text-secondary">{{ getTerminalLabel(resolutionJob) }} · {{ getEquipmentCount(resolutionJob) }} 张标签。请检查打印机队列和纸张,无法确定时先关闭此窗口。</p>
<div class="field-stack">
<label class="field-label" for="print-resolution-outcome">核实结果</label>
<select id="print-resolution-outcome" v-model="resolutionOutcome" class="select">
<option value="">请选择实际结果</option>
<option value="printed">已出纸,标记完成</option>
<option value="not_printed">确认未出纸,重新排队</option>
<option value="cancel">取消后续打印</option>
</select>
</div>
<p v-if="resolutionOutcome === 'not_printed'" class="text-sm text-status-warning">重新排队会再次打印。必须先确认原任务未出纸,避免重复标签。</p>
<label class="flex items-start gap-3 text-sm text-text-secondary">
<input v-model="resolutionVerified" type="checkbox" class="mt-1 rounded" />
<span>已核对打印机队列和实际出纸情况,并确认所选结果。</span>
</label>
<div class="flex justify-end gap-4">
<button class="btn-secondary" :disabled="busyJobId !== null" @click="resolutionJob = null">暂不处理</button>
<button class="btn-primary" :disabled="!resolutionOutcome || !resolutionVerified || busyJobId !== null" @click="submitResolution">{{ busyJobId !== null ? '提交中...' : '确认处理' }}</button>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { cancelQueuedPrintJob, getPrintJobs } from '@/api/print'
import { cancelQueuedPrintJob, getPrintJobs, resolvePrintJob } from '@/api/print'
import { useAuthStore } from '@/stores/auth'
import { PERMISSIONS } from '@/auth/permissions'
import type { PrintJob, PrintJobStatus } from '@/types'
import { formatDateTime } from '@/utils/format'
@ -196,6 +227,11 @@ const pageSize = 12
const loading = ref(false)
const busyJobId = ref<number | null>(null)
const statusFilter = ref<PrintJobStatus | ''>('')
const authStore = useAuthStore()
const canResolve = computed(() => authStore.can(PERMISSIONS.SYSTEM_SETTINGS))
const resolutionJob = ref<PrintJob | null>(null)
const resolutionOutcome = ref<'printed' | 'not_printed' | 'cancel' | ''>('')
const resolutionVerified = ref(false)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const queuedCount = computed(() => jobs.value.filter((job) => job.status === 'queued').length)
@ -224,12 +260,14 @@ const getJobStatusText = (status?: string | null) => {
if (status === 'completed') return '已完成'
if (status === 'failed') return '失败'
if (status === 'cancelled') return '已取消'
if (status === 'result_unknown') return '结果待核实'
return status || '未知'
}
const getJobStatusBadgeClass = (status?: string | null) => {
if (status === 'completed') return 'badge-success'
if (status === 'failed') return 'badge-error'
if (status === 'result_unknown') return 'badge-warning'
if (status === 'queued' || status === 'claimed' || status === 'printing') return 'badge-warning'
return 'badge-neutral'
}
@ -249,6 +287,7 @@ const getEquipmentCount = (job: PrintJob) => {
}
const getJobResultText = (job: PrintJob) => {
if (job.status === 'result_unknown') return '终端回执中断,需核对出纸情况;系统不会自动重印。'
if (job.error_message) return job.error_message
if (job.status === 'completed') {
return job.completed_at ? `已完成,时间 ${formatNullableDateTime(job.completed_at)}` : '已完成'
@ -312,6 +351,34 @@ const handleCancelJob = async (job: PrintJob) => {
}
}
const openResolution = (job: PrintJob) => {
if (!canResolve.value || job.status !== 'result_unknown') return
resolutionJob.value = { ...job }
resolutionOutcome.value = ''
resolutionVerified.value = false
}
const submitResolution = async () => {
const job = resolutionJob.value
if (!canResolve.value || !job || !resolutionOutcome.value || !resolutionVerified.value || busyJobId.value !== null) return
busyJobId.value = job.id
try {
await resolvePrintJob(job.id, {
confirmed: true,
outcome: resolutionOutcome.value,
expected_claim_token: job.claim_token ?? null
})
resolutionJob.value = null
await fetchJobs()
} catch (error: any) {
alert(error?.message || '任务状态已变化,请刷新后重新核实')
resolutionJob.value = null
await fetchJobs()
} finally {
busyJobId.value = null
}
}
onMounted(() => {
fetchJobs()
})

View File

@ -140,6 +140,7 @@
</div>
</div>
<p v-if="(terminal.protocol_version || 0) < 2" class="text-sm text-status-warning">打印助手需要升级,请在这台电脑安装兼容版本后再接单。</p>
<div v-if="getTerminalError(terminal)" class="p-2.5 rounded-md bg-status-error/10 border border-status-error/20">
<p class="text-xs text-status-error">{{ getTerminalError(terminal) }}</p>
</div>
@ -373,6 +374,7 @@ const getTerminalError = (terminal: PrintTerminal) => {
const isTerminalReady = (terminal: PrintTerminal) => {
if (terminal.status === 'disabled') return false
if ((terminal.protocol_version || 0) < 2) return false
if (typeof terminal.readiness?.is_ready === 'boolean') {
return terminal.readiness.is_ready
}
@ -420,6 +422,8 @@ const getTerminalReadinessText = (terminal: PrintTerminal) => {
return '这台终端已停用,不会再被派发远程打印任务。'
}
if ((terminal.protocol_version || 0) < 2) return '打印助手需要升级,当前不能接单。'
if (terminal.status !== 'online') {
return '电脑离线时仍会保留登记信息,等重新上线后再恢复状态。'
}

View File

@ -8,9 +8,9 @@
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">在册资产价值</p>
<p class="page-meta__label">资产账面价值</p>
<p class="page-meta__value">{{ formatSummaryCurrency(summary.total_value) }}</p>
<p class="page-meta__subvalue">当前筛选 · 不含已报废</p>
<p class="page-meta__subvalue">不含已报废;已丢失仍保留账面成本</p>
</div>
<div class="page-meta">
<p class="page-meta__label">可用价值</p>
@ -295,7 +295,7 @@ const initCharts = (data: ValueStatistics) => {
{ value: data.available_value, name: '可用价值', itemStyle: { color: darkColors.available } },
{ value: data.borrowed_value, name: '借出价值', itemStyle: { color: darkColors.borrowed } },
{ value: data.maintenance_value, name: '维修价值', itemStyle: { color: darkColors.maintenance } },
{ value: data.other_value, name: '其他在册价值', itemStyle: { color: darkColors.other } }
{ value: data.other_value, name: '其他账面价值', itemStyle: { color: darkColors.other } }
]
}
]

View File

@ -6,9 +6,10 @@
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.2.5</Version>
<AssemblyVersion>0.2.5.0</AssemblyVersion>
<FileVersion>0.2.5.0</FileVersion>
<Version>0.2.7</Version>
<AssemblyVersion>0.2.7.0</AssemblyVersion>
<FileVersion>0.2.7.0</FileVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Company>常州简尚家具有限公司</Company>
<Product>固定资产打印助手</Product>
<DebugType>none</DebugType>

View File

@ -122,6 +122,8 @@ internal sealed class AssetLabelPayload
internal sealed class PrintJobDto
{
[JsonPropertyName("claim_token")]
public string ClaimToken { get; set; } = string.Empty;
[JsonPropertyName("id")]
public int Id { get; set; }
@ -135,6 +137,26 @@ internal sealed class PrintJobDto
public JsonElement PayloadJson { get; set; }
}
internal sealed class PrintJobState
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("claim_token")]
public string? ClaimToken
{
get => _claimToken;
set { _claimToken = value; ClaimTokenWasProvided = true; }
}
private string? _claimToken;
[JsonIgnore]
public bool ClaimTokenWasProvided { get; private set; }
public bool HasValidState() => ClaimTokenWasProvided &&
Status is "queued" or "claimed" or "printing" or "result_unknown" or "completed" or "cancelled" or "failed";
}
internal sealed class PrintJobClaimData
{
[JsonPropertyName("job")]

View File

@ -8,6 +8,7 @@ namespace FixedAssetPrintAgent;
internal sealed class PrintAgentRuntime : IDisposable
{
private readonly AgentConfigurationStore _store;
private readonly IPrintResultJournal _journal;
private readonly PrintAgentApiClient _apiClient = new();
private readonly LabelPrintService _labelPrintService = new();
private readonly WindowsStartupManager _startupManager;
@ -26,6 +27,7 @@ internal sealed class PrintAgentRuntime : IDisposable
public PrintAgentRuntime(string storageRoot, string appVersion, string executablePath)
{
_store = new AgentConfigurationStore(storageRoot);
_journal = new PrintResultJournal(storageRoot);
_startupManager = new WindowsStartupManager(executablePath);
_appVersion = appVersion;
}
@ -211,9 +213,22 @@ internal sealed class PrintAgentRuntime : IDisposable
return;
}
await _pollSemaphore.WaitAsync();
if (!await _pollSemaphore.WaitAsync(0)) return;
try
{
var pending = await _journal.LoadAsync();
if (pending is not null)
{
var configuration = CurrentState.Configuration.Clone();
if (pending.ServerUrl != configuration.ServerUrl || pending.TerminalId != configuration.TerminalId)
throw new InvalidOperationException("上一任务属于其他服务器或终端,请恢复原配置并核实结果。");
await PrintResultRecovery.AcknowledgeAsync(pending, _journal,
(id, token, status, error) => _apiClient.ReportJobStatusAsync(configuration.ServerUrl,
configuration.DeviceToken, id, status, error, false, token),
id => _apiClient.GetJobStateAsync(configuration.ServerUrl, configuration.DeviceToken, id), AppendLog);
EmitState();
return;
}
var claimResult = await _apiClient.ClaimNextJobAsync(
CurrentState.Configuration.ServerUrl,
CurrentState.Configuration.DeviceToken,
@ -238,7 +253,7 @@ internal sealed class PrintAgentRuntime : IDisposable
CurrentState.LastError = ex.Message;
if (!suppressErrors)
{
AppendLog($"领取任务失败:{ex.Message}");
AppendLog($"打印任务处理或结果回写失败:{ex.Message}");
EmitState();
}
}
@ -299,100 +314,21 @@ internal sealed class PrintAgentRuntime : IDisposable
private async Task ProcessPrintJobAsync(PrintJobDto job)
{
await _apiClient.ReportJobStatusAsync(
CurrentState.Configuration.ServerUrl,
CurrentState.Configuration.DeviceToken,
job.Id,
"printing",
null,
false);
if (!string.Equals(job.JobType, "asset_label", StringComparison.OrdinalIgnoreCase))
var configuration = CurrentState.Configuration.Clone();
await PrintResultRecovery.ExecuteAsync(job, configuration, _journal,
(id, token, status, error) => _apiClient.ReportJobStatusAsync(configuration.ServerUrl,
configuration.DeviceToken, id, status, error, false, token),
id => _apiClient.GetJobStateAsync(configuration.ServerUrl, configuration.DeviceToken, id),
async () =>
{
await _apiClient.ReportJobStatusAsync(
CurrentState.Configuration.ServerUrl,
CurrentState.Configuration.DeviceToken,
job.Id,
"completed",
null,
false);
CurrentState.LastJobResult = $"任务 {job.Id} 不是资产标签,已标记完成。";
AppendLog(CurrentState.LastJobResult);
EmitState();
return;
}
var payload = ResolvePayload(job);
if (payload is null)
{
const string message = "当前打印任务缺少可用的资产标签数据,已阻止实际出纸。";
await _apiClient.ReportJobStatusAsync(
CurrentState.Configuration.ServerUrl,
CurrentState.Configuration.DeviceToken,
job.Id,
"failed",
message,
false);
CurrentState.LastError = message;
CurrentState.LastJobResult = message;
AppendLog(message);
EmitState();
return;
}
if (payload.RenderedLabels is null || payload.RenderedLabels.Count == 0)
{
const string message = "当前任务缺少网页生成的成品标签图,请在系统里重新发送打印。";
await _apiClient.ReportJobStatusAsync(
CurrentState.Configuration.ServerUrl,
CurrentState.Configuration.DeviceToken,
job.Id,
"failed",
message,
false);
CurrentState.LastError = message;
CurrentState.LastJobResult = message;
AppendLog(message);
EmitState();
return;
}
try
{
var printedCount = await _labelPrintService.PrintRenderedLabelImagesAsync(
CurrentState.Configuration.SelectedPrinterName,
payload.RenderedLabels);
await _apiClient.ReportJobStatusAsync(
CurrentState.Configuration.ServerUrl,
CurrentState.Configuration.DeviceToken,
job.Id,
"completed",
null,
false);
if (payload?.Items.Count is not > 0 || payload.RenderedLabels.Count == 0)
throw new InvalidOperationException("缺少可用成品标签图,已阻止实际出纸。");
var count = await _labelPrintService.PrintRenderedLabelImagesAsync(
configuration.SelectedPrinterName, payload.RenderedLabels);
CurrentState.LastJobResult = $"任务 {job.Id} 已发送到 {configuration.SelectedPrinterName},共 {count} 张。";
CurrentState.LastError = string.Empty;
CurrentState.LastJobResult = $"任务 {job.Id} 已发送到 {CurrentState.Configuration.SelectedPrinterName},共 {printedCount} 张。";
AppendLog(CurrentState.LastJobResult);
}
catch (Exception ex)
{
await _apiClient.ReportJobStatusAsync(
CurrentState.Configuration.ServerUrl,
CurrentState.Configuration.DeviceToken,
job.Id,
"failed",
ex.Message,
false);
CurrentState.LastError = ex.Message;
CurrentState.LastJobResult = ex.Message;
AppendLog($"任务 {job.Id} 打印失败:{ex.Message}");
}
}, AppendLog);
EmitState();
}
@ -596,7 +532,7 @@ internal sealed class WindowsStartupManager
internal sealed class PrintAgentApiClient : IDisposable
{
private readonly HttpClient _httpClient = new();
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) };
internal static JsonSerializerOptions SerializerOptions { get; } = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
@ -647,7 +583,8 @@ internal sealed class PrintAgentApiClient : IDisposable
platform,
selected_printer_name = selectedPrinterName,
printer_snapshot = printerSnapshot,
label_profile = labelProfile
label_profile = labelProfile,
protocol_version = 2
},
deviceToken,
true);
@ -661,32 +598,44 @@ internal sealed class PrintAgentApiClient : IDisposable
HttpMethod.Post,
new
{
terminal_id = terminalId
terminal_id = terminalId,
protocol_version = 2
},
deviceToken,
true);
}
public Task ReportJobStatusAsync(
public async Task ReportJobStatusAsync(
string serverUrl,
string deviceToken,
int jobId,
string status,
string? errorMessage,
bool retryable)
bool retryable,
string claimToken)
{
return SendAsync<object>(
var result = await SendAsync<PrintJobState>(
serverUrl,
$"/api/print-agent/jobs/{jobId}/status",
HttpMethod.Post,
new
{
status,
claim_token = claimToken,
error_message = errorMessage,
retryable
},
deviceToken,
true);
if (!result.HasValidState() || result.Id != jobId || result.ClaimToken != claimToken ||
!(result.Status == status || (status == "failed" && result.Status == "result_unknown")))
throw new InvalidOperationException("服务器未返回明确任务结果,保留本地恢复记录。");
}
public Task<PrintJobState> GetJobStateAsync(string serverUrl, string deviceToken, int jobId)
{
return SendAsync<PrintJobState>(serverUrl, $"/api/print-agent/jobs/{jobId}/state",
HttpMethod.Get, null, deviceToken, true);
}
public Task ReportTestPrintAsync(
@ -781,7 +730,8 @@ internal sealed class PrintAgentApiClient : IDisposable
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(GetMessage(raw) ?? $"请求失败({(int)response.StatusCode})");
throw new PrintAgentRequestException((int)response.StatusCode,
GetMessage(raw) ?? $"请求失败({(int)response.StatusCode})");
}
if (string.IsNullOrWhiteSpace(raw))

View File

@ -0,0 +1,176 @@
using System.Text.Json;
namespace FixedAssetPrintAgent;
internal sealed class PendingPrintResult
{
public int JobId { get; set; }
public string ClaimToken { get; set; } = string.Empty;
public string ServerUrl { get; set; } = string.Empty;
public int TerminalId { get; set; }
public string Phase { get; set; } = "claimed";
public string? ErrorMessage { get; set; }
}
internal interface IPrintResultJournal
{
Task<PendingPrintResult?> LoadAsync();
Task SaveAsync(PendingPrintResult result);
Task ClearAsync();
}
internal sealed class PrintResultJournal(string storageRoot) : IPrintResultJournal
{
private readonly string _filePath = Path.Combine(storageRoot, "pending-print-result.json");
private readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web);
public async Task<PendingPrintResult?> LoadAsync()
{
if (!File.Exists(_filePath)) return null;
await using var stream = File.OpenRead(_filePath);
var result = await JsonSerializer.DeserializeAsync<PendingPrintResult>(stream, _options);
if (result is null || result.JobId <= 0 || string.IsNullOrWhiteSpace(result.ClaimToken) ||
result.Phase is not ("claimed" or "dispatching" or "completed_pending_ack" or "unknown_pending_ack" or "failed_pending_ack"))
throw new InvalidOperationException("本地打印恢复记录损坏,请先核实上一任务出纸情况。");
return result;
}
public async Task SaveAsync(PendingPrintResult result)
{
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
var temporaryPath = $"{_filePath}.{Guid.NewGuid():N}.tmp";
try
{
await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
await JsonSerializer.SerializeAsync(stream, result, _options);
stream.Flush(true);
}
File.Move(temporaryPath, _filePath, true);
}
finally
{
if (File.Exists(temporaryPath)) File.Delete(temporaryPath);
}
}
public Task ClearAsync()
{
File.Delete(_filePath);
return Task.CompletedTask;
}
}
internal sealed class PrintAgentRequestException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}
internal static class PrintResultRecovery
{
// Acknowledgment retries never enter the printer. Inspect a conflicting result
// before clearing the journal; only a fenced attempt or final server state wins.
public static async Task AcknowledgeAsync(
PendingPrintResult pending,
IPrintResultJournal journal,
Func<int, string, string, string?, Task> report,
Func<int, Task<PrintJobState>> readState,
Action<string> log)
{
var status = pending.Phase == "completed_pending_ack" ? "completed" :
pending.Phase == "failed_pending_ack" ? "failed" : "result_unknown";
try
{
await report(pending.JobId, pending.ClaimToken, status, pending.ErrorMessage ??
(status == "result_unknown" ? "打印过程曾中断,请人工核实出纸结果。" : null));
await journal.ClearAsync();
log(status == "completed" ? $"任务 {pending.JobId} 完成结果已确认。" :
$"任务 {pending.JobId} 结果已上报,请到系统查看。" );
}
catch (PrintAgentRequestException ex) when (ex.StatusCode == 409)
{
var current = await readState(pending.JobId);
if (!current.HasValidState() || current.Id != pending.JobId)
throw new InvalidOperationException("服务器任务状态响应不完整,保留恢复记录并等待核实。");
if (current.ClaimToken != pending.ClaimToken || current.Status is "completed" or "cancelled" or "failed")
{
await journal.ClearAsync();
log($"任务 {pending.JobId} 已由服务器处理,请核对系统中的结果。");
return;
}
throw;
}
}
public static async Task ExecuteAsync(
PrintJobDto job,
AgentConfiguration configuration,
IPrintResultJournal journal,
Func<int, string, string, string?, Task> report,
Func<int, Task<PrintJobState>> readState,
Func<Task> dispatch,
Action<string> log,
TimeSpan? renewalInterval = null)
{
if (string.IsNullOrWhiteSpace(job.ClaimToken))
throw new InvalidOperationException("服务器缺少领取凭据,已阻止实际出纸,请更新服务器。");
var pending = new PendingPrintResult
{
JobId = job.Id, ClaimToken = job.ClaimToken, ServerUrl = configuration.ServerUrl,
TerminalId = configuration.TerminalId, Phase = "claimed"
};
await journal.SaveAsync(pending);
if (job.JobType != "asset_label")
{
pending.Phase = "failed_pending_ack";
pending.ErrorMessage = "不支持的打印任务类型,未开始打印。";
await journal.SaveAsync(pending);
await AcknowledgeAsync(pending, journal, report, readState, log);
return;
}
await report(job.Id, job.ClaimToken, "printing", null);
// Persist before any spool call. On restart this phase becomes result_unknown.
pending.Phase = "dispatching";
await journal.SaveAsync(pending);
using var cancellation = new CancellationTokenSource();
var renewal = RenewAsync(job, report, log, renewalInterval ?? TimeSpan.FromSeconds(30), cancellation.Token);
try
{
await dispatch();
pending.Phase = "completed_pending_ack";
pending.ErrorMessage = null;
}
catch (Exception ex)
{
// Some labels may already be spooled. A partial failure cannot be retried
// as a whole batch until an administrator verifies the actual output.
pending.Phase = "unknown_pending_ack";
pending.ErrorMessage = ex.Message;
}
finally
{
cancellation.Cancel();
await renewal;
}
await journal.SaveAsync(pending);
// This is deliberately outside the dispatch catch. Network failure after
// printing keeps completed_pending_ack and cannot become a print failure.
await AcknowledgeAsync(pending, journal, report, readState, log);
}
private static async Task RenewAsync(
PrintJobDto job, Func<int, string, string, string?, Task> report,
Action<string> log, TimeSpan interval, CancellationToken cancellation)
{
while (!cancellation.IsCancellationRequested)
{
try
{
await Task.Delay(interval, cancellation);
if (!cancellation.IsCancellationRequested) await report(job.Id, job.ClaimToken, "printing", null);
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { return; }
catch (Exception ex) { log($"任务 {job.Id} 续期失败:{ex.Message}"); }
}
}
}

View File

@ -0,0 +1,114 @@
using FixedAssetPrintAgent;
var configuration = new AgentConfiguration { ServerUrl = "https://asset.example.com", TerminalId = 1 };
var job = new PrintJobDto { Id = 7, ClaimToken = "attempt-one", JobType = "asset_label" };
var journal = new MemoryJournal();
var statuses = new List<string>();
var prints = 0;
var failCompleted = true;
Task Report(int id, string token, string status, string? error)
{
Check(id == 7 && token == "attempt-one", "每次回写必须绑定原领取凭据");
statuses.Add(status);
if (status == "completed" && failCompleted) { failCompleted = false; throw new IOException("network disconnected"); }
return Task.CompletedTask;
}
Task<PrintJobState> Read(int id) => Task.FromResult(new PrintJobState { Id = id, Status = "result_unknown", ClaimToken = job.ClaimToken });
Task Dispatch() { prints++; return Task.CompletedTask; }
try
{
await PrintResultRecovery.ExecuteAsync(job, configuration, journal, Report, Read, Dispatch, _ => { });
throw new Exception("应模拟完成回写断网");
}
catch (IOException) { }
Check(journal.Pending?.Phase == "completed_pending_ack", "打印成功回写断网仍须保留completed");
await PrintResultRecovery.AcknowledgeAsync(journal.Pending!, journal, Report, Read, _ => { });
Check(prints == 1 && statuses.SequenceEqual(new[] { "printing", "completed", "completed" }), "只能补交结果,不能重印或改报失败");
Check(journal.Pending is null, "确认后清理恢复记录");
Console.WriteLine("PASS completion acknowledgment retries without printing");
foreach (var phase in new[] { "dispatching", "claimed", "completed_pending_ack" })
{
var pending = new PendingPrintResult { JobId = 7, ClaimToken = job.ClaimToken, Phase = phase };
await journal.SaveAsync(pending);
await PrintResultRecovery.AcknowledgeAsync(pending, journal, Report, Read, _ => { });
Check(statuses.Last() == (phase == "completed_pending_ack" ? "completed" : "result_unknown"), "恢复阶段不得猜测是否已打印");
}
Console.WriteLine("PASS restart recovery does not dispatch");
await PrintResultRecovery.ExecuteAsync(job, configuration, journal, Report, Read,
() => throw new InvalidOperationException("partial print failed"), _ => { });
Check(statuses.Last() == "result_unknown", "部分出纸异常必须待核实");
Console.WriteLine("PASS partial dispatch failure remains uncertain");
journal.FailSave = true;
try
{
await PrintResultRecovery.ExecuteAsync(job, configuration, journal, Report, Read, Dispatch, _ => { });
throw new Exception("应模拟落盘失败");
}
catch (IOException) { }
Check(prints == 1, "落盘失败禁止打印");
journal.FailSave = false;
Console.WriteLine("PASS journal write failure prevents printing");
var conflict = new PendingPrintResult { JobId = 7, ClaimToken = job.ClaimToken, Phase = "completed_pending_ack" };
await journal.SaveAsync(conflict);
Task ConflictReport(int id, string token, string status, string? error) => throw new PrintAgentRequestException(409, "conflict");
try
{
await PrintResultRecovery.AcknowledgeAsync(conflict, journal, ConflictReport, Read, _ => { });
throw new Exception("同token未确定结果必须保留恢复记录");
}
catch (PrintAgentRequestException) { }
Check(journal.Pending is not null, "409不能直接清记录");
await PrintResultRecovery.AcknowledgeAsync(conflict, journal, ConflictReport,
_ => Task.FromResult(new PrintJobState { Id = 7, Status = "queued", ClaimToken = null }), _ => { });
Check(journal.Pending is null, "管理员已重新派单后允许释放旧记录");
Console.WriteLine("PASS conflict inspection preserves unresolved attempts");
foreach (var invalid in new[] { new PrintJobState(), new PrintJobState { Status = "completed" },
new PrintJobState { Status = "bogus", ClaimToken = null } })
{
await journal.SaveAsync(conflict);
try
{
await PrintResultRecovery.AcknowledgeAsync(conflict, journal, ConflictReport,
_ => Task.FromResult(invalid), _ => { });
throw new Exception("无效状态读回不得确认完成");
}
catch (InvalidOperationException) { }
Check(journal.Pending is not null, "缺状态或token必须保留记录");
}
Console.WriteLine("PASS invalid server response retains recovery journal");
var temporaryRoot = Path.Combine(Path.GetTempPath(), $"asset-print-journal-{Guid.NewGuid():N}");
try
{
var disk = new PrintResultJournal(temporaryRoot);
await disk.SaveAsync(conflict);
Check((await new PrintResultJournal(temporaryRoot).LoadAsync())?.Phase == conflict.Phase, "落盘记录必须跨实例恢复");
await disk.ClearAsync();
Check(await disk.LoadAsync() is null, "清记录后不应留旧任务");
await File.WriteAllTextAsync(Path.Combine(temporaryRoot, "pending-print-result.json"), "{bad");
try { await disk.LoadAsync(); throw new Exception("损坏记录应阻止自动打印"); }
catch (System.Text.Json.JsonException) { }
}
finally { Directory.Delete(temporaryRoot, true); }
Console.WriteLine("PASS durable journal restoration and corruption protection");
static void Check(bool condition, string message) { if (!condition) throw new Exception(message); }
internal sealed class MemoryJournal : IPrintResultJournal
{
public PendingPrintResult? Pending;
public bool FailSave;
public Task<PendingPrintResult?> LoadAsync() => Task.FromResult(Pending);
public Task SaveAsync(PendingPrintResult result)
{
if (FailSave) throw new IOException("disk full");
Pending = result;
return Task.CompletedTask;
}
public Task ClearAsync() { Pending = null; return Task.CompletedTask; }
}

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Compile Include="../FixedAssetPrintAgent/PrintResultRecovery.cs" Link="PrintResultRecovery.cs" />
<Compile Include="../FixedAssetPrintAgent/PrintAgentModels.cs" Link="PrintAgentModels.cs" />
</ItemGroup>
</Project>

View File

@ -1,12 +1,12 @@
{
"name": "fixed-asset-print-agent",
"version": "0.2.6",
"version": "0.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fixed-asset-print-agent",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"qrcode": "^1.5.4"
},

View File

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

View File

@ -23,6 +23,7 @@ import {
import { createPrintAgentClient } from './services/apiClient.js'
import { createJobProcessor } from './services/jobProcessor.js'
import { createAgentRuntime } from './services/agentRuntime.js'
import { createPrintJournal } from './services/printJournal.js'
import {
buildAssetLabelPrintHtml,
buildRenderedLabelImagePrintHtml,
@ -237,6 +238,7 @@ async function createRuntime() {
runtime = createAgentRuntime({
store,
journal: createPrintJournal(path.join(app.getPath('userData'), 'data')),
clientFactory: (options) => createPrintAgentClient(options),
getPrinters,
jobProcessor,
@ -266,12 +268,23 @@ app.on('before-quit', () => {
isQuitting = true
})
app.whenReady().then(async () => {
// One durable journal belongs to one process. A second instance must never
// create a runtime or clear another process's pending acknowledgment.
const hasInstanceLock = app.requestSingleInstanceLock()
if (!hasInstanceLock) {
app.quit()
} else {
app.on('second-instance', () => {
mainWindow?.show()
mainWindow?.focus()
})
app.whenReady().then(async () => {
createWindow()
createTray()
registerIpcHandlers()
await createRuntime()
})
})
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {

View File

@ -1,5 +1,6 @@
export function createAgentRuntime({
store,
journal,
clientFactory,
getPrinters,
jobProcessor,
@ -24,6 +25,7 @@ export function createAgentRuntime({
let heartbeatTimer = null
let pollTimer = null
let pollBusy = false
function pushLog(message) {
const line = `${new Date().toLocaleString('zh-CN', { hour12: false })} ${message}`
@ -110,6 +112,7 @@ export function createAgentRuntime({
const printers = await refreshPrinters()
const client = getClient()
const payload = {
protocolVersion: 2,
terminalId: state.config?.terminalId,
terminalName: state.config?.terminalName,
selectedPrinterName: state.config?.selectedPrinterName,
@ -132,16 +135,61 @@ export function createAgentRuntime({
}
}
async function acknowledgePending(client, pending) {
const status = pending.phase === 'completed_pending_ack' ? 'completed' :
pending.phase === 'failed_pending_ack' ? 'failed' : 'result_unknown'
try {
await client.reportJobStatus(pending.jobId, {
status,
claimToken: pending.claimToken,
errorMessage: pending.errorMessage || (status === 'result_unknown' ? '打印过程曾中断,请人工核实出纸结果' : null)
})
await journal.clear()
pushLog(status === 'completed' ? `任务 ${pending.jobId} 完成结果已确认。` :
`任务 ${pending.jobId} 结果已上报${status === 'result_unknown' ? ',请到系统核实出纸情况' : ''}。`)
} catch (error) {
if (error.status === 409 && typeof client.getJobState === 'function') {
const current = await client.getJobState(pending.jobId)
if (!current || current.id !== pending.jobId || !Object.prototype.hasOwnProperty.call(current, 'claim_token') ||
(current.claim_token !== null && typeof current.claim_token !== 'string') ||
!['queued', 'claimed', 'printing', 'result_unknown', 'completed', 'cancelled', 'failed'].includes(current.status)) {
throw new Error('服务器任务状态响应不完整,保留恢复记录并等待核实')
}
// Do not drop a conflicting acknowledgment without inspecting the server.
// A fenced token or a final result proves this attempt cannot be replayed.
if (current.claim_token !== pending.claimToken || ['completed', 'cancelled', 'failed'].includes(current.status)) {
await journal.clear()
state.lastError = `任务 ${pending.jobId} 已由服务器处理,请核对系统中的结果。`
pushLog(state.lastError)
return
}
}
throw error
}
}
async function pollOnce() {
if (!state.config?.deviceToken || !state.config?.terminalId) {
return null
}
if (pollBusy) return null
pollBusy = true
const client = getClient()
try {
if (!journal) throw new Error('缺少本地打印恢复记录,已阻止自动打印')
const pending = await journal.load()
if (pending) {
if (pending.serverUrl !== state.config.serverUrl || pending.terminalId !== state.config.terminalId) {
throw new Error('上一任务属于其他服务器或终端,请先恢复原配置并核实结果')
}
await acknowledgePending(client, pending)
return null
}
const claimResult = await client.claimNextJob({
terminalId: state.config.terminalId
terminalId: state.config.terminalId,
protocolVersion: 2
})
state.lastClaimAt = new Date().toISOString()
@ -150,24 +198,66 @@ export function createAgentRuntime({
}
const job = claimResult.job
if (!job.claim_token) throw new Error('服务器缺少领取凭据,已阻止实际出纸,请更新服务器')
const entry = {
jobId: job.id,
claimToken: job.claim_token,
serverUrl: state.config.serverUrl,
terminalId: state.config.terminalId,
phase: 'claimed'
}
await journal.save(entry)
if (job.job_type !== 'asset_label') {
await journal.save({ ...entry, phase: 'failed_pending_ack', errorMessage: '不支持的打印任务类型,未开始打印' })
await acknowledgePending(client, await journal.load())
return null
}
await client.reportJobStatus(job.id, {
status: 'printing'
status: 'printing',
claimToken: job.claim_token
})
const processingResult = await jobProcessor.process(job, {
// Persist intent BEFORE dispatch. A restart here always asks for inspection.
await journal.save({ ...entry, phase: 'dispatching' })
let dispatching = true
let renewalBusy = false
const renewalTimer = setIntervalImpl(async () => {
if (!dispatching || renewalBusy) return
renewalBusy = true
try {
await client.reportJobStatus(job.id, { status: 'printing', claimToken: job.claim_token })
} catch (error) {
pushLog(`任务 ${job.id} 续期失败:${error.message}`)
} finally {
renewalBusy = false
}
}, 30000)
let processingResult
try {
processingResult = await jobProcessor.process(job, {
selectedPrinterName: state.config?.selectedPrinterName
})
} catch (error) {
processingResult = { ok: false, errorMessage: error.message, nextStatus: 'result_unknown' }
} finally {
dispatching = false
clearIntervalImpl(renewalTimer)
}
state.lastJobResult = processingResult.message || processingResult.errorMessage || ''
await client.reportJobStatus(job.id, {
status: processingResult.nextStatus,
await journal.save({
...entry,
phase: processingResult.ok ? 'completed_pending_ack' : 'unknown_pending_ack',
errorMessage: processingResult.errorMessage || null
})
// A failed result acknowledgment retains the journal. Next poll retries
// this submission only; it never calls the printer again.
await acknowledgePending(client, await journal.load())
pushLog(
processingResult.ok
? `任务 ${job.id} 已发送到 ${state.config?.selectedPrinterName || '当前打印机'}。`
: `任务 ${job.id} 打印失败:${processingResult.errorMessage}`
: `任务 ${job.id} 结果待核实:${processingResult.errorMessage}`
)
return processingResult
@ -175,6 +265,8 @@ export function createAgentRuntime({
state.lastError = error.message
pushLog(`领取任务失败:${error.message}`)
throw error
} finally {
pollBusy = false
}
}

View File

@ -43,6 +43,7 @@ async function parseResponse(response) {
export function createPrintAgentClient({
serverUrl,
getDeviceToken = () => '',
requestTimeoutMs = 15000,
fetchImpl = globalThis.fetch
}) {
if (typeof fetchImpl !== 'function') {
@ -64,6 +65,7 @@ export function createPrintAgentClient({
const response = await fetchImpl(`${trimBaseUrl(serverUrl)}${endpoint}`, {
method,
headers,
signal: AbortSignal.timeout(requestTimeoutMs),
body: body ? JSON.stringify(body) : undefined
})
@ -76,6 +78,14 @@ export function createPrintAgentClient({
}
const mapped = { ...body }
if ('claimToken' in mapped) {
mapped.claim_token = mapped.claimToken
delete mapped.claimToken
}
if ('protocolVersion' in mapped) {
mapped.protocol_version = mapped.protocolVersion
delete mapped.protocolVersion
}
if ('pairCode' in mapped) {
mapped.code = mapped.pairCode
@ -144,11 +154,20 @@ export function createPrintAgentClient({
body: toSnakeCaseBody(payload)
})
},
reportJobStatus(jobId, payload) {
return request(`/api/print-agent/jobs/${jobId}/status`, {
async reportJobStatus(jobId, payload) {
const result = await request(`/api/print-agent/jobs/${jobId}/status`, {
method: 'POST',
body: toSnakeCaseBody(payload)
})
const expectedToken = payload.claimToken || payload.claim_token
if (result?.id !== jobId || result.claim_token !== expectedToken ||
!(result.status === payload.status || (payload.status === 'failed' && result.status === 'result_unknown'))) {
throw new Error('服务器未返回明确的任务结果,保留本地恢复记录')
}
return result
},
getJobState(jobId) {
return request(`/api/print-agent/jobs/${jobId}/state`)
},
requestTestPrint(payload) {
return request('/api/print-agent/test-print', {

View File

@ -0,0 +1,44 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
export function createPrintJournal(baseDir) {
const filePath = path.join(baseDir, 'pending-print-result.json')
return {
async load() {
try {
const entry = JSON.parse(await fs.readFile(filePath, 'utf8'))
if (!entry || !Number.isInteger(entry.jobId) || !entry.claimToken ||
!['claimed', 'dispatching', 'completed_pending_ack', 'unknown_pending_ack', 'failed_pending_ack'].includes(entry.phase)) {
throw new Error('本地打印恢复记录损坏,请先核实上一任务的出纸情况')
}
return entry
} catch (error) {
if (error?.code === 'ENOENT') return null
throw error
}
},
async save(entry) {
await fs.mkdir(baseDir, { recursive: true })
const temporaryPath = `${filePath}.${randomUUID()}.tmp`
const file = await fs.open(temporaryPath, 'wx')
try {
await file.writeFile(JSON.stringify(entry))
await file.sync()
} finally {
await file.close()
}
try {
await fs.rename(temporaryPath, filePath)
} catch (error) {
await fs.unlink(temporaryPath).catch(() => {})
throw error
}
},
async clear() {
await fs.unlink(filePath).catch((error) => {
if (error?.code !== 'ENOENT') throw error
})
}
}
}

Some files were not shown because too many files have changed in this diff Show More