feat(导出): 按行政台账展示实时资产关系

This commit is contained in:
WuYongJie 2026-07-10 18:20:10 +08:00
parent 81d1419ef5
commit a754472cd5
7 changed files with 879 additions and 155 deletions

View File

@ -4,15 +4,16 @@ import prisma from '../utils/prisma'
import { sumSnapshotChildrenValue } from '../utils/assetValue'
import {
flattenEquipmentExportHierarchy,
hydrateEquipmentExportHierarchy,
summarizeEquipmentExportHierarchy
} from '../utils/equipmentExportHierarchy'
import {
buildEquipmentExportDescriptionRows,
formatShanghaiExportFilenameTimestamp
} from '../utils/equipmentExportMetadata'
import { calculateEquipmentExportValues } from '../utils/equipmentExportValues'
import { buildEquipmentKeywordSearchConditions } from '../utils/equipmentKeywordSearch'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
import { buildAdministrativeEquipmentWorkbook } from '../utils/equipmentAdministrativeExport'
const loadExcelJS = (): typeof import('exceljs') => {
// 低频导出按需加载,避免后台常驻流程在启动时就加载 Excel 组件。
@ -235,32 +236,41 @@ export const exportEquipment = async (req: Request, res: Response) => {
}
const equipments = await prisma.$transaction(
async (tx) =>
tx.equipment.findMany({
async (tx) => {
const rootEquipments = await tx.equipment.findMany({
where,
include: {
department: { select: { name: true } },
storageLocation: { select: { name: true } },
assetType: { select: { name: true } },
children: {
include: {
department: { select: { name: true } },
storageLocation: { select: { name: true } },
assetType: { select: { name: true } },
children: {
include: {
department: { select: { name: true } },
storageLocation: { select: { name: true } },
assetType: { select: { name: true } }
},
orderBy: [{ equipment_code: 'asc' }, { id: 'asc' }]
}
},
orderBy: [{ equipment_code: 'asc' }, { id: 'asc' }]
borrow_records: {
where: { status: '已借出' },
orderBy: [{ borrow_time: 'desc' }, { id: 'desc' }],
take: 2,
select: {
id: true,
person_nickname: true,
person_name: true,
borrow_time: true
}
}
},
orderBy: [{ created_at: 'desc' }, { id: 'desc' }]
}),
})
return hydrateEquipmentExportHierarchy(
rootEquipments,
async (parentIds) => tx.equipment.findMany({
where: { parent_id: { in: parentIds } },
include: {
department: { select: { name: true } },
storageLocation: { select: { name: true } },
assetType: { select: { name: true } }
},
orderBy: [{ equipment_code: 'asc' }, { id: 'asc' }]
})
)
},
{
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
maxWait: 5000,
@ -278,133 +288,14 @@ export const exportEquipment = async (req: Request, res: Response) => {
)
const ExcelJS = loadExcelJS()
const workbook = new ExcelJS.Workbook()
workbook.created = exportedAt
const worksheet = workbook.addWorksheet('资产列表')
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.columns = [
{ header: '资产层级', key: 'asset_level', width: 12 },
{ header: '子资产数量', key: 'accessory_count', width: 12 },
{ header: '关系状态', key: 'relationship_status', width: 18 },
{ header: '子资产明细', key: 'child_details', width: 80 },
{ header: '资产编码', key: 'equipment_code', width: 20 },
{ header: '资产名称', key: 'equipment_name', width: 25 },
{ header: '资产类型', key: 'equipment_type', width: 15 },
{ header: '型号', key: 'equipment_model', width: 20 },
{ header: '使用部门', key: 'department', width: 15 },
{ header: '存放地址', key: 'storage_location', width: 20 },
{ header: '本体单价', key: 'unit_price', width: 12 },
{ header: '本体价值', key: 'own_total_value', width: 14 },
{ header: '当前价值', key: 'display_total_value', width: 14 },
{ header: '挂载配件价值', key: 'mounted_children_value', width: 16 },
{ header: '借出价值', key: 'borrowed_value', width: 14 },
{ header: '可用价值', key: 'available_value', width: 14 },
{ header: '总数量', key: 'total_quantity', width: 10 },
{ header: '剩余数量', key: 'remaining_quantity', width: 10 },
{ header: '状态', key: 'status', width: 10 },
{ header: '采购日期', key: 'purchase_date', width: 15 },
{ header: '供应商', key: 'supplier', width: 20 }
]
// 设置表头样式
worksheet.getRow(1).font = { bold: true }
worksheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }
const currentValueHeader = worksheet.getCell(
1,
worksheet.getColumn('display_total_value').number
)
currentValueHeader.note =
'每个根资产只占一行,可安全求和;主资产当前价值已包含“子资产明细”中的全部配件。配件自身价值也列在明细文本中。'
worksheet.getCell(1, worksheet.getColumn('own_total_value').number).note =
'根资产自身价值,不包含所挂载配件;配件本体价值逐项列在“子资产明细”中。'
for (const columnKey of [
'mounted_children_value',
'borrowed_value',
'available_value'
]) {
worksheet.getCell(1, worksheet.getColumn(columnKey).number).note =
'每个根资产只占一行,可直接求和;主资产金额已汇总其全部子资产。'
}
exportGroups.forEach((group) => {
const eq: any = group.equipment
const calculatedValues = exportValueByGroupCode.get(group.relationship_group_code)
if (!calculatedValues) {
throw new Error(`资产导出金额缺失: ${group.relationship_group_code}`)
}
const row = worksheet.addRow({
asset_level: group.asset_level === '主资产' ? '父资产' : '独立资产',
accessory_count: group.child_count,
relationship_status:
group.asset_level === '主资产' ? `包含 ${group.child_count} 个子资产` : '无子资产',
child_details: group.child_details,
equipment_code: eq.equipment_code,
equipment_name: eq.equipment_name,
equipment_type: eq.assetType?.name || eq.equipment_type,
equipment_model: eq.equipment_model,
department: eq.department?.name || '',
storage_location: eq.storageLocation?.name || '',
unit_price: Number(eq.unit_price),
own_total_value: calculatedValues.own_total_value,
display_total_value: calculatedValues.display_total_value,
mounted_children_value: calculatedValues.mounted_children_value,
borrowed_value: calculatedValues.borrowed_value,
available_value: calculatedValues.available_value,
total_quantity: eq.total_quantity,
remaining_quantity: eq.remaining_quantity,
status: eq.status,
purchase_date: eq.purchase_date ? new Date(eq.purchase_date).toLocaleDateString('zh-CN') : '',
supplier: eq.supplier || ''
})
row.alignment = { vertical: 'top' }
row.getCell('child_details').alignment = {
...row.getCell('child_details').alignment,
wrapText: true
}
row.height = group.child_count > 0
? Math.min(24 + group.child_count * 15, 405)
: 24
if (group.asset_level === '主资产') {
for (const columnKey of ['asset_level', 'equipment_code', 'equipment_name']) {
row.getCell(columnKey).font = { bold: true }
}
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF3F6F8' }
}
} else {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFAFAFA' }
}
}
const workbook = buildAdministrativeEquipmentWorkbook(ExcelJS, {
groups: exportGroups,
valueByGroupCode: exportValueByGroupCode,
exportedAt
})
const descriptionWorksheet = workbook.addWorksheet('导出说明')
descriptionWorksheet.columns = [
{ header: '项目', key: 'item', width: 16 },
{ header: '说明', key: 'description', width: 100 }
]
descriptionWorksheet.addRows(buildEquipmentExportDescriptionRows(exportedAt))
descriptionWorksheet.getRow(1).font = { bold: true }
descriptionWorksheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' }
}
descriptionWorksheet.getColumn('description').alignment = {
vertical: 'top',
wrapText: true
}
descriptionWorksheet.views = [{ state: 'frozen', ySplit: 1 }]
const timestamp = formatShanghaiExportFilenameTimestamp(exportedAt)
const filename = `资产列表_${timestamp}.xlsx`
const filename = `行政资产台账_${timestamp}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)

View File

@ -0,0 +1,397 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import ExcelJS = require('exceljs')
import {
buildAdministrativeEquipmentWorkbook,
buildEquipmentAdministrativeRows
} from '../equipmentAdministrativeExport'
import {
flattenEquipmentExportHierarchy,
summarizeEquipmentExportHierarchy,
type EquipmentExportHierarchyNode
} from '../equipmentExportHierarchy'
import { calculateEquipmentExportValues } from '../equipmentExportValues'
const prepareExport = (equipment: EquipmentExportHierarchyNode[]) => {
const flattenedRows = flattenEquipmentExportHierarchy(equipment)
const calculatedValues = calculateEquipmentExportValues(flattenedRows)
const groups = summarizeEquipmentExportHierarchy(flattenedRows)
const valueByGroupCode = new Map(
flattenedRows
.map((row, index) => ({ row, values: calculatedValues[index] }))
.filter(({ row }) => row.asset_level !== '配件')
.map(({ row, values }) => [row.relationship_group_code, values] as const)
)
return { groups, valueByGroupCode }
}
test('行政台账应按类型别名拆列、多项换行稳定排序,并完整保留未知子资产', () => {
const exportData = prepareExport([
{
id: 1,
equipment_code: 'ROOT-PC-001',
equipment_name: '研发电脑',
equipment_type: '电脑主机',
status: '已借出',
unit_price: 5000,
total_value: 5000,
purchase_date: '2026-01-02T00:00:00.000Z',
department: { name: '研发部' },
storageLocation: { name: '三楼' },
borrow_records: [
{
id: 1,
person_nickname: '旧昵称',
person_name: '旧员工',
borrow_time: '2026-01-01T00:00:00.000Z'
},
{
id: 2,
person_nickname: '新昵称',
person_name: '新员工',
borrow_time: '2026-02-01T00:00:00.000Z'
}
],
children: [
{
id: 109,
equipment_code: 'PART-UNKNOWN',
equipment_name: '万兆网卡',
equipment_type: '网络配件',
equipment_model: 'X710',
total_value: 800
},
{
id: 108,
equipment_code: 'PART-MEM-002',
equipment_name: '内存条二',
assetType: { name: '内存条' },
equipment_model: '32GB',
total_value: 400
},
{
id: 101,
equipment_code: 'PART-CPU',
equipment_name: '处理器',
assetType: { name: 'cpu' },
equipment_type: '未识别类型',
equipment_model: 'i9',
total_value: 2000
},
{
id: 102,
equipment_code: 'PART-MAINBOARD',
equipment_name: '主板',
equipment_type: '主板',
equipment_model: 'Z790',
total_value: 1000
},
{
id: 107,
equipment_code: 'PART-MEM-001',
equipment_name: '内存条一',
equipment_type: '内存',
equipment_model: '16GB',
total_value: 200
},
{
id: 103,
equipment_code: 'PART-SSD',
equipment_name: '固态盘',
equipment_type: 'SSD',
equipment_model: '2TB',
total_value: 600
},
{
id: 104,
equipment_code: 'PART-HDD',
equipment_name: '机械盘',
equipment_type: 'HDD',
equipment_model: '8TB',
total_value: 700
},
{
id: 105,
equipment_code: 'PART-GPU',
equipment_name: '显卡',
equipment_type: 'GPU',
equipment_model: 'RTX 5090',
total_value: 18000
}
]
}
])
const { mainRows, relationRows } = buildEquipmentAdministrativeRows(
exportData.groups,
exportData.valueByGroupCode
)
const row = mainRows[0]
assert.equal(row.cpu, 'PART-CPU|i9')
assert.equal(row.motherboard, 'PART-MAINBOARD|Z790')
assert.equal(row.memory, 'PART-MEM-001|16GB\nPART-MEM-002|32GB')
assert.equal(row.ssd, 'PART-SSD|2TB')
assert.equal(row.hdd, 'PART-HDD|8TB')
assert.equal(row.gpu, 'PART-GPU|RTX 5090')
assert.equal(row.other_children, 'PART-UNKNOWN|X710')
assert.equal(row.current_borrower, '新昵称')
assert.match(row.relation_review, /存在未识别子资产/)
assert.match(row.relation_review, /存在多个当前借用记录/)
assert.equal(row.child_count, 8)
assert.equal(row.current_value, 28700)
assert.equal(relationRows.length, 8)
assert.deepEqual(
relationRows.map((item) => item.child_equipment_code),
[
'PART-CPU',
'PART-GPU',
'PART-HDD',
'PART-MAINBOARD',
'PART-MEM-001',
'PART-MEM-002',
'PART-SSD',
'PART-UNKNOWN'
]
)
})
test('六类硬件的全部中英文别名都应落入固定列', () => {
const aliases = [
['CPU', 'cpu'],
['cpu', 'cpu'],
['处理器', 'cpu'],
['主板', 'motherboard'],
['内存', 'memory'],
['内存条', 'memory'],
['固态硬盘', 'ssd'],
['SSD', 'ssd'],
['机械硬盘', 'hdd'],
['HDD', 'hdd'],
['显卡', 'gpu'],
['GPU', 'gpu']
] as const
const exportData = prepareExport([
{
id: 1,
equipment_code: 'ROOT-ALIASES',
equipment_name: '别名测试主机',
equipment_type: '电脑',
status: '可用',
total_value: 100,
children: aliases.map(([alias], index) => ({
id: index + 2,
equipment_code: `PART-${String(index + 1).padStart(2, '0')}`,
equipment_name: `${alias}配件`,
equipment_type: alias,
total_value: 1
}))
}
])
const { mainRows } = buildEquipmentAdministrativeRows(
exportData.groups,
exportData.valueByGroupCode
)
const row = mainRows[0]
for (const [alias, slot] of aliases) {
assert.match(row[slot], new RegExp(`${alias}配件`))
}
assert.equal(row.other_children, '')
assert.equal(row.relation_review, '')
})
test('关系核对应提示非电脑根资产挂硬件,无子资产和不同分组不得串数据', () => {
const exportData = prepareExport([
{
id: 1,
equipment_code: 'ROOT-DISPLAY',
equipment_name: '会议室显示器',
equipment_type: '显示器',
status: '可用',
total_value: 1000,
children: [
{
id: 2,
equipment_code: 'DISPLAY-CPU',
equipment_name: '处理器',
equipment_type: '处理器',
total_value: 100
}
]
},
{
id: 3,
equipment_code: 'ROOT-EMPTY',
equipment_name: '独立打印机',
equipment_type: '打印机',
status: '可用',
total_value: 500,
borrow_records: [
{
id: 30,
person_nickname: '部分借用员工',
person_name: '借用员工',
borrow_time: '2026-06-01T00:00:00.000Z'
}
],
children: []
},
{
id: 4,
equipment_code: 'ROOT-PC-002',
equipment_name: '办公主机',
equipment_type: '台式机',
status: '可用',
total_value: 2000,
children: [
{
id: 5,
equipment_code: 'SECOND-GPU',
equipment_name: '显卡',
equipment_type: '显卡',
total_value: 300
}
]
}
])
const { mainRows, relationRows } = buildEquipmentAdministrativeRows(
exportData.groups,
exportData.valueByGroupCode
)
assert.match(mainRows[0].relation_review, /非电脑类根资产包含硬件子资产/)
assert.equal(mainRows[0].cpu, 'DISPLAY-CPU|处理器')
assert.equal(mainRows[0].gpu, '')
assert.equal(mainRows[1].child_count, 0)
assert.equal(mainRows[1].cpu, '')
assert.equal(mainRows[1].other_children, '')
assert.equal(mainRows[1].relation_review, '')
assert.equal(mainRows[1].current_borrower, '部分借用员工')
assert.equal(mainRows[2].gpu, 'SECOND-GPU|显卡')
assert.equal(mainRows[2].cpu, '')
assert.deepEqual(
relationRows.map((row) => [row.root_equipment_code, row.child_equipment_code]),
[
['ROOT-DISPLAY', 'DISPLAY-CPU'],
['ROOT-PC-002', 'SECOND-GPU']
]
)
})
test('关系明细应保留孙级资产的直接父资产,电脑类型判断不得依赖资产名称', () => {
const exportData = prepareExport([
{
id: 1,
equipment_code: 'ROOT-DESK',
equipment_name: '电脑桌',
equipment_type: '办公家具',
status: '可用',
total_value: 1000,
children: [
{
id: 2,
parent_id: 1,
equipment_code: 'DOCK-001',
equipment_name: '扩展坞',
equipment_type: '扩展坞',
total_value: 200,
children: [
{
id: 3,
parent_id: 2,
equipment_code: 'GPU-001',
equipment_name: '显卡',
equipment_type: '显卡',
total_value: 300
}
]
}
]
},
{
id: 10,
equipment_code: 'ROOT-SERVER',
equipment_name: '机房设备',
equipment_type: '服务器',
status: '可用',
total_value: 5000,
children: [
{
id: 11,
parent_id: 10,
equipment_code: 'CPU-SERVER',
equipment_name: 'CPU',
equipment_type: 'CPU',
total_value: 1000
}
]
}
])
const { mainRows, relationRows } = buildEquipmentAdministrativeRows(
exportData.groups,
exportData.valueByGroupCode
)
assert.match(mainRows[0].relation_review, /非电脑类根资产包含硬件子资产/)
assert.doesNotMatch(mainRows[1].relation_review, /非电脑类根资产包含硬件子资产/)
const grandchildRow = relationRows.find((row) => row.child_equipment_code === 'GPU-001')
assert.equal(grandchildRow?.parent_equipment_code, 'DOCK-001')
assert.equal(grandchildRow?.parent_equipment_name, '扩展坞')
})
test('工作簿应生成三张行政导出表并保留日期、金额和版式格式', () => {
const exportData = prepareExport([
{
id: 1,
equipment_code: 'ROOT-PC-003',
equipment_name: '财务电脑',
equipment_type: '电脑',
status: '可用',
purchase_date: '2026-03-04T00:00:00.000Z',
unit_price: '6000.5',
total_value: 6000.5,
children: [
{
id: 2,
equipment_code: 'PART-RAM',
equipment_name: '内存',
equipment_type: '内存条',
equipment_model: '32GB',
total_value: 300
}
]
}
])
const workbook = buildAdministrativeEquipmentWorkbook(ExcelJS, {
...exportData,
exportedAt: new Date('2026-07-10T00:00:00.000Z')
})
assert.deepEqual(workbook.worksheets.map((sheet) => sheet.name), [
'行政资产台账',
'资产关系明细',
'导出说明'
])
const mainSheet = workbook.getWorksheet('行政资产台账')!
const relationSheet = workbook.getWorksheet('资产关系明细')!
assert.equal((mainSheet.views[0] as { ySplit?: number }).ySplit, 1)
assert.ok(mainSheet.autoFilter)
assert.ok(relationSheet.autoFilter)
assert.equal(mainSheet.getCell('A1').fill.type, 'pattern')
assert.equal(mainSheet.getCell('A1').border.top?.style, 'thin')
assert.equal(mainSheet.getCell('S2').border.right?.style, 'thin')
assert.equal(mainSheet.getCell('J2').alignment.wrapText, true)
assert.equal(mainSheet.getCell('F2').value, null)
assert.equal(mainSheet.getCell('L2').value, null)
assert.ok(mainSheet.getCell('Q2').value instanceof Date)
assert.equal(mainSheet.getCell('Q2').numFmt, 'yyyy-mm-dd')
assert.equal(mainSheet.getCell('R2').value, 6000.5)
assert.equal(mainSheet.getCell('R2').numFmt, '#,##0.00')
assert.equal(mainSheet.getCell('S2').value, 6300.5)
})

View File

@ -3,9 +3,33 @@ import assert from 'node:assert/strict'
import {
flattenEquipmentExportHierarchy,
hydrateEquipmentExportHierarchy,
summarizeEquipmentExportHierarchy
} from '../equipmentExportHierarchy'
test('hydrateEquipmentExportHierarchy 应按层加载任意深度并组装完整树', async () => {
const descendants = [
{ id: 2, parent_id: 1, equipment_code: 'CHILD-2', equipment_name: '二级资产' },
{ id: 3, parent_id: 2, equipment_code: 'CHILD-3', equipment_name: '三级资产' },
{ id: 4, parent_id: 3, equipment_code: 'CHILD-4', equipment_name: '四级资产' }
]
const loadedParentIds: number[][] = []
const roots = await hydrateEquipmentExportHierarchy(
[{ id: 1, parent_id: null, equipment_code: 'ROOT-1', equipment_name: '根资产' }],
async (parentIds) => {
loadedParentIds.push(parentIds)
return descendants.filter((item) => parentIds.includes(item.parent_id))
}
)
assert.deepEqual(loadedParentIds, [[1], [2], [3], [4]])
assert.equal(roots[0].children?.[0].children?.[0].children?.[0].equipment_code, 'CHILD-4')
const rows = flattenEquipmentExportHierarchy(roots)
assert.deepEqual(rows.map((row) => row.equipment.id), [1, 2, 3, 4])
assert.equal(rows[3].parent_equipment_code, 'CHILD-3')
assert.equal(rows[3].parent_equipment_name, '三级资产')
})
test('flattenEquipmentExportHierarchy 应让父资产后紧跟配件,并保留独立资产', () => {
const rows = flattenEquipmentExportHierarchy([
{
@ -83,6 +107,8 @@ test('flattenEquipmentExportHierarchy 应递归展平孙级配件且保持深度
assert.deepEqual(rows.map((row) => row.asset_level), ['主资产', '配件', '配件', '配件'])
assert.deepEqual(rows.map((row) => row.accessory_sequence), [null, 1, 2, 3])
assert.deepEqual(rows.map((row) => row.group_accessory_count), [3, 3, 3, 3])
assert.equal(rows[3].parent_equipment_code, 'JS-PJ-0042')
assert.equal(rows[3].parent_equipment_name, '扩展坞')
})
test('summarizeEquipmentExportHierarchy 应让每个根资产只占一行且子资产明细不跨组', () => {

View File

@ -23,8 +23,10 @@ test('导出说明应完整声明快照、当前关系、时效与金额口径',
assert.match(descriptionByItem.get('关系口径') || '', /当前 parent_id \/ children/)
assert.match(descriptionByItem.get('关系口径') || '', /不包含历史挂载/)
assert.match(descriptionByItem.get('时效提醒') || '', /最新导出/)
assert.match(descriptionByItem.get('分组阅读') || '', /每个根资产只占一行/)
assert.match(descriptionByItem.get('分组阅读') || '', /子资产明细/)
assert.match(descriptionByItem.get('分组阅读') || '', /无子资产/)
assert.match(descriptionByItem.get('台账阅读') || '', /每个根资产只占一行/)
assert.match(descriptionByItem.get('台账阅读') || '', /CPU、主板、内存、固态硬盘、机械硬盘、显卡/)
assert.match(descriptionByItem.get('台账阅读') || '', /其他子资产/)
assert.match(descriptionByItem.get('关系核对') || '', /不会自动改写/)
assert.match(descriptionByItem.get('关系核对') || '', /资产关系明细/)
assert.match(descriptionByItem.get('金额口径') || '', /可安全求和/)
})

View File

@ -0,0 +1,342 @@
import type { EquipmentExportGroupSummary, EquipmentExportHierarchyNode } from './equipmentExportHierarchy'
import type { EquipmentExportCalculatedValues } from './equipmentExportValues'
import { buildEquipmentExportDescriptionRows } from './equipmentExportMetadata'
export type AdministrativeHardwareSlot = 'cpu' | 'motherboard' | 'memory' | 'ssd' | 'hdd' | 'gpu'
export interface EquipmentAdministrativeMainRow {
equipment_code: string
status: string
equipment_name: string
equipment_type: string
department: string
current_borrower: string
storage_location: string
cpu: string
motherboard: string
memory: string
ssd: string
hdd: string
gpu: string
other_children: string
relation_review: string
child_count: number
purchase_date: Date | ''
unit_price: number
current_value: number
max_child_items_in_cell: number
}
export interface EquipmentAdministrativeRelationRow {
root_equipment_code: string
root_equipment_name: string
sequence: number
parent_equipment_code: string
parent_equipment_name: string
child_equipment_code: string
child_equipment_name: string
child_equipment_type: string
child_equipment_model: string
child_own_total_value: number
}
const HARDWARE_TYPE_ALIASES = new Map<string, AdministrativeHardwareSlot>([
['cpu', 'cpu'],
['处理器', 'cpu'],
['主板', 'motherboard'],
['内存', 'memory'],
['内存条', 'memory'],
['固态硬盘', 'ssd'],
['ssd', 'ssd'],
['机械硬盘', 'hdd'],
['hdd', 'hdd'],
['显卡', 'gpu'],
['gpu', 'gpu']
])
const COMPUTER_ROOT_TYPES = new Set([
'主机',
'电脑主机',
'台式机',
'台式电脑',
'电脑',
'pc',
'工作站',
'笔记本',
'笔记本电脑',
'一体机',
'服务器'
])
const normalizeText = (value: unknown) => String(value == null ? '' : value).trim()
const normalizeTypeKey = (value: unknown) => normalizeText(value).toLowerCase()
const toSafeNumber = (value: unknown) => {
const numericValue = Number(value || 0)
return Number.isFinite(numericValue) ? numericValue : 0
}
const toExportDate = (value: Date | string | null | undefined): Date | '' => {
if (!value) return ''
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value)
return Number.isNaN(date.getTime()) ? '' : date
}
const compareEquipment = (left: EquipmentExportHierarchyNode, right: EquipmentExportHierarchyNode) => {
const codeComparison = left.equipment_code.localeCompare(right.equipment_code)
return codeComparison || left.id - right.id
}
const resolveHardwareSlot = (child: EquipmentExportHierarchyNode) => {
const assetTypeSlot = HARDWARE_TYPE_ALIASES.get(normalizeTypeKey(child.assetType?.name))
if (assetTypeSlot) return assetTypeSlot
return HARDWARE_TYPE_ALIASES.get(normalizeTypeKey(child.equipment_type)) || null
}
const formatHardwareCellLine = (child: EquipmentExportHierarchyNode) => {
const modelOrName = normalizeText(child.equipment_model) || normalizeText(child.equipment_name) || '-'
return `${child.equipment_code}|${modelOrName}`
}
const isComputerRoot = (equipment: EquipmentExportHierarchyNode) => {
const structuredTypes = [equipment.assetType?.name, equipment.equipment_type]
.map(normalizeTypeKey)
.filter(Boolean)
return structuredTypes.some((equipmentType) => COMPUTER_ROOT_TYPES.has(equipmentType))
}
const resolveCurrentBorrowRecords = (equipment: EquipmentExportHierarchyNode) =>
[...(equipment.borrow_records || [])].sort((left, right) => {
const leftTime = left.borrow_time ? new Date(left.borrow_time).getTime() || 0 : 0
const rightTime = right.borrow_time ? new Date(right.borrow_time).getTime() || 0 : 0
return rightTime - leftTime || Number(right.id || 0) - Number(left.id || 0)
})
export const buildEquipmentAdministrativeRows = (
groups: EquipmentExportGroupSummary[],
valueByGroupCode: Map<string, EquipmentExportCalculatedValues>
) => {
const mainRows: EquipmentAdministrativeMainRow[] = []
const relationRows: EquipmentAdministrativeRelationRow[] = []
for (const group of groups) {
const equipment = group.equipment
const calculatedValues = valueByGroupCode.get(group.relationship_group_code)
if (!calculatedValues) {
throw new Error(`资产导出金额缺失: ${group.relationship_group_code}`)
}
const slotChildren: Record<AdministrativeHardwareSlot, EquipmentExportHierarchyNode[]> = {
cpu: [],
motherboard: [],
memory: [],
ssd: [],
hdd: [],
gpu: []
}
const otherChildren: EquipmentExportHierarchyNode[] = []
const sortedChildRows = [...group.child_rows].sort((left, right) =>
compareEquipment(left.equipment, right.equipment)
)
const sortedChildren = sortedChildRows.map((row) => row.equipment)
sortedChildRows.forEach((childRow, index) => {
const child = childRow.equipment
const slot = resolveHardwareSlot(child)
if (slot) {
slotChildren[slot].push(child)
} else {
otherChildren.push(child)
}
relationRows.push({
root_equipment_code: equipment.equipment_code,
root_equipment_name: equipment.equipment_name,
sequence: index + 1,
parent_equipment_code: childRow.parent_equipment_code || equipment.equipment_code,
parent_equipment_name: childRow.parent_equipment_name || equipment.equipment_name,
child_equipment_code: child.equipment_code,
child_equipment_name: child.equipment_name,
child_equipment_type: child.assetType?.name || child.equipment_type || '',
child_equipment_model: child.equipment_model || '',
child_own_total_value: toSafeNumber(child.total_value)
})
})
const activeBorrowRecords = resolveCurrentBorrowRecords(equipment)
const latestBorrowRecord = activeBorrowRecords[0]
const relationWarnings: string[] = []
const hardwareChildCount = Object.values(slotChildren).reduce((sum, items) => sum + items.length, 0)
if (hardwareChildCount > 0 && !isComputerRoot(equipment)) {
relationWarnings.push('非电脑类根资产包含硬件子资产')
}
if (otherChildren.length > 0) {
relationWarnings.push('存在未识别子资产')
}
if (activeBorrowRecords.length > 1) {
relationWarnings.push('存在多个当前借用记录')
}
const slotText = (slot: AdministrativeHardwareSlot) =>
slotChildren[slot].map(formatHardwareCellLine).join('\n')
const otherChildrenText = otherChildren.map(formatHardwareCellLine).join('\n')
const maxChildItemsInCell = Math.max(
0,
...Object.values(slotChildren).map((items) => items.length),
otherChildren.length
)
mainRows.push({
equipment_code: equipment.equipment_code,
status: normalizeText(equipment.status),
equipment_name: equipment.equipment_name,
equipment_type: equipment.assetType?.name || equipment.equipment_type || '',
department: equipment.department?.name || '',
current_borrower:
latestBorrowRecord
? normalizeText(latestBorrowRecord.person_nickname) || normalizeText(latestBorrowRecord.person_name)
: '',
storage_location: equipment.storageLocation?.name || '',
cpu: slotText('cpu'),
motherboard: slotText('motherboard'),
memory: slotText('memory'),
ssd: slotText('ssd'),
hdd: slotText('hdd'),
gpu: slotText('gpu'),
other_children: otherChildrenText,
relation_review: relationWarnings.join(';'),
child_count: sortedChildren.length,
purchase_date: toExportDate(equipment.purchase_date),
unit_price: toSafeNumber(equipment.unit_price),
current_value: toSafeNumber(calculatedValues.display_total_value),
max_child_items_in_cell: maxChildItemsInCell
})
}
return { mainRows, relationRows }
}
const applyHeaderStyle = (worksheet: import('exceljs').Worksheet) => {
const headerRow = worksheet.getRow(1)
headerRow.font = { bold: true }
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD1D5DB' } }
headerRow.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
headerRow.height = 28
}
const applyUsedRangeBorders = (worksheet: import('exceljs').Worksheet) => {
for (let rowNumber = 1; rowNumber <= worksheet.rowCount; rowNumber += 1) {
const row = worksheet.getRow(rowNumber)
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
const cell = row.getCell(columnNumber)
cell.border = {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
}
}
const setAutoFilter = (worksheet: import('exceljs').Worksheet) => {
worksheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: 1, column: worksheet.columnCount }
}
}
const toWorksheetRow = (row: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [key, value === '' ? null : value])
)
export const buildAdministrativeEquipmentWorkbook = (
ExcelJS: typeof import('exceljs'),
{
groups,
valueByGroupCode,
exportedAt
}: {
groups: EquipmentExportGroupSummary[]
valueByGroupCode: Map<string, EquipmentExportCalculatedValues>
exportedAt: Date
}
) => {
const { mainRows, relationRows } = buildEquipmentAdministrativeRows(groups, valueByGroupCode)
const workbook = new ExcelJS.Workbook()
workbook.created = exportedAt
const mainWorksheet = workbook.addWorksheet('行政资产台账')
mainWorksheet.views = [{ state: 'frozen', ySplit: 1 }]
mainWorksheet.columns = [
{ header: '资产编码', key: 'equipment_code', width: 20 },
{ header: '当前状态', key: 'status', width: 12 },
{ header: '资产名称', key: 'equipment_name', width: 24 },
{ header: '资产分类', key: 'equipment_type', width: 16 },
{ header: '部门', key: 'department', width: 18 },
{ header: '当前使用员工', key: 'current_borrower', width: 18 },
{ header: '地址', key: 'storage_location', width: 20 },
{ header: 'CPU', key: 'cpu', width: 24 },
{ header: '主板', key: 'motherboard', width: 24 },
{ header: '内存', key: 'memory', width: 24 },
{ header: '固态硬盘', key: 'ssd', width: 24 },
{ header: '机械硬盘', key: 'hdd', width: 24 },
{ header: '显卡', key: 'gpu', width: 24 },
{ header: '其他子资产', key: 'other_children', width: 28 },
{ header: '关系核对', key: 'relation_review', width: 30 },
{ header: '子资产数量', key: 'child_count', width: 12 },
{ header: '到货时间/采购日期', key: 'purchase_date', width: 18 },
{ header: '采购单价(元)', key: 'unit_price', width: 16 },
{ header: '当前价值(元)', key: 'current_value', width: 16 }
]
applyHeaderStyle(mainWorksheet)
for (const item of mainRows) {
const row = mainWorksheet.addRow(toWorksheetRow(item as unknown as Record<string, unknown>))
row.alignment = { vertical: 'top' }
for (const columnKey of ['cpu', 'motherboard', 'memory', 'ssd', 'hdd', 'gpu', 'other_children', 'relation_review']) {
row.getCell(columnKey).alignment = { vertical: 'top', wrapText: true }
}
row.height = Math.min(24 + item.max_child_items_in_cell * 15, 409)
}
mainWorksheet.getColumn('purchase_date').numFmt = 'yyyy-mm-dd'
mainWorksheet.getColumn('unit_price').numFmt = '#,##0.00'
mainWorksheet.getColumn('current_value').numFmt = '#,##0.00'
setAutoFilter(mainWorksheet)
applyUsedRangeBorders(mainWorksheet)
const relationWorksheet = workbook.addWorksheet('资产关系明细')
relationWorksheet.views = [{ state: 'frozen', ySplit: 1 }]
relationWorksheet.columns = [
{ header: '根资产编码', key: 'root_equipment_code', width: 20 },
{ header: '根资产名称', key: 'root_equipment_name', width: 24 },
{ header: '序号', key: 'sequence', width: 10 },
{ header: '直接父资产编码', key: 'parent_equipment_code', width: 20 },
{ header: '直接父资产名称', key: 'parent_equipment_name', width: 24 },
{ header: '子资产编码', key: 'child_equipment_code', width: 20 },
{ header: '子资产名称', key: 'child_equipment_name', width: 24 },
{ header: '子资产类型', key: 'child_equipment_type', width: 16 },
{ header: '子资产型号', key: 'child_equipment_model', width: 24 },
{ header: '子资产本体价值', key: 'child_own_total_value', width: 18 }
]
applyHeaderStyle(relationWorksheet)
relationWorksheet.addRows(
relationRows.map((item) => toWorksheetRow(item as unknown as Record<string, unknown>))
)
relationWorksheet.getColumn('child_own_total_value').numFmt = '#,##0.00'
setAutoFilter(relationWorksheet)
applyUsedRangeBorders(relationWorksheet)
const descriptionWorksheet = workbook.addWorksheet('导出说明')
descriptionWorksheet.views = [{ state: 'frozen', ySplit: 1 }]
descriptionWorksheet.columns = [
{ header: '项目', key: 'item', width: 16 },
{ header: '说明', key: 'description', width: 100 }
]
applyHeaderStyle(descriptionWorksheet)
descriptionWorksheet.addRows(buildEquipmentExportDescriptionRows(exportedAt))
descriptionWorksheet.getColumn('description').alignment = { vertical: 'top', wrapText: true }
return workbook
}

View File

@ -2,18 +2,33 @@ export type EquipmentExportHierarchyLevel = '主资产' | '配件' | '独立资
export interface EquipmentExportHierarchyNode {
id: number
parent_id?: number | null
equipment_code: string
equipment_name: string
equipment_type?: string | null
equipment_model?: string | null
unit_price?: unknown
total_value?: unknown
total_value_with_children?: unknown
borrowed_value?: unknown
available_value?: unknown
status?: unknown
purchase_date?: Date | string | null
assetType?: {
name: string
} | null
department?: {
name: string
} | null
storageLocation?: {
name: string
} | null
borrow_records?: Array<{
id?: number
person_nickname?: string | null
person_name?: string | null
borrow_time?: Date | string | null
}>
children?: EquipmentExportHierarchyNode[]
}
@ -35,6 +50,7 @@ export interface EquipmentExportGroupSummary {
asset_level: Exclude<EquipmentExportHierarchyLevel, '配件'>
child_count: number
children: EquipmentExportHierarchyNode[]
child_rows: FlattenedEquipmentExportRow[]
child_details: string
}
@ -46,12 +62,54 @@ const compareAccessories = (
return codeComparison || left.id - right.id
}
export const hydrateEquipmentExportHierarchy = async (
roots: EquipmentExportHierarchyNode[],
loadChildren: (parentIds: number[]) => Promise<EquipmentExportHierarchyNode[]>
) => {
const rootNodes = roots.map((root) => ({ ...root, children: [] as EquipmentExportHierarchyNode[] }))
const equipmentById = new Map<number, EquipmentExportHierarchyNode>(
rootNodes.map((root) => [root.id, root])
)
const visitedEquipmentIds = new Set(rootNodes.map((root) => root.id))
let parentIds = rootNodes.map((root) => root.id)
while (parentIds.length > 0) {
const loadedChildren = [...await loadChildren(parentIds)].sort(compareAccessories)
const nextParentIds: number[] = []
for (const loadedChild of loadedChildren) {
if (visitedEquipmentIds.has(loadedChild.id) || loadedChild.parent_id == null) {
continue
}
const parent = equipmentById.get(loadedChild.parent_id)
if (!parent) {
continue
}
const child = { ...loadedChild, children: [] as EquipmentExportHierarchyNode[] }
parent.children = [...(parent.children || []), child]
equipmentById.set(child.id, child)
visitedEquipmentIds.add(child.id)
nextParentIds.push(child.id)
}
parentIds = nextParentIds
}
return rootNodes
}
interface CollectedEquipmentExportDescendant {
equipment: EquipmentExportHierarchyNode
directParent: EquipmentExportHierarchyNode
}
const collectDescendants = (
parent: EquipmentExportHierarchyNode,
exportedEquipmentIds: Set<number>,
ancestorIds: Set<number>
) => {
const descendants: EquipmentExportHierarchyNode[] = []
const descendants: CollectedEquipmentExportDescendant[] = []
const childIdsInCurrentGroup = new Set<number>()
const children = [...(parent.children || [])]
.filter((child) => {
@ -69,7 +127,7 @@ const collectDescendants = (
for (const child of children) {
exportedEquipmentIds.add(child.id)
descendants.push(child)
descendants.push({ equipment: child, directParent: parent })
descendants.push(
...collectDescendants(
child,
@ -107,13 +165,13 @@ export const flattenEquipmentExportHierarchy = (
group_accessory_count: descendants.length
})
descendants.forEach((child, index) => {
descendants.forEach(({ equipment: child, directParent }, index) => {
rows.push({
equipment: child,
relationship_group_code: parent.equipment_code,
asset_level: '配件',
parent_equipment_code: parent.equipment_code,
parent_equipment_name: parent.equipment_name,
parent_equipment_code: directParent.equipment_code,
parent_equipment_name: directParent.equipment_name,
accessory_sequence: index + 1,
group_accessory_count: descendants.length
})
@ -152,6 +210,7 @@ export const summarizeEquipmentExportHierarchy = (
asset_level: row.asset_level,
child_count: 0,
children: [],
child_rows: [],
child_details: '无子资产'
}
groups.push(group)
@ -162,7 +221,9 @@ export const summarizeEquipmentExportHierarchy = (
if (row.asset_level !== '配件') {
continue
}
groupByCode.get(row.relationship_group_code)?.children.push(row.equipment)
const group = groupByCode.get(row.relationship_group_code)
group?.children.push(row.equipment)
group?.child_rows.push(row)
}
for (const group of groups) {

View File

@ -62,13 +62,18 @@ export const buildEquipmentExportDescriptionRows = (exportedAt: Date) => [
description: '父子关系会随挂载、卸载而变化,请以系统最新导出为准。'
},
{
item: '分组阅读',
item: '台账阅读',
description:
'每个根资产只占一行;有子资产时统一写入“子资产数量”和“子资产明细”,明细按编码稳定排序并逐行列出。独立资产明确显示“无子资产”。'
'“行政资产台账”每个根资产只占一行;当前子资产按 CPU、主板、内存、固态硬盘、机械硬盘、显卡六类拆列,无法识别的类型完整保留在“其他子资产”。同一分类有多项时按资产编码稳定排序并换行显示。'
},
{
item: '关系核对',
description:
'“关系核对”只提示非电脑类根资产包含硬件子资产、存在未识别子资产或存在多个当前借用记录,不会自动改写当前资产关系;完整父子关系请查看“资产关系明细”。'
},
{
item: '金额口径',
description:
'本体价值为每项资产自身价值;主资产组合金额现场汇总组内全部本体价值。当前价值、借出价值、可用价值和挂载配件价值仅在主资产或独立资产行填写,可安全求和;配件价值请查看本体价值。'
'“当前价值(元)”沿用现有金额口径:根资产组合金额在导出快照内汇总组内全部本体价值,每个根资产只占一行,可安全求和;子资产自身价值请查看“资产关系明细”。'
}
]