diff --git a/backend/src/controllers/__tests__/borrowExport.test.ts b/backend/src/controllers/__tests__/borrowExport.test.ts new file mode 100644 index 0000000..da81894 --- /dev/null +++ b/backend/src/controllers/__tests__/borrowExport.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { PassThrough, Readable } from 'node:stream' +import { finished } from 'node:stream/promises' +import test from 'node:test' +import ExcelJS from 'exceljs' +import prisma from '../../utils/prisma' +import { getBorrowRecordList } from '../borrowController' +import { exportBorrowRecords } from '../exportController' + +const readBorrowWorkbook = async (query: Record) => { + const stream = new PassThrough() + const chunks: Buffer[] = [] + stream.on('data', chunk => chunks.push(Buffer.from(chunk))) + let statusCode = 200 + let errorBody: unknown + const response = Object.assign(stream, { + setHeader() {}, + status(code: number) { statusCode = code; return this }, + json(value: unknown) { errorBody = value; stream.end() } + }) + await exportBorrowRecords({ query } as any, response as any) + await finished(stream) + assert.equal(statusCode, 200, JSON.stringify(errorBody)) + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.read(Readable.from(chunks)) + return workbook.getWorksheet('借用记录')! +} + +test('借用列表与 Excel 对状态、人员(含当前工号)、资产关键词取交集,只有列表分页', async (t) => { + const queries: any[] = [] + let countWhere: unknown + const originalFindMany = prisma.borrowRecord.findMany + const originalCount = prisma.borrowRecord.count + ;(prisma.borrowRecord.findMany as any) = async (args: unknown) => { queries.push(args); return [] } + ;(prisma.borrowRecord.count as any) = async ({ where }: any) => { countWhere = where; return 0 } + t.after(() => { prisma.borrowRecord.findMany = originalFindMany; prisma.borrowRecord.count = originalCount }) + const query = { status: '已借出', person_id: '8', person_name: 'EMP-18', keyword: '主机', page: '2', pageSize: '5' } + await getBorrowRecordList({ query } as any, { + json() {}, status(code: number) { assert.fail(`列表意外返回 ${code}`) } + } as any) + await readBorrowWorkbook(query) + const [list, excel] = queries + assert.deepEqual(list.where, { AND: [ + { status: '已借出' }, { person_id: 8 }, + { OR: [ + { person_name: { contains: 'EMP-18' } }, { person_nickname: { contains: 'EMP-18' } }, + { person: { is: { job_number: { contains: 'EMP-18' } } } } + ] }, + { OR: [{ equipment_code: { contains: '主机' } }, { equipment: { is: { equipment_name: { contains: '主机' } } } }] } + ] }) + assert.deepEqual(countWhere, list.where) + assert.deepEqual(excel.where, list.where) + assert.equal(list.skip, 5); assert.equal(list.take, 5) + assert.equal(excel.skip, undefined); assert.equal(excel.take, undefined) + + await readBorrowWorkbook({ ...query, start_date: '2026-11-01', end_date: '2026-11-09T16:00:00+08:00' }) + assert.deepEqual(queries[2].where.AND, [...list.where.AND, { borrow_time: { + gte: new Date('2026-11-01T00:00:00Z'), lte: new Date('2026-11-09T08:00:00Z') + } }]) +}) + +test('借用 Excel 在非上海运行时仍按北京时间显示跨日日期,保留长期和空日期', async (t) => { + const previousTimezone = process.env.TZ + process.env.TZ = 'America/Los_Angeles' + const record = { + equipment_code: 'JS-TEST-01', equipment: { equipment_name: '主机', unit_price: 100 }, + person_name: '测试', person_nickname: '员工', person: { job_number: 'EMP-18' }, + borrow_time: new Date('2026-11-08T16:01:02Z'), + expected_return_time: new Date('2026-11-09T16:15:00Z'), + return_time: new Date('2026-11-10T16:30:42Z'), + is_long_term: false, status: '已归还', return_condition: '正常', children_snapshot: null + } + const originalFindMany = prisma.borrowRecord.findMany + ;(prisma.borrowRecord.findMany as any) = async () => [ + record, + { ...record, equipment_code: 'JS-TEST-02', is_long_term: true, return_time: null }, + { ...record, equipment_code: 'JS-TEST-03', expected_return_time: null, return_time: null } + ] + t.after(() => { prisma.borrowRecord.findMany = originalFindMany }) + try { + const worksheet = await readBorrowWorkbook({}) + assert.equal(worksheet.getCell('G2').text, '2026-11-09 00:01:02') + assert.equal(worksheet.getCell('H2').text, '2026-11-10') + assert.equal(worksheet.getCell('I2').text, '2026-11-11 00:30:42') + assert.equal(worksheet.getCell('H3').text, '长期') + assert.equal(worksheet.getCell('I3').text, '') + assert.equal(worksheet.getCell('H4').text, '') + assert.equal(worksheet.getCell('I4').text, '') + } finally { + if (previousTimezone === undefined) delete process.env.TZ + else process.env.TZ = previousTimezone + } +}) diff --git a/backend/src/controllers/borrowController.ts b/backend/src/controllers/borrowController.ts index 5feae11..aa2fe62 100644 --- a/backend/src/controllers/borrowController.ts +++ b/backend/src/controllers/borrowController.ts @@ -9,6 +9,7 @@ import { } from '../utils/assetValue' import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly' import { assertReturnStorageDepartmentContract } from '../utils/borrowDepartment' +import { buildBorrowRecordFilters } from '../utils/borrowRecordFilters' import { buildParentAssetFirstOrderBy, sortParentAssetsFirst @@ -1065,43 +1066,8 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => { // 获取借用记录列表 export const getBorrowRecordList = async (req: Request, res: Response) => { try { - const { page = 1, pageSize = 10, status, person_name, person_id, keyword } = req.query - - const filters: any[] = [] - - if (status) { - filters.push({ status }) - } - - const personIdNum = parseNumber(person_id) - if (personIdNum) { - filters.push({ person_id: personIdNum }) - } - - if (person_name) { - const kw = person_name as string - filters.push({ - OR: [ - { person_name: { contains: kw } }, - { person_nickname: { contains: kw } } - ] - }) - } - - if (keyword) { - const kw = keyword as string - filters.push({ - OR: [ - { equipment_code: { contains: kw } }, - { - equipment: { - equipment_name: { contains: kw } - } - } - ] - }) - } - + const { page = 1, pageSize = 10 } = req.query + const filters = buildBorrowRecordFilters(req.query) const where = filters.length ? { AND: filters } : undefined const skip = (Number(page) - 1) * Number(pageSize) diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index 0d5d6a0..1f4a12f 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -8,8 +8,10 @@ import { summarizeEquipmentExportHierarchy } from '../utils/equipmentExportHierarchy' import { + formatShanghaiExportDateTime, formatShanghaiExportFilenameTimestamp } from '../utils/equipmentExportMetadata' +import { buildBorrowRecordFilters } from '../utils/borrowRecordFilters' import { calculateEquipmentExportValues } from '../utils/equipmentExportValues' import { buildEquipmentKeywordSearchConditions } from '../utils/equipmentKeywordSearch' import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' @@ -311,22 +313,15 @@ export const exportEquipment = async (req: Request, res: Response) => { // 导出借用记录 export const exportBorrowRecords = async (req: Request, res: Response) => { try { - const { status, person_name, start_date, end_date } = req.query - - const where: any = {} - if (status) where.status = status - if (person_name) { - where.OR = [ - { person_name: { contains: String(person_name) } }, - { person_nickname: { contains: String(person_name) } }, - { person: { is: { job_number: { contains: String(person_name) } } } } - ] - } + const { start_date, end_date } = req.query + const filters = buildBorrowRecordFilters(req.query) if (start_date || end_date) { - where.borrow_time = {} - if (start_date) where.borrow_time.gte = new Date(String(start_date)) - if (end_date) where.borrow_time.lte = new Date(String(end_date)) + filters.push({ borrow_time: { + gte: start_date ? new Date(String(start_date)) : undefined, + lte: end_date ? new Date(String(end_date)) : undefined + } }) } + const where = filters.length ? { AND: filters } : undefined const records = await prisma.borrowRecord.findMany({ where, @@ -378,9 +373,10 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { person_nickname: record.person_nickname, current_job_number: record.person?.job_number || '', department: record.person?.mainDepartment?.name || record.person?.department || '', - borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '', - expected_return_time: record.is_long_term ? '长期' : (record.expected_return_time ? new Date(record.expected_return_time).toLocaleDateString('zh-CN') : ''), - return_time: record.return_time ? new Date(record.return_time).toLocaleString('zh-CN') : '', + borrow_time: record.borrow_time ? formatShanghaiExportDateTime(record.borrow_time) : '', + expected_return_time: record.is_long_term ? '长期' : + (record.expected_return_time ? formatShanghaiExportDateTime(record.expected_return_time).slice(0, 10) : ''), + return_time: record.return_time ? formatShanghaiExportDateTime(record.return_time) : '', equipment_value: Number(record.equipment?.unit_price || 0) + sumSnapshotChildrenValue(record.children_snapshot), status: record.status, return_condition: record.return_condition, @@ -388,7 +384,7 @@ export const exportBorrowRecords = async (req: Request, res: Response) => { }) }) - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = formatShanghaiExportFilenameTimestamp(new Date()).slice(0, 8) const filename = `借用记录_${timestamp}.xlsx` res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') diff --git a/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts b/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts index 9a59c99..9b9f8e7 100644 --- a/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts +++ b/backend/src/utils/__tests__/equipmentAdministrativeExport.test.ts @@ -351,6 +351,8 @@ test('工作簿应生成三张行政导出表并保留日期、金额和版式 equipment_name: '财务电脑', equipment_type: '电脑', status: '可用', + department: { name: '财务部' }, + storageLocation: { name: '财务库' }, purchase_date: '2026-03-04T00:00:00.000Z', unit_price: '6000.5', total_value: 6000.5, @@ -361,7 +363,17 @@ test('工作簿应生成三张行政导出表并保留日期、金额和版式 equipment_name: '内存', equipment_type: '内存条', equipment_model: '32GB', - total_value: 300 + total_value: 300, + department: { name: '设备部' }, + storageLocation: { name: '配件库' } + }, + { + id: 3, + equipment_code: 'PART-SSD', + equipment_name: '固态硬盘', + equipment_type: '固态硬盘', + department: null, + storageLocation: null } ] } @@ -389,7 +401,13 @@ test('工作簿应生成三张行政导出表并保留日期、金额和版式 assert.equal(mainSheet.getCell('H2').border.top?.color?.argb, 'FFD1D5DB') assert.equal(mainSheet.getCell('S2').border.right?.style, 'thin') assert.strictEqual(mainSheet.getCell('A1').border, mainSheet.getCell('S2').border) - assert.strictEqual(mainSheet.getCell('A1').border, relationSheet.getCell('J2').border) + assert.strictEqual(mainSheet.getCell('A1').border, relationSheet.getCell('L2').border) + assert.equal(relationSheet.getCell('K1').value, '子资产部门') + assert.equal(relationSheet.getCell('L1').value, '子资产原库位') + assert.equal(relationSheet.getCell('K2').value, '设备部') + assert.equal(relationSheet.getCell('L2').value, '配件库') + assert.equal(relationSheet.getCell('K3').value, '公共库存') + assert.equal(relationSheet.getCell('L3').value, null) assert.equal(mainSheet.getCell('J2').alignment.wrapText, true) assert.equal(mainSheet.getCell('F2').value, null) assert.equal(mainSheet.getCell('L2').value, null) diff --git a/backend/src/utils/borrowRecordFilters.ts b/backend/src/utils/borrowRecordFilters.ts new file mode 100644 index 0000000..25a7c79 --- /dev/null +++ b/backend/src/utils/borrowRecordFilters.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@prisma/client' + +export const buildBorrowRecordFilters = (query: { + status?: unknown + person_id?: unknown + person_name?: unknown + keyword?: unknown +}): Prisma.BorrowRecordWhereInput[] => { + const { status, person_id, person_name, keyword } = query + const filters: Prisma.BorrowRecordWhereInput[] = [] + if (typeof status === 'string' && status) filters.push({ status }) + + const personId = typeof person_id === 'string' || typeof person_id === 'number' ? Number(person_id) : 0 + if (Number.isSafeInteger(personId) && personId > 0) filters.push({ person_id: personId }) + + if (typeof person_name === 'string' && person_name) filters.push({ + OR: [ + { person_name: { contains: person_name } }, + { person_nickname: { contains: person_name } }, + { person: { is: { job_number: { contains: person_name } } } } + ] + }) + if (typeof keyword === 'string' && keyword) filters.push({ + OR: [ + { equipment_code: { contains: keyword } }, + { equipment: { is: { equipment_name: { contains: keyword } } } } + ] + }) + return filters +} diff --git a/backend/src/utils/equipmentAdministrativeExport.ts b/backend/src/utils/equipmentAdministrativeExport.ts index d5add2a..fb37d78 100644 --- a/backend/src/utils/equipmentAdministrativeExport.ts +++ b/backend/src/utils/equipmentAdministrativeExport.ts @@ -38,6 +38,8 @@ export interface EquipmentAdministrativeRelationRow { child_equipment_type: string child_equipment_model: string child_own_total_value: number + child_department: string + child_storage_location: string } const HARDWARE_TYPE_ALIASES = new Map([ @@ -159,7 +161,9 @@ export const buildEquipmentAdministrativeRows = ( 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) + child_own_total_value: toSafeNumber(child.total_value), + child_department: child.department?.name || '公共库存', + child_storage_location: child.storageLocation?.name || '' }) }) @@ -327,7 +331,9 @@ export const buildAdministrativeEquipmentWorkbook = ( { 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 } + { header: '子资产本体价值', key: 'child_own_total_value', width: 18 }, + { header: '子资产部门', key: 'child_department', width: 18 }, + { header: '子资产原库位', key: 'child_storage_location', width: 24 } ] applyHeaderStyle(relationWorksheet) relationWorksheet.addRows( diff --git a/backend/src/utils/equipmentExportMetadata.ts b/backend/src/utils/equipmentExportMetadata.ts index 7c89e49..cca1941 100644 --- a/backend/src/utils/equipmentExportMetadata.ts +++ b/backend/src/utils/equipmentExportMetadata.ts @@ -1,4 +1,14 @@ const SHANGHAI_TIME_ZONE = 'Asia/Shanghai' +const shanghaiDateTimeFormatter = 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' +}) type ShanghaiDateTimeParts = { year: string @@ -14,16 +24,7 @@ const getShanghaiDateTimeParts = (date: Date): ShanghaiDateTimeParts => { 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 parts = shanghaiDateTimeFormatter.formatToParts(date) const partMap = new Map(parts.map((part) => [part.type, part.value])) return { @@ -55,7 +56,7 @@ export const buildEquipmentExportDescriptionRows = (exportedAt: Date) => [ { item: '关系口径', description: - '按导出时同一次只读事务快照中的当前 parent_id / children 父子关系生成,不包含历史挂载记录。' + '按导出时同一次只读事务快照中的当前 parent_id / children 父子关系生成,不包含历史挂载记录。子资产部门和原库位来自该配件的台账,未指定部门显示为公共库存,未登记库位留空。' }, { item: '时效提醒', diff --git a/frontend/src/api/export.ts b/frontend/src/api/export.ts index 12ca253..f495dd1 100644 --- a/frontend/src/api/export.ts +++ b/frontend/src/api/export.ts @@ -27,6 +27,7 @@ export const exportEquipment = (params?: { export const exportBorrowRecords = (params?: { status?: string person_name?: string + keyword?: string start_date?: string end_date?: string }) => { diff --git a/frontend/src/views/borrow/BorrowList.vue b/frontend/src/views/borrow/BorrowList.vue index 389de4c..c89ca80 100644 --- a/frontend/src/views/borrow/BorrowList.vue +++ b/frontend/src/views/borrow/BorrowList.vue @@ -8,7 +8,7 @@
扫码出库 扫码入库 -
@@ -173,9 +173,9 @@

共 {{ formatNumber(total) }} 条记录

- + {{ currentPage }} / {{ totalPages }} - +
@@ -218,6 +218,9 @@ import { getBorrowRecordList } from '@/api/borrow' import { downloadFile, exportBorrowRecords } from '@/api/export' import type { BorrowRecord } from '@/types' import { formatDateTime, formatNumber } from '@/utils/format' +import { createRequestScope } from '@/utils/requestScope' + +type BorrowFilters = Pick[0], 'status' | 'person_name' | 'keyword'> const borrowRecords = ref([]) const loading = ref(false) @@ -231,6 +234,8 @@ const filters = reactive({ person_name: '', keyword: '' }) +const appliedFilters = ref(null) +const listRequests = createRequestScope() const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) @@ -241,30 +246,34 @@ const pageSummary = computed(() => { return { borrowed, returned, longTerm } }) -const fetchData = async () => { +const fetchData = async (page: number, requestFilters: BorrowFilters = appliedFilters.value ?? {}) => { + const request = listRequests.begin() loading.value = true try { const res = await getBorrowRecordList({ - page: currentPage.value, + page, pageSize: pageSize.value, - status: filters.status || undefined, - person_name: filters.person_name || undefined, - keyword: filters.keyword || undefined + ...requestFilters }) - if (res.data) { + if (listRequests.isCurrent(request) && res.data) { borrowRecords.value = res.data.list total.value = res.data.total + currentPage.value = page + appliedFilters.value = requestFilters } } catch (error) { console.error('获取借用记录失败:', error) } finally { - loading.value = false + if (listRequests.isCurrent(request)) loading.value = false } } const handleSearch = () => { - currentPage.value = 1 - fetchData() + fetchData(1, { + status: filters.status || undefined, + person_name: filters.person_name || undefined, + keyword: filters.keyword || undefined + }) } const handleReset = () => { @@ -275,12 +284,10 @@ const handleReset = () => { } const handleExport = async () => { + if (exporting.value || !appliedFilters.value) return exporting.value = true try { - const response = await exportBorrowRecords({ - status: filters.status || undefined, - person_name: filters.person_name || undefined - }) + const response = await exportBorrowRecords(appliedFilters.value) downloadFile(response.data as Blob, '借用记录.xlsx') } catch (error) { console.error('导出借用记录失败:', error) @@ -290,16 +297,14 @@ const handleExport = async () => { } const handlePrevPage = () => { - if (currentPage.value > 1) { - currentPage.value-- - fetchData() + if (!loading.value && currentPage.value > 1) { + fetchData(currentPage.value - 1) } } const handleNextPage = () => { - if (currentPage.value < totalPages.value) { - currentPage.value++ - fetchData() + if (!loading.value && currentPage.value < totalPages.value) { + fetchData(currentPage.value + 1) } } @@ -313,6 +318,6 @@ const getStatusBadgeClass = (status: string) => { } onMounted(() => { - fetchData() + fetchData(1) })