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