diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index 6a2dd24..f7ddd93 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -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)}`) diff --git a/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts b/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts new file mode 100644 index 0000000..d0f1a92 --- /dev/null +++ b/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts @@ -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) +}) diff --git a/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts b/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts index 9a3b4b2..d0a5dce 100644 --- a/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts +++ b/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts @@ -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 应让每个根资产只占一行且子资产明细不跨组', () => { diff --git a/backend/src/utils/__tests__/equipmentExportMetadata.test.ts b/backend/src/utils/__tests__/equipmentExportMetadata.test.ts index 6efa08d..bf44988 100644 --- a/backend/src/utils/__tests__/equipmentExportMetadata.test.ts +++ b/backend/src/utils/__tests__/equipmentExportMetadata.test.ts @@ -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('金额口径') || '', /可安全求和/) }) diff --git a/backend/src/utils/equipmentAdministrativeExport.ts b/backend/src/utils/equipmentAdministrativeExport.ts new file mode 100644 index 0000000..19b92ba --- /dev/null +++ b/backend/src/utils/equipmentAdministrativeExport.ts @@ -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([ + ['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 +) => { + 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 = { + 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) => + 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 + 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)) + 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)) + ) + 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 +} diff --git a/backend/src/utils/equipmentExportHierarchy.ts b/backend/src/utils/equipmentExportHierarchy.ts index 292e7a2..83cbb38 100644 --- a/backend/src/utils/equipmentExportHierarchy.ts +++ b/backend/src/utils/equipmentExportHierarchy.ts @@ -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 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 +) => { + const rootNodes = roots.map((root) => ({ ...root, children: [] as EquipmentExportHierarchyNode[] })) + const equipmentById = new Map( + 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, ancestorIds: Set ) => { - const descendants: EquipmentExportHierarchyNode[] = [] + const descendants: CollectedEquipmentExportDescendant[] = [] const childIdsInCurrentGroup = new Set() 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) { diff --git a/backend/src/utils/equipmentExportMetadata.ts b/backend/src/utils/equipmentExportMetadata.ts index 1042440..7c89e49 100644 --- a/backend/src/utils/equipmentExportMetadata.ts +++ b/backend/src/utils/equipmentExportMetadata.ts @@ -62,13 +62,18 @@ export const buildEquipmentExportDescriptionRows = (exportedAt: Date) => [ description: '父子关系会随挂载、卸载而变化,请以系统最新导出为准。' }, { - item: '分组阅读', + item: '台账阅读', description: - '每个根资产只占一行;有子资产时统一写入“子资产数量”和“子资产明细”,明细按编码稳定排序并逐行列出。独立资产明确显示“无子资产”。' + '“行政资产台账”每个根资产只占一行;当前子资产按 CPU、主板、内存、固态硬盘、机械硬盘、显卡六类拆列,无法识别的类型完整保留在“其他子资产”。同一分类有多项时按资产编码稳定排序并换行显示。' + }, + { + item: '关系核对', + description: + '“关系核对”只提示非电脑类根资产包含硬件子资产、存在未识别子资产或存在多个当前借用记录,不会自动改写当前资产关系;完整父子关系请查看“资产关系明细”。' }, { item: '金额口径', description: - '本体价值为每项资产自身价值;主资产组合金额现场汇总组内全部本体价值。当前价值、借出价值、可用价值和挂载配件价值仅在主资产或独立资产行填写,可安全求和;配件价值请查看本体价值。' + '“当前价值(元)”沿用现有金额口径:根资产组合金额在导出快照内汇总组内全部本体价值,每个根资产只占一行,可安全求和;子资产自身价值请查看“资产关系明细”。' } ]