From 879d74f668dcfa31a0de3bd099ad42f309a4f46a Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 10 Jul 2026 13:37:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AF=BC=E5=87=BA):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E7=88=B6=E5=AD=90=E5=85=B3=E7=B3=BB=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/controllers/exportController.ts | 186 +++++++++++++++--- .../equipmentExportHierarchy.test.ts | 83 ++++++++ .../__tests__/equipmentExportMetadata.test.ts | 27 +++ .../__tests__/equipmentExportValues.test.ts | 85 ++++++++ .../__tests__/equipmentKeywordSearch.test.ts | 34 ++++ backend/src/utils/equipmentExportHierarchy.ts | 110 +++++++++++ backend/src/utils/equipmentExportMetadata.ts | 69 +++++++ backend/src/utils/equipmentExportValues.ts | 70 +++++++ backend/src/utils/equipmentKeywordSearch.ts | 74 +++++++ frontend/src/api/export.ts | 15 +- .../src/views/equipment/EquipmentList.vue | 7 +- 11 files changed, 725 insertions(+), 35 deletions(-) create mode 100644 backend/src/utils/__tests__/equipmentExportHierarchy.test.ts create mode 100644 backend/src/utils/__tests__/equipmentExportMetadata.test.ts create mode 100644 backend/src/utils/__tests__/equipmentExportValues.test.ts create mode 100644 backend/src/utils/__tests__/equipmentKeywordSearch.test.ts create mode 100644 backend/src/utils/equipmentExportHierarchy.ts create mode 100644 backend/src/utils/equipmentExportMetadata.ts create mode 100644 backend/src/utils/equipmentExportValues.ts create mode 100644 backend/src/utils/equipmentKeywordSearch.ts diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index 678a2b1..3b22aca 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -1,15 +1,21 @@ 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 { - getEquipmentDisplayAvailableValue, - getEquipmentDisplayBorrowedValue, - getEquipmentDisplayTotalValue, - getEquipmentMountedChildrenValue, - sumSnapshotChildrenValue -} from '../utils/assetValue' + buildEquipmentExportDescriptionRows, + formatShanghaiExportFilenameTimestamp +} from '../utils/equipmentExportMetadata' +import { calculateEquipmentExportValues } from '../utils/equipmentExportValues' +import { buildEquipmentKeywordSearchConditions } from '../utils/equipmentKeywordSearch' import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' -// eslint-disable-next-line @typescript-eslint/no-var-requires -const ExcelJS = require('exceljs') + +const loadExcelJS = (): typeof import('exceljs') => { + // 低频导出按需加载,避免后台常驻流程在启动时就加载 Excel 组件。 + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('exceljs') +} // 生成带下拉选项的导入模板 export const generateImportTemplate = async (req: Request, res: Response) => { @@ -39,6 +45,7 @@ export const generateImportTemplate = async (req: Request, res: Response) => { const assetTypeNames = assetTypes.map(t => t.name) const locationNames = storageLocations.map(l => l.name) + const ExcelJS = loadExcelJS() const workbook = new ExcelJS.Workbook() // 创建主数据表 @@ -194,34 +201,85 @@ export const generateImportTemplate = async (req: Request, res: Response) => { // 导出资产列表 export const exportEquipment = async (req: Request, res: Response) => { + const exportedAt = new Date() try { - const { status, equipment_type, department_id, keyword } = req.query + const { + status, + equipment_type, + department_id, + storage_location_id, + mount_status, + keyword + } = req.query - const where: any = {} + const where: any = { parent_id: null } if (status) where.status = status if (equipment_type) where.equipment_type = equipment_type - if (department_id) where.department_id = Number(department_id) + const departmentId = Number(department_id) + if (department_id && !Number.isNaN(departmentId)) where.department_id = departmentId + const storageLocationId = Number(storage_location_id) + if (storage_location_id && !Number.isNaN(storageLocationId)) { + where.storage_location_id = storageLocationId + } + if (mount_status === 'mounted') { + where.children = { some: {} } + } else if (mount_status === 'unmounted') { + where.children = { none: {} } + } if (keyword) { - where.OR = [ - { equipment_code: { contains: String(keyword) } }, - { equipment_name: { contains: String(keyword) } } - ] + const normalizedKeyword = String(keyword).trim() + where.OR = buildEquipmentKeywordSearchConditions(normalizedKeyword, 2) } - const equipments = await prisma.equipment.findMany({ - where, - include: { - department: { select: { name: true } }, - storageLocation: { select: { name: true } }, - assetType: { select: { name: true } } - }, - orderBy: { created_at: 'desc' } - }) + const equipments = await prisma.$transaction( + async (tx) => + 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' }] + } + }, + orderBy: [{ created_at: 'desc' }, { id: 'desc' }] + }), + { + isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, + maxWait: 5000, + timeout: 30000 + } + ) + const exportRows = flattenEquipmentExportHierarchy(equipments) + const exportValues = calculateEquipmentExportValues(exportRows) + const ExcelJS = loadExcelJS() const workbook = new ExcelJS.Workbook() + workbook.created = exportedAt const worksheet = workbook.addWorksheet('资产列表') + worksheet.properties.outlineProperties = { summaryBelow: false, summaryRight: false } 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: 'relationship_status', width: 18 }, { header: '资产编码', key: 'equipment_code', width: 20 }, { header: '资产名称', key: 'equipment_name', width: 25 }, { header: '资产类型', key: 'equipment_type', width: 15 }, @@ -229,6 +287,7 @@ export const exportEquipment = async (req: Request, res: Response) => { { 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 }, @@ -243,9 +302,38 @@ export const exportEquipment = async (req: Request, res: Response) => { // 设置表头样式 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 = + '仅主资产和独立资产填写,可直接求和;配件行留空以避免与主资产重复计算。' + } - equipments.forEach(eq => { - worksheet.addRow({ + exportRows.forEach((hierarchyRow, index) => { + const eq: any = hierarchyRow.equipment + const calculatedValues = exportValues[index] + 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 || '', + relationship_status: + hierarchyRow.asset_level === '主资产' + ? `包含 ${hierarchyRow.group_accessory_count} 个配件` + : hierarchyRow.asset_level === '配件' + ? '已挂载' + : '无配件', equipment_code: eq.equipment_code, equipment_name: eq.equipment_name, equipment_type: eq.assetType?.name || eq.equipment_type, @@ -253,19 +341,53 @@ export const exportEquipment = async (req: Request, res: Response) => { department: eq.department?.name || '', storage_location: eq.storageLocation?.name || '', unit_price: Number(eq.unit_price), - display_total_value: getEquipmentDisplayTotalValue(eq), - mounted_children_value: getEquipmentMountedChildrenValue(eq), - borrowed_value: getEquipmentDisplayBorrowedValue(eq), - available_value: getEquipmentDisplayAvailableValue(eq), + 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 || '' }) + + if (hierarchyRow.asset_level === '配件') { + row.outlineLevel = 1 + row.getCell('equipment_name').alignment = { + ...row.getCell('equipment_name').alignment, + indent: 1 + } + } else if (hierarchyRow.asset_level === '主资产') { + row.font = { bold: true } + row.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF3F6F8' } + } + } }) - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + 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` res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') @@ -318,6 +440,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { orderBy: { borrow_time: 'desc' } }) + const ExcelJS = loadExcelJS() const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet('借用记录') @@ -389,6 +512,7 @@ export const generatePersonTemplate = async (req: Request, res: Response) => { const departmentNames = departments.map(d => d.name) + const ExcelJS = loadExcelJS() const workbook = new ExcelJS.Workbook() // 创建主数据表 diff --git a/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts b/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts new file mode 100644 index 0000000..7c11754 --- /dev/null +++ b/backend/src/utils/__tests__/equipmentExportHierarchy.test.ts @@ -0,0 +1,83 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { flattenEquipmentExportHierarchy } from '../equipmentExportHierarchy' + +test('flattenEquipmentExportHierarchy 应让父资产后紧跟配件,并保留独立资产', () => { + const rows = flattenEquipmentExportHierarchy([ + { + id: 10, + equipment_code: 'JS-PC-0010', + equipment_name: '设计主机', + children: [ + { id: 12, equipment_code: 'JS-PJ-0012', equipment_name: '显卡' }, + { id: 11, equipment_code: 'JS-PJ-0011', equipment_name: '内存' } + ] + }, + { + id: 20, + equipment_code: 'JS-DY-0020', + equipment_name: '独立打印机', + children: [] + } + ]) + + assert.deepEqual(rows.map((row) => row.equipment.id), [10, 11, 12, 20]) + assert.deepEqual(rows.map((row) => row.asset_level), ['主资产', '配件', '配件', '独立资产']) + assert.deepEqual(rows.map((row) => row.relationship_group_code), [ + 'JS-PC-0010', + 'JS-PC-0010', + 'JS-PC-0010', + 'JS-DY-0020' + ]) + assert.deepEqual(rows.map((row) => row.accessory_sequence), [null, 1, 2, null]) + assert.deepEqual(rows.map((row) => row.group_accessory_count), [2, 2, 2, 0]) + assert.equal(rows[1].parent_equipment_code, 'JS-PC-0010') + assert.equal(rows[1].parent_equipment_name, '设计主机') +}) + +test('flattenEquipmentExportHierarchy 应稳定排序配件且同一资产只导出一次', () => { + const duplicatedChild = { id: 32, equipment_code: 'JS-PJ-0032', equipment_name: '硬盘' } + const rows = flattenEquipmentExportHierarchy([ + { + id: 30, + equipment_code: 'JS-PC-0030', + equipment_name: '研发主机', + children: [ + duplicatedChild, + { id: 31, equipment_code: 'JS-PJ-0031', equipment_name: '内存' }, + duplicatedChild + ] + } + ]) + + assert.deepEqual(rows.map((row) => row.equipment.id), [30, 31, 32]) + assert.equal(new Set(rows.map((row) => row.equipment.id)).size, rows.length) + assert.deepEqual(rows.map((row) => row.accessory_sequence), [null, 1, 2]) +}) + +test('flattenEquipmentExportHierarchy 应递归展平孙级配件且保持深度优先连续', () => { + const rows = flattenEquipmentExportHierarchy([ + { + id: 40, + equipment_code: 'JS-PC-0040', + equipment_name: '多级主机', + children: [ + { + id: 42, + equipment_code: 'JS-PJ-0042', + equipment_name: '扩展坞', + children: [ + { id: 43, equipment_code: 'JS-PJ-0043', equipment_name: '扩展卡' } + ] + }, + { id: 41, equipment_code: 'JS-PJ-0041', equipment_name: '内存' } + ] + } + ]) + + assert.deepEqual(rows.map((row) => row.equipment.id), [40, 41, 42, 43]) + 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]) +}) diff --git a/backend/src/utils/__tests__/equipmentExportMetadata.test.ts b/backend/src/utils/__tests__/equipmentExportMetadata.test.ts new file mode 100644 index 0000000..6a93844 --- /dev/null +++ b/backend/src/utils/__tests__/equipmentExportMetadata.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildEquipmentExportDescriptionRows, + formatShanghaiExportDateTime, + formatShanghaiExportFilenameTimestamp +} from '../equipmentExportMetadata' + +test('资产导出时间应固定按 Asia/Shanghai 精确格式化到秒', () => { + const exportedAt = new Date('2026-07-10T16:02:03.000Z') + + assert.equal(formatShanghaiExportDateTime(exportedAt), '2026-07-11 00:02:03') + assert.equal(formatShanghaiExportFilenameTimestamp(exportedAt), '20260711_000203') +}) + +test('导出说明应完整声明快照、当前关系、时效与金额口径', () => { + const rows = buildEquipmentExportDescriptionRows(new Date('2026-07-10T01:02:03.000Z')) + const descriptionByItem = new Map(rows.map((row) => [row.item, row.description])) + + assert.equal(descriptionByItem.get('文件性质'), '导出时点快照') + assert.match(descriptionByItem.get('导出时间') || '', /2026-07-10 09:02:03(Asia\/Shanghai)/) + assert.match(descriptionByItem.get('关系口径') || '', /当前 parent_id \/ children/) + assert.match(descriptionByItem.get('关系口径') || '', /不包含历史挂载/) + assert.match(descriptionByItem.get('时效提醒') || '', /最新导出/) + assert.match(descriptionByItem.get('金额口径') || '', /可安全求和/) +}) diff --git a/backend/src/utils/__tests__/equipmentExportValues.test.ts b/backend/src/utils/__tests__/equipmentExportValues.test.ts new file mode 100644 index 0000000..939ba5f --- /dev/null +++ b/backend/src/utils/__tests__/equipmentExportValues.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { flattenEquipmentExportHierarchy } from '../equipmentExportHierarchy' +import { calculateEquipmentExportValues } from '../equipmentExportValues' + +test('主资产组合金额应现场汇总全部孙级本体价值并忽略根预存组合值', () => { + const rows = flattenEquipmentExportHierarchy([ + { + id: 1, + equipment_code: 'JS-PC-0001', + equipment_name: '主机', + total_value: 100, + total_value_with_children: 150, + status: '已借出', + children: [ + { + id: 2, + equipment_code: 'JS-PJ-0002', + equipment_name: '扩展坞', + total_value: 50, + children: [ + { + id: 3, + equipment_code: 'JS-PJ-0003', + equipment_name: '扩展卡', + total_value: 30 + } + ] + } + ] + } + ]) + const values = calculateEquipmentExportValues(rows) + + assert.deepEqual(values[0], { + own_total_value: 100, + display_total_value: 180, + mounted_children_value: 80, + borrowed_value: 180, + available_value: 0 + }) + assert.deepEqual(values.slice(1), [ + { + own_total_value: 50, + display_total_value: '', + mounted_children_value: '', + borrowed_value: '', + available_value: '' + }, + { + own_total_value: 30, + display_total_value: '', + mounted_children_value: '', + borrowed_value: '', + available_value: '' + } + ]) +}) + +test('可用主资产应把现场组合总额计入可用价值', () => { + const rows = flattenEquipmentExportHierarchy([ + { + id: 10, + equipment_code: 'JS-PC-0010', + equipment_name: '可用主机', + total_value: 200, + total_value_with_children: 0, + status: '可用', + children: [ + { + id: 11, + equipment_code: 'JS-PJ-0011', + equipment_name: '内存', + total_value: 60 + } + ] + } + ]) + const values = calculateEquipmentExportValues(rows) + + assert.equal(values[0].display_total_value, 260) + assert.equal(values[0].borrowed_value, 0) + assert.equal(values[0].available_value, 260) +}) diff --git a/backend/src/utils/__tests__/equipmentKeywordSearch.test.ts b/backend/src/utils/__tests__/equipmentKeywordSearch.test.ts new file mode 100644 index 0000000..e1456a7 --- /dev/null +++ b/backend/src/utils/__tests__/equipmentKeywordSearch.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildEquipmentKeywordSearchConditions } from '../equipmentKeywordSearch' + +test('buildEquipmentKeywordSearchConditions 应覆盖设备、部门、地址和当前借用人字段', () => { + const serialized = JSON.stringify(buildEquipmentKeywordSearchConditions('A1001', 2)) + + for (const field of [ + 'equipment_code', + 'equipment_name', + 'equipment_model', + 'department', + 'storageLocation', + 'borrow_records', + 'person_nickname', + 'person_name', + 'nickname', + 'real_name', + 'job_number' + ]) { + assert.equal(serialized.includes(field), true, `应包含 ${field}`) + } + + assert.equal(serialized.includes('person_identifier'), false) + assert.equal(serialized.includes('id_number'), false) +}) + +test('buildEquipmentKeywordSearchConditions 深度 2 应允许父资产、配件和孙级配件命中', () => { + const serialized = JSON.stringify(buildEquipmentKeywordSearchConditions('显卡', 2)) + const childRelationCount = (serialized.match(/"children"/g) || []).length + + assert.equal(childRelationCount, 2) +}) diff --git a/backend/src/utils/equipmentExportHierarchy.ts b/backend/src/utils/equipmentExportHierarchy.ts new file mode 100644 index 0000000..22bd442 --- /dev/null +++ b/backend/src/utils/equipmentExportHierarchy.ts @@ -0,0 +1,110 @@ +export type EquipmentExportHierarchyLevel = '主资产' | '配件' | '独立资产' + +export interface EquipmentExportHierarchyNode { + id: number + equipment_code: string + equipment_name: string + total_value?: unknown + total_value_with_children?: unknown + borrowed_value?: unknown + available_value?: unknown + status?: unknown + children?: EquipmentExportHierarchyNode[] +} + +export interface FlattenedEquipmentExportRow< + TEquipment extends EquipmentExportHierarchyNode = EquipmentExportHierarchyNode +> { + equipment: TEquipment + relationship_group_code: string + asset_level: EquipmentExportHierarchyLevel + parent_equipment_code: string | null + parent_equipment_name: string | null + accessory_sequence: number | null + group_accessory_count: number +} + +const compareAccessories = ( + left: EquipmentExportHierarchyNode, + right: EquipmentExportHierarchyNode +) => { + const codeComparison = left.equipment_code.localeCompare(right.equipment_code) + return codeComparison || left.id - right.id +} + +const collectDescendants = ( + parent: EquipmentExportHierarchyNode, + exportedEquipmentIds: Set, + ancestorIds: Set +) => { + const descendants: EquipmentExportHierarchyNode[] = [] + const childIdsInCurrentGroup = new Set() + const children = [...(parent.children || [])] + .filter((child) => { + if ( + exportedEquipmentIds.has(child.id) || + ancestorIds.has(child.id) || + childIdsInCurrentGroup.has(child.id) + ) { + return false + } + childIdsInCurrentGroup.add(child.id) + return true + }) + .sort(compareAccessories) + + for (const child of children) { + exportedEquipmentIds.add(child.id) + descendants.push(child) + descendants.push( + ...collectDescendants( + child, + exportedEquipmentIds, + new Set([...ancestorIds, child.id]) + ) + ) + } + + return descendants +} + +export const flattenEquipmentExportHierarchy = ( + parents: EquipmentExportHierarchyNode[] +): FlattenedEquipmentExportRow[] => { + const rows: FlattenedEquipmentExportRow[] = [] + const exportedEquipmentIds = new Set() + + for (const parent of parents) { + if (exportedEquipmentIds.has(parent.id)) { + continue + } + + exportedEquipmentIds.add(parent.id) + const descendants = collectDescendants(parent, exportedEquipmentIds, new Set([parent.id])) + const assetLevel: EquipmentExportHierarchyLevel = descendants.length > 0 ? '主资产' : '独立资产' + + rows.push({ + equipment: parent, + relationship_group_code: parent.equipment_code, + asset_level: assetLevel, + parent_equipment_code: null, + parent_equipment_name: null, + accessory_sequence: null, + group_accessory_count: descendants.length + }) + + descendants.forEach((child, 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, + accessory_sequence: index + 1, + group_accessory_count: descendants.length + }) + }) + } + + return rows +} diff --git a/backend/src/utils/equipmentExportMetadata.ts b/backend/src/utils/equipmentExportMetadata.ts new file mode 100644 index 0000000..df054f1 --- /dev/null +++ b/backend/src/utils/equipmentExportMetadata.ts @@ -0,0 +1,69 @@ +const SHANGHAI_TIME_ZONE = 'Asia/Shanghai' + +type ShanghaiDateTimeParts = { + year: string + month: string + day: string + hour: string + minute: string + second: string +} + +const getShanghaiDateTimeParts = (date: Date): ShanghaiDateTimeParts => { + if (Number.isNaN(date.getTime())) { + throw new Error('导出时间无效') + } + + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: SHANGHAI_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }).formatToParts(date) + const partMap = new Map(parts.map((part) => [part.type, part.value])) + + return { + year: partMap.get('year') || '', + month: partMap.get('month') || '', + day: partMap.get('day') || '', + hour: partMap.get('hour') || '', + minute: partMap.get('minute') || '', + second: partMap.get('second') || '' + } +} + +export const formatShanghaiExportDateTime = (date: Date) => { + const parts = getShanghaiDateTimeParts(date) + return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` +} + +export const formatShanghaiExportFilenameTimestamp = (date: Date) => { + const parts = getShanghaiDateTimeParts(date) + return `${parts.year}${parts.month}${parts.day}_${parts.hour}${parts.minute}${parts.second}` +} + +export const buildEquipmentExportDescriptionRows = (exportedAt: Date) => [ + { item: '文件性质', description: '导出时点快照' }, + { + item: '导出时间', + description: `${formatShanghaiExportDateTime(exportedAt)}(Asia/Shanghai)` + }, + { + item: '关系口径', + description: + '按导出时同一次只读事务快照中的当前 parent_id / children 父子关系生成,不包含历史挂载记录。' + }, + { + item: '时效提醒', + description: '父子关系会随挂载、卸载而变化,请以系统最新导出为准。' + }, + { + item: '金额口径', + description: + '本体价值为每项资产自身价值;主资产组合金额现场汇总组内全部本体价值。当前价值、借出价值、可用价值和挂载配件价值仅在主资产或独立资产行填写,可安全求和;配件价值请查看本体价值。' + } +] diff --git a/backend/src/utils/equipmentExportValues.ts b/backend/src/utils/equipmentExportValues.ts new file mode 100644 index 0000000..9813d9e --- /dev/null +++ b/backend/src/utils/equipmentExportValues.ts @@ -0,0 +1,70 @@ +import { + getEquipmentDisplayAvailableValue, + getEquipmentDisplayBorrowedValue, + getEquipmentDisplayTotalValue, + getEquipmentMountedChildrenValue +} from './assetValue' +import type { FlattenedEquipmentExportRow } from './equipmentExportHierarchy' + +export interface EquipmentExportCalculatedValues { + own_total_value: number + display_total_value: number | '' + mounted_children_value: number | '' + borrowed_value: number | '' + available_value: number | '' +} + +const toSafeNumber = (value: unknown) => { + const numericValue = Number(value || 0) + return Number.isFinite(numericValue) ? numericValue : 0 +} + +export const calculateEquipmentExportValues = ( + rows: FlattenedEquipmentExportRow[] +): EquipmentExportCalculatedValues[] => { + const groupTotalValues = new Map() + + for (const row of rows) { + const ownTotalValue = toSafeNumber(row.equipment.total_value) + groupTotalValues.set( + row.relationship_group_code, + (groupTotalValues.get(row.relationship_group_code) || 0) + ownTotalValue + ) + } + + return rows.map((row) => { + const equipment = row.equipment + const ownTotalValue = toSafeNumber(equipment.total_value) + + if (row.asset_level === '配件') { + return { + own_total_value: ownTotalValue, + display_total_value: '', + mounted_children_value: '', + borrowed_value: '', + available_value: '' + } + } + + if (row.asset_level === '独立资产') { + return { + own_total_value: ownTotalValue, + display_total_value: getEquipmentDisplayTotalValue(equipment), + mounted_children_value: getEquipmentMountedChildrenValue(equipment), + borrowed_value: getEquipmentDisplayBorrowedValue(equipment), + available_value: getEquipmentDisplayAvailableValue(equipment) + } + } + + const combinedTotalValue = groupTotalValues.get(row.relationship_group_code) || ownTotalValue + const status = String(equipment.status || '') + + return { + own_total_value: ownTotalValue, + display_total_value: combinedTotalValue, + mounted_children_value: Math.max(combinedTotalValue - ownTotalValue, 0), + borrowed_value: status === '已借出' ? combinedTotalValue : 0, + available_value: status === '可用' ? combinedTotalValue : 0 + } + }) +} diff --git a/backend/src/utils/equipmentKeywordSearch.ts b/backend/src/utils/equipmentKeywordSearch.ts new file mode 100644 index 0000000..afda98c --- /dev/null +++ b/backend/src/utils/equipmentKeywordSearch.ts @@ -0,0 +1,74 @@ +type EquipmentKeywordCondition = Record + +const buildOwnEquipmentKeywordConditions = (keyword: string): EquipmentKeywordCondition[] => [ + { equipment_name: { contains: keyword } }, + { equipment_code: { contains: keyword } }, + { equipment_model: { contains: keyword } }, + { + department: { + is: { + name: { contains: keyword } + } + } + }, + { + storageLocation: { + is: { + name: { contains: keyword } + } + } + }, + { + borrow_records: { + some: { + status: '已借出', + OR: [ + { person_nickname: { contains: keyword } }, + { person_name: { contains: keyword } }, + { + person: { + is: { + nickname: { contains: keyword } + } + } + }, + { + person: { + is: { + real_name: { contains: keyword } + } + } + }, + { + person: { + is: { + job_number: { contains: keyword } + } + } + } + ] + } + } + } +] + +export const buildEquipmentKeywordSearchConditions = ( + keyword: string, + descendantDepth = 0 +): EquipmentKeywordCondition[] => { + const ownConditions = buildOwnEquipmentKeywordConditions(keyword) + if (descendantDepth <= 0) { + return ownConditions + } + + return [ + ...ownConditions, + { + children: { + some: { + OR: buildEquipmentKeywordSearchConditions(keyword, descendantDepth - 1) + } + } + } + ] +} diff --git a/frontend/src/api/export.ts b/frontend/src/api/export.ts index bca7f8b..1db5dfb 100644 --- a/frontend/src/api/export.ts +++ b/frontend/src/api/export.ts @@ -3,12 +3,21 @@ import request from './index' // 导出资产列表 export const exportEquipment = (params?: { status?: string - equipment_type?: string - department_id?: number + equipmentType?: string + departmentId?: number | string + storageLocationId?: number | string + mountStatus?: string keyword?: string }) => { return request.get('/export/equipment', { - params, + params: { + status: params?.status, + equipment_type: params?.equipmentType, + department_id: params?.departmentId, + storage_location_id: params?.storageLocationId, + mount_status: params?.mountStatus, + keyword: params?.keyword + }, responseType: 'blob' }) } diff --git a/frontend/src/views/equipment/EquipmentList.vue b/frontend/src/views/equipment/EquipmentList.vue index 248b97d..d565508 100644 --- a/frontend/src/views/equipment/EquipmentList.vue +++ b/frontend/src/views/equipment/EquipmentList.vue @@ -1424,9 +1424,14 @@ const toggleSelectAll = () => { const handlePrevPage = () => { if (currentPage.value > 1) { currentPage.value--; fetchData() } } const handleNextPage = () => { if (currentPage.value < totalPages.value) { currentPage.value++; fetchData() } } +const formatExportTimestamp = (date = new Date()) => { + const pad = (value: number) => String(value).padStart(2, '0') + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` +} + const handleExport = async () => { const res = await exportEquipment(filters.value) - downloadFile(res.data as Blob, '资产列表.xlsx') + downloadFile(res.data as Blob, `资产列表_${formatExportTimestamp()}.xlsx`) } const handleExportAndClose = async () => {