diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index 3b22aca..6a2dd24 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -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' } + } } }) diff --git a/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts b/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts index 7c11754..9a3b4b2 100644 --- a/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts +++ b/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts @@ -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/) +}) diff --git a/backend/src/utils/__tests__/equipmentExportMetadata.test.ts b/backend/src/utils/__tests__/equipmentExportMetadata.test.ts index 6a93844..6efa08d 100644 --- a/backend/src/utils/__tests__/equipmentExportMetadata.test.ts +++ b/backend/src/utils/__tests__/equipmentExportMetadata.test.ts @@ -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('金额口径') || '', /可安全求和/) }) diff --git a/backend/src/utils/equipmentExportHierarchy.ts b/backend/src/utils/equipmentExportHierarchy.ts index 22bd442..292e7a2 100644 --- a/backend/src/utils/equipmentExportHierarchy.ts +++ b/backend/src/utils/equipmentExportHierarchy.ts @@ -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 + 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() + + 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 +} diff --git a/backend/src/utils/equipmentExportMetadata.ts b/backend/src/utils/equipmentExportMetadata.ts index df054f1..1042440 100644 --- a/backend/src/utils/equipmentExportMetadata.ts +++ b/backend/src/utils/equipmentExportMetadata.ts @@ -61,6 +61,11 @@ export const buildEquipmentExportDescriptionRows = (exportedAt: Date) => [ item: '时效提醒', description: '父子关系会随挂载、卸载而变化,请以系统最新导出为准。' }, + { + item: '分组阅读', + description: + '每个根资产只占一行;有子资产时统一写入“子资产数量”和“子资产明细”,明细按编码稳定排序并逐行列出。独立资产明确显示“无子资产”。' + }, { item: '金额口径', description: