fix(导出): 父资产与子资产合并为单行

This commit is contained in:
WuYongJie 2026-07-10 15:19:07 +08:00
parent ef4c95f28d
commit 3f2151a72f
5 changed files with 179 additions and 30 deletions

View File

@ -2,7 +2,10 @@ import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { sumSnapshotChildrenValue } from '../utils/assetValue'
import { flattenEquipmentExportHierarchy } from '../utils/equipmentExportHierarchy'
import {
flattenEquipmentExportHierarchy,
summarizeEquipmentExportHierarchy
} from '../utils/equipmentExportHierarchy'
import {
buildEquipmentExportDescriptionRows,
formatShanghaiExportFilenameTimestamp
@ -266,20 +269,25 @@ export const exportEquipment = async (req: Request, res: Response) => {
)
const exportRows = flattenEquipmentExportHierarchy(equipments)
const exportValues = calculateEquipmentExportValues(exportRows)
const exportGroups = summarizeEquipmentExportHierarchy(exportRows)
const exportValueByGroupCode = new Map(
exportRows
.map((row, index) => ({ row, values: exportValues[index] }))
.filter(({ row }) => row.asset_level !== '配件')
.map(({ row, values }) => [row.relationship_group_code, values] as const)
)
const ExcelJS = loadExcelJS()
const workbook = new ExcelJS.Workbook()
workbook.created = exportedAt
const worksheet = workbook.addWorksheet('资产列表')
worksheet.properties.outlineProperties = { summaryBelow: false, summaryRight: false }
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.columns = [
{ header: '关系组编码', key: 'relationship_group_code', width: 20 },
{ header: '资产层级', key: 'asset_level', width: 12 },
{ header: '所属主资产编码', key: 'parent_equipment_code', width: 20 },
{ header: '所属主资产名称', key: 'parent_equipment_name', width: 25 },
{ header: '配件序号', key: 'accessory_sequence', width: 10 },
{ 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 },
@ -307,33 +315,30 @@ export const exportEquipment = async (req: Request, res: Response) => {
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 =
'仅主资产和独立资产填写,可直接求和;配件行留空以避免与主资产重复计算。'
'每个根资产只占一行,可直接求和;主资产金额已汇总其全部子资产。'
}
exportRows.forEach((hierarchyRow, index) => {
const eq: any = hierarchyRow.equipment
const calculatedValues = exportValues[index]
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({
relationship_group_code: hierarchyRow.relationship_group_code,
asset_level: hierarchyRow.asset_level,
parent_equipment_code: hierarchyRow.parent_equipment_code || '',
parent_equipment_name: hierarchyRow.parent_equipment_name || '',
accessory_sequence: hierarchyRow.accessory_sequence || '',
asset_level: group.asset_level === '主资产' ? '父资产' : '独立资产',
accessory_count: group.child_count,
relationship_status:
hierarchyRow.asset_level === '主资产'
? `包含 ${hierarchyRow.group_accessory_count} 个配件`
: hierarchyRow.asset_level === '配件'
? '已挂载'
: '无配件',
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,
@ -353,19 +358,30 @@ export const exportEquipment = async (req: Request, res: Response) => {
supplier: eq.supplier || ''
})
if (hierarchyRow.asset_level === '配件') {
row.outlineLevel = 1
row.getCell('equipment_name').alignment = {
...row.getCell('equipment_name').alignment,
indent: 1
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 }
}
} else if (hierarchyRow.asset_level === '主资产') {
row.font = { bold: true }
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF3F6F8' }
}
} else {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFAFAFA' }
}
}
})

View File

@ -1,7 +1,10 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { flattenEquipmentExportHierarchy } from '../equipmentExportHierarchy'
import {
flattenEquipmentExportHierarchy,
summarizeEquipmentExportHierarchy
} from '../equipmentExportHierarchy'
test('flattenEquipmentExportHierarchy 应让父资产后紧跟配件,并保留独立资产', () => {
const rows = flattenEquipmentExportHierarchy([
@ -81,3 +84,59 @@ test('flattenEquipmentExportHierarchy 应递归展平孙级配件且保持深度
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])
})
test('summarizeEquipmentExportHierarchy 应让每个根资产只占一行且子资产明细不跨组', () => {
const flattenedRows = flattenEquipmentExportHierarchy([
{
id: 2132,
equipment_code: 'JS-PC-2132',
equipment_name: '显示器',
children: [
{
id: 2133,
equipment_code: 'JS-PJ-2133',
equipment_name: 'CPU',
equipment_type: '处理器',
equipment_model: 'i7',
total_value: 1000
},
{ id: 2134, equipment_code: 'JS-PJ-2134', equipment_name: '内存' },
{ id: 2135, equipment_code: 'JS-PJ-2135', equipment_name: '硬盘' },
{ id: 2136, equipment_code: 'JS-PJ-2136', equipment_name: '主板' }
]
},
{
id: 2200,
equipment_code: 'JS-DY-2200',
equipment_name: '独立打印机',
children: []
},
{
id: 2300,
equipment_code: 'JS-PC-2300',
equipment_name: '办公主机',
children: [
{ id: 2301, equipment_code: 'JS-PJ-2301', equipment_name: 'CPU' }
]
}
])
const groups = summarizeEquipmentExportHierarchy(flattenedRows)
assert.equal(groups.length, 3)
assert.equal(groups[0].equipment.equipment_code, 'JS-PC-2132')
assert.equal(groups[0].child_count, 4)
assert.match(groups[0].child_details, /1\. JS-PJ-2133|CPU \/ 处理器|型号:i7|本体价值:1000\.00/)
assert.match(groups[0].child_details, /2\. JS-PJ-2134|内存|型号:-/)
assert.match(groups[0].child_details, /4\. JS-PJ-2136|主板/)
assert.doesNotMatch(groups[0].child_details, /JS-DY-2200|JS-PC-2300|JS-PJ-2301/)
assert.equal(groups[1].equipment.equipment_code, 'JS-DY-2200')
assert.equal(groups[1].asset_level, '独立资产')
assert.equal(groups[1].child_count, 0)
assert.equal(groups[1].child_details, '无子资产')
assert.equal(groups[2].equipment.equipment_code, 'JS-PC-2300')
assert.equal(groups[2].child_count, 1)
assert.match(groups[2].child_details, /JS-PJ-2301/)
assert.doesNotMatch(groups[2].child_details, /JS-PJ-2133|JS-DY-2200/)
})

View File

@ -23,5 +23,8 @@ 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('金额口径') || '', /可安全求和/)
})

View File

@ -4,11 +4,16 @@ export interface EquipmentExportHierarchyNode {
id: number
equipment_code: string
equipment_name: string
equipment_type?: string | null
equipment_model?: string | null
total_value?: unknown
total_value_with_children?: unknown
borrowed_value?: unknown
available_value?: unknown
status?: unknown
assetType?: {
name: string
} | null
children?: EquipmentExportHierarchyNode[]
}
@ -24,6 +29,15 @@ export interface FlattenedEquipmentExportRow<
group_accessory_count: number
}
export interface EquipmentExportGroupSummary {
equipment: EquipmentExportHierarchyNode
relationship_group_code: string
asset_level: Exclude<EquipmentExportHierarchyLevel, '配件'>
child_count: number
children: EquipmentExportHierarchyNode[]
child_details: string
}
const compareAccessories = (
left: EquipmentExportHierarchyNode,
right: EquipmentExportHierarchyNode
@ -108,3 +122,55 @@ export const flattenEquipmentExportHierarchy = (
return rows
}
const formatChildDetail = (child: EquipmentExportHierarchyNode, index: number) => {
const equipmentType = child.assetType?.name || child.equipment_type || '-'
const equipmentLabel = equipmentType === '-' || child.equipment_name.trim().toLocaleLowerCase() === equipmentType.trim().toLocaleLowerCase()
? child.equipment_name
: `${child.equipment_name} / ${equipmentType}`
const equipmentModel = child.equipment_model || '-'
const ownTotalValue = Number(child.total_value || 0)
const safeOwnTotalValue = Number.isFinite(ownTotalValue) ? ownTotalValue : 0
return `${index + 1}. ${child.equipment_code}|${equipmentLabel}|型号:${equipmentModel}|本体价值:${safeOwnTotalValue.toFixed(2)}`
}
export const summarizeEquipmentExportHierarchy = (
rows: FlattenedEquipmentExportRow[]
): EquipmentExportGroupSummary[] => {
const groups: EquipmentExportGroupSummary[] = []
const groupByCode = new Map<string, EquipmentExportGroupSummary>()
for (const row of rows) {
if (row.asset_level === '配件') {
continue
}
const group: EquipmentExportGroupSummary = {
equipment: row.equipment,
relationship_group_code: row.relationship_group_code,
asset_level: row.asset_level,
child_count: 0,
children: [],
child_details: '无子资产'
}
groups.push(group)
groupByCode.set(row.relationship_group_code, group)
}
for (const row of rows) {
if (row.asset_level !== '配件') {
continue
}
groupByCode.get(row.relationship_group_code)?.children.push(row.equipment)
}
for (const group of groups) {
group.child_count = group.children.length
group.child_details = group.children.length > 0
? group.children.map(formatChildDetail).join('\n')
: '无子资产'
}
return groups
}

View File

@ -61,6 +61,11 @@ export const buildEquipmentExportDescriptionRows = (exportedAt: Date) => [
item: '时效提醒',
description: '父子关系会随挂载、卸载而变化,请以系统最新导出为准。'
},
{
item: '分组阅读',
description:
'每个根资产只占一行;有子资产时统一写入“子资产数量”和“子资产明细”,明细按编码稳定排序并逐行列出。独立资产明确显示“无子资产”。'
},
{
item: '金额口径',
description: