Merge remote-tracking branch 'origin/release/test' into dev

# Conflicts:
#	src/modules/container-loading/pages/ContainersView.vue
This commit is contained in:
2026-09-09 15:42:42 +08:00
22 changed files with 677 additions and 123 deletions
+16
View File
@@ -465,3 +465,19 @@ export async function createFinanceCost(payload: Record<string, unknown>, vouche
const response = await http.post<ApiEnvelope<FinanceCostResource>, ApiEnvelope<FinanceCostResource>>(`${basePath}/cost-entries`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('cost') } })
return unwrapApiResponse(response)
}
export interface FinancePaymentVoucher {
fileId: string
fileName: string
contentType: string
sizeBytes: number
}
export async function getFinancePaymentVoucher(customerId: string, fileId: string): Promise<FinancePaymentVoucher> {
return unwrapApiResponse(await http.get<ApiEnvelope<FinancePaymentVoucher>, ApiEnvelope<FinancePaymentVoucher>>(
`${basePath}/vouchers/${encodeURIComponent(fileId)}`, { params: { customerId } }))
}
export function downloadFinancePaymentVoucher(customerId: string, fileId: string): Promise<Blob> {
return http.get<Blob, Blob>(`${basePath}/vouchers/${encodeURIComponent(fileId)}/content`, {
params: { customerId }, responseType: 'blob',
})
}
+2
View File
@@ -2,6 +2,8 @@ export { financeRoutes } from './routes'
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
export {
getFinanceCustomerAccountSummary,
getFinancePaymentVoucher,
downloadFinancePaymentVoucher,
getFinanceSettlement,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
@@ -232,6 +232,37 @@ describe('FinanceCustomerStatementsView', () => {
expect(vm.filteredRows).toHaveLength(0)
})
it('does not issue one detail request per statement for an ordinary query', async () => {
vi.mocked(listFinanceStatements).mockResolvedValue(Array.from({ length: 60 }, (_, index) => ({
...statement,
id: String(501 + index),
billNo: `REC-${501 + index}`,
})))
const { wrapper } = await mountPage()
vi.mocked(listFinanceStatements).mockClear()
vi.mocked(getReconciliationStatement).mockClear()
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({ status: 'ISSUED' })
await flushPromises()
expect(listFinanceStatements).toHaveBeenCalledTimes(1)
expect(getReconciliationStatement).not.toHaveBeenCalled()
})
it('uses only the statement list request for an ordinary search', async () => {
const { wrapper } = await mountPage()
vi.mocked(listFinanceStatements).mockClear()
vi.mocked(getReconciliationStatement).mockClear()
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({
status: 'ISSUED', billNo: '501', customerId: '', containerNo: '',
periodStartRange: [], periodEndRange: [], settled: '', lastPaymentRange: [], createdRange: [],
})
expect(listFinanceStatements).toHaveBeenCalledTimes(1)
expect(getReconciliationStatement).not.toHaveBeenCalled()
})
it('keeps statement status filter options aligned with the list status column', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as {
@@ -355,10 +386,10 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.body.textContent).not.toContain('hash-81')
})
it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => {
it('surfaces container-filter detail hydration failures instead of silently returning an empty filtered list', async () => {
const { wrapper } = await mountPage()
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({ status: 'ISSUED' })
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({ status: 'ISSUED', containerNo: 'CONT-31' })
await flushPromises()
expect(document.body.textContent).toContain('筛选字段加载失败')
})
@@ -246,7 +246,10 @@ async function loadCustomers() {
}
async function load(model?: QueryModel) {
const hydrateDetails = model !== undefined
// The list response already contains every normal filter field. Only the
// container-number filter still depends on statement lines from the detail API.
// Avoid loading every statement detail for an ordinary search (N+1 requests).
const hydrateDetails = Boolean(String(model?.containerNo || '').trim())
if (model) {
filters.value = { ...model }
appliedFilters.value = { ...model }
@@ -647,7 +650,7 @@ watch(detailRouteId, (statementId) => {
</el-tabs>
</DetailPageLayout>
<ListPageLayout v-else aria-label="客户对账单">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
<template v-if="!customerError && !error">
<section class="surface-panel table-panel">