fix: align Excel exports with asset records

This commit is contained in:
WuYongJie 2026-11-09 15:42:55 +08:00
parent c69fd7ce4f
commit 83fcfbca3e
9 changed files with 209 additions and 93 deletions

View File

@ -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<string, string>) => {
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
}
})

View File

@ -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)

View File

@ -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')

View File

@ -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)

View File

@ -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
}

View File

@ -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<string, AdministrativeHardwareSlot>([
@ -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(

View File

@ -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: '时效提醒',

View File

@ -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
}) => {

View File

@ -8,7 +8,7 @@
<div class="mt-3 flex flex-wrap gap-2">
<router-link to="/borrow/scan" class="btn-primary text-sm">扫码出库</router-link>
<router-link to="/return/scan" class="btn-secondary text-sm">扫码入库</router-link>
<button class="btn-secondary text-sm" :disabled="exporting" @click="handleExport">
<button class="btn-secondary text-sm" :disabled="exporting || !appliedFilters" @click="handleExport">
{{ exporting ? '导出中...' : '导出' }}
</button>
</div>
@ -173,9 +173,9 @@
<div class="pagination-bar">
<p class="text-sm text-text-secondary">共 {{ formatNumber(total) }} 条记录</p>
<div class="flex items-center gap-2">
<button @click="handlePrevPage" :disabled="currentPage === 1" class="btn-secondary text-sm">上一页</button>
<button @click="handlePrevPage" :disabled="loading || currentPage === 1" class="btn-secondary text-sm">上一页</button>
<span class="text-sm text-text-primary">{{ currentPage }} / {{ totalPages }}</span>
<button @click="handleNextPage" :disabled="currentPage >= totalPages" class="btn-secondary text-sm">下一页</button>
<button @click="handleNextPage" :disabled="loading || currentPage >= totalPages" class="btn-secondary text-sm">下一页</button>
</div>
</div>
</section>
@ -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<Parameters<typeof getBorrowRecordList>[0], 'status' | 'person_name' | 'keyword'>
const borrowRecords = ref<BorrowRecord[]>([])
const loading = ref(false)
@ -231,6 +234,8 @@ const filters = reactive({
person_name: '',
keyword: ''
})
const appliedFilters = ref<BorrowFilters | null>(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)
})
</script>