优化
This commit is contained in:
@@ -18,13 +18,13 @@ describe('finance api', () => {
|
||||
|
||||
it('uses the customer statement read-model path', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({
|
||||
code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2 }],
|
||||
code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2, createdBy: 7, createdByName: '创建人A', updatedBy: 8, updatedByName: '更新人B' }],
|
||||
})
|
||||
|
||||
const result = await listFinanceStatements('10001')
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/statements', { params: { customerId: '10001' } })
|
||||
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2 })
|
||||
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2, createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' })
|
||||
})
|
||||
|
||||
it('never serializes an empty customerId for optional finance list filters', async () => {
|
||||
@@ -116,7 +116,7 @@ describe('finance api', () => {
|
||||
})
|
||||
|
||||
it('uses the frozen G3 reconciliation create and action contracts', async () => {
|
||||
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', containerId: 31, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [], revisions: [] }
|
||||
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', containerId: 31, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: 701, transactionType: 'RECEIPT', transactionDate: '2026-08-02', amount: 10, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: 9, createdByName: '收款登记人', createdAt: '2026-08-02T11:00:00' }], revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 120, afterAmount: 100, reason: '优惠', operatorId: 10, operatorName: '调整人', occurredAt: '2026-08-02T12:00:00' }] }
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
@@ -127,12 +127,13 @@ describe('finance api', () => {
|
||||
expect(post.mock.calls[0]).toEqual(['/platform/finance/v1/statements', expect.objectContaining({ customerId: '10001', currency: 'CNY', generationMode: 'BY_CUTOFF' }), { headers: { 'Idempotency-Key': expect.any(String) } }])
|
||||
expect(post.mock.calls[1]).toEqual(['/platform/finance/v1/reconciliation-statements/501/actions', { action: 'SUBMIT', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.any(String) } }])
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/reconciliation-statements/501')
|
||||
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, createdBy: '7', createdAt: '2026-08-01T09:00:00', updatedBy: '8', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: '601', sourceFeeId: '42', sourceHash: 'hash-42', containerId: '31', sourceAmount: 120, amount: 100, adjustmentReason: '优惠调整' }] })
|
||||
expect(loaded.revisions).toEqual([])
|
||||
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: '601', sourceFeeId: '42', sourceHash: 'hash-42', containerId: '31', sourceAmount: 120, amount: 100, adjustmentReason: '优惠调整' }] })
|
||||
expect(loaded.paymentHistory[0]).toMatchObject({ createdBy: '9', createdByName: '收款登记人' })
|
||||
expect(loaded.revisions[0]).toMatchObject({ operatorId: '10', operatorName: '调整人' })
|
||||
})
|
||||
|
||||
it('uses the frozen G4 settlement create, detail and payment contracts', async () => {
|
||||
const detail = { summary: { settlementId: 701, settlementNo: 'AP-701', customerId: 10001, supplierId: 81, reconciliationStatementId: 501, direction: 'PAYABLE', totalAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 0 }, lines: [], transactions: [], revisions: [] }
|
||||
const detail = { summary: { settlementId: 701, settlementNo: 'AP-701', customerId: 10001, supplierId: 81, reconciliationStatementId: 501, direction: 'PAYABLE', totalAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 0 }, lines: [], transactions: [{ transactionId: 801, transactionType: 'PAYMENT', transactionDate: '2026-08-11', amount: 20, method: 'BANK_TRANSFER', referenceNo: 'PAY-801', voucherFileIds: [9001], createdBy: 11, createdByName: '付款登记人', createdAt: '2026-08-11T10:00:00' }], revisions: [{ revisionNo: 1, action: 'VOID', beforeAmount: 200, afterAmount: 200, reason: '测试', operatorId: 12, operatorName: '作废操作人', occurredAt: '2026-08-11T11:00:00' }] }
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
@@ -146,6 +147,8 @@ describe('finance api', () => {
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/settlements/701')
|
||||
expect(post.mock.calls[2]).toEqual(['/platform/finance/v1/settlements/701/transactions', expect.objectContaining({ amount: 200, voucherFileIds: ['9001'], expectedVersion: 0 }), { headers: { 'Idempotency-Key': expect.any(String) } }])
|
||||
expect(loaded).toMatchObject({ id: '701', settlementNo: 'AP-701', customerId: '10001', supplierId: '81', reconciliationStatementId: '501', direction: 'PAYABLE', amount: 200, status: 'ISSUED' })
|
||||
expect(loaded.transactions[0]).toMatchObject({ referenceNo: 'PAY-801', createdBy: '11', createdByName: '付款登记人', createdAt: '2026-08-11T10:00:00' })
|
||||
expect(loaded.revisions[0]).toMatchObject({ operatorId: '12', operatorName: '作废操作人' })
|
||||
})
|
||||
|
||||
it('keeps receipt and cost commands on CNY with real voucher ids', async () => {
|
||||
|
||||
@@ -160,9 +160,9 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
|
||||
return {
|
||||
id: stringValue(item.statementId), billNo: stringValue(item.statementNo), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
|
||||
billDate: stringValue(item.periodEnd), dueDate: '', periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: numberValue(item.creditDays), currency: 'CNY',
|
||||
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedAt: stringValue(item.updatedAt), lines,
|
||||
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), createdBy: stringValue(row.createdBy), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), occurredAt: stringValue(row.occurredAt) } }),
|
||||
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt), lines,
|
||||
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), operatorName: stringValue(row.operatorName), occurredAt: stringValue(row.occurredAt) } }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ function normalizeSettlementDetail(item: RawResource): FinanceSettlementDetail {
|
||||
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), customerName: stringValue(summary.customerName), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), supplierName: stringValue(summary.supplierName), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), businessPeriod: stringValue(summary.businessPeriod), generatedDate: stringValue(summary.generatedDate), dueDate: stringValue(summary.dueDate), paymentStatus: stringValue(summary.paymentStatus) as FinanceSettlementDetail['paymentStatus'], direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
|
||||
amount: numberValue(summary.totalAmount ?? summary.amount), paidAmount: numberValue(summary.paidAmount), outstandingAmount: numberValue(summary.outstandingAmount), currency: 'CNY', status: stringValue(summary.status), version: numberValue(summary.version),
|
||||
lines: (Array.isArray(item.lines) ? item.lines : []).map((line) => normalizeFeeSourceDetail(line as RawResource)),
|
||||
transactions: (Array.isArray(item.transactions) ? item.transactions : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, occurredAt: stringValue(row.occurredAt) } }),
|
||||
transactions: (Array.isArray(item.transactions) ? item.transactions : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), createdAt: stringValue(row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), operatorName: stringValue(row.operatorName), occurredAt: stringValue(row.occurredAt) } }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function actionFinanceSettlement(settlementId: string, payload: imp
|
||||
|
||||
export async function listFinanceStatements(customerId?: string): Promise<FinanceStatementReadModel[]> {
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/statements`, { params: { customerId: customerId?.trim() || undefined } })
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), createdBy: stringValue(item.createdBy), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedAt: stringValue(item.updatedAt) }))
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt) }))
|
||||
}
|
||||
|
||||
export async function listFinanceReceivableSettlements(customerId?: string): Promise<FinanceReceivableSettlement[]> {
|
||||
|
||||
@@ -44,17 +44,23 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(page).not.toContain('class="detail-toolbar"')
|
||||
expect(page).not.toContain('<AppDrawer')
|
||||
for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker)
|
||||
for (const code of ['FEE_INCLUDED', 'FEE_EXCLUDED', 'RECONCILIATION_CREATED', 'SETTLEMENT_ISSUED', 'RECEIPT', 'PAYMENT', 'ADJUSTMENT', 'VOID']) expect(page).toContain(code)
|
||||
for (const obsolete of ["value: 'FEE_SOURCE'", "value: 'RECONCILIATION'", "value: 'SETTLEMENT'"]) expect(page).not.toContain(obsolete)
|
||||
})
|
||||
|
||||
it('keeps term editing aligned with the account summary and nearby actions', () => {
|
||||
for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
|
||||
expect(page).toContain('grid-template-columns:repeat(4,minmax(0,1fr))')
|
||||
expect(page).toContain('grid-template-columns:120px minmax(0,220px)')
|
||||
expect(page).toContain('justify-content:start')
|
||||
expect(page).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
|
||||
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
|
||||
expect(page).toContain('justify-content: start')
|
||||
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
|
||||
expect(page).toContain('min-height:72px')
|
||||
expect(page).toContain('gap:0;padding:0 0 16px')
|
||||
expect(page).toContain('min-height: 64px')
|
||||
expect(page).toContain('padding: 0 0 12px')
|
||||
expect(page).toContain('class="detail-table-scroll"')
|
||||
expect(page).toContain('class="ledger-identifier"')
|
||||
expect(page).toContain('class="ledger-document"')
|
||||
expect(page).toContain('text-overflow: ellipsis')
|
||||
expect(page).toContain('white-space: nowrap')
|
||||
})
|
||||
|
||||
it('shows only derived CNY summaries and no wallet actions', () => {
|
||||
@@ -108,6 +114,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
})
|
||||
|
||||
it('formats known ledger codes, operator ids and timestamps for display', async () => {
|
||||
vi.mocked(listFinanceAccountLedger).mockResolvedValue({ records: [{ ledgerId: '2087867025978863617', occurredAt: '2026-08-13T10:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL', documentId: '2', documentNo: 'REC-00000000000000000001', receivableChange: 10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '结算单生效' }], total: 1, pages: 1 })
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['platform:finance:customer-account:list']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
@@ -118,8 +125,9 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
await router.push('/finance/customer-accounts/10001'); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('收款')
|
||||
expect(wrapper.text()).toContain('操作人信息缺失')
|
||||
expect(wrapper.text()).toContain('结算单生效')
|
||||
expect(wrapper.text()).toContain('REC-00000000000000000001')
|
||||
expect(wrapper.text()).toContain('名称数据缺失')
|
||||
expect(wrapper.text()).not.toContain('operator-1000001')
|
||||
expect(wrapper.text()).not.toContain('1000001')
|
||||
expect(wrapper.text()).not.toContain('2026-08-13T10:00:00')
|
||||
|
||||
@@ -66,13 +66,16 @@ const columns: DataTableColumn[] = [
|
||||
]
|
||||
const ledgerQueryFields: QueryField[] = [
|
||||
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
|
||||
{ label: '费用来源', value: 'FEE_SOURCE' }, { label: '对账单', value: 'RECONCILIATION' }, { label: '结算单', value: 'SETTLEMENT' }, { label: '收付款', value: 'PAYMENT' },
|
||||
{ label: '纳入结算', value: 'FEE_INCLUDED' }, { label: '排除结算', value: 'FEE_EXCLUDED' },
|
||||
{ label: '生成对账单', value: 'RECONCILIATION_CREATED' }, { label: '结算单生效', value: 'SETTLEMENT_ISSUED' },
|
||||
{ label: '收款', value: 'RECEIPT' }, { label: '付款', value: 'PAYMENT' },
|
||||
{ label: '账务调整', value: 'ADJUSTMENT' }, { label: '作废', value: 'VOID' },
|
||||
] },
|
||||
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
|
||||
]
|
||||
const ledgerColumns: DataTableColumn[] = [
|
||||
{ key: 'ledgerId', label: '流水号', minWidth: 160 }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 130, slotName: 'businessType' },
|
||||
{ key: 'documentNo', label: '关联单据', minWidth: 170 }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
|
||||
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
|
||||
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
|
||||
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const summary = computed(() => rows.value.reduce((result, row) => ({
|
||||
@@ -153,20 +156,23 @@ function customerName(customerId: string, snapshot?: string) {
|
||||
}
|
||||
function ledgerBusinessType(value: unknown) {
|
||||
const code = String(value || '').trim().toUpperCase()
|
||||
const known: Record<string, string> = { REC: '收款', RECEIPT: '收款', PAYMENT: '付款', COST: '成本', BILL: '对账单', RECONCILIATION: '对账单', SETTLEMENT: '结算', FEE_SOURCE: '费用来源', ADJUSTMENT: '账务调整' }
|
||||
const known: Record<string, string> = { REC: '收款', RECONCILIATION: '对账单', FEE_SOURCE: '费用来源' }
|
||||
return known[code] || financeBusinessTypeLabel(code)
|
||||
}
|
||||
function displayOperator(value: unknown) {
|
||||
const operator = String(value || '').trim()
|
||||
if (!operator) return '系统'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '操作人信息缺失' : operator
|
||||
if (!operator) return '名称数据缺失'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
|
||||
}
|
||||
function formatDateTime(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return '时间暂不可用'
|
||||
if (!raw) return '时间数据缺失'
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
return Number.isNaN(date.getTime()) ? '时间数据缺失' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function formatLastChangedAt(value: unknown) { return String(value || '').trim() ? formatDateTime(value) : '暂无变动' }
|
||||
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
|
||||
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
|
||||
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||||
function downloadCsv(fileName: string, headers: string[], data: unknown[][]) {
|
||||
const content = `\uFEFF${[headers, ...data].map((line) => line.map(csvCell).join(',')).join('\r\n')}`
|
||||
@@ -215,7 +221,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<template #toolbar-actions><el-button :disabled="loading || !rows.length" @click="exportAccounts"><Download :size="16" />导出</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
|
||||
<template #lastChangedAt="{ row }">{{ formatDateTime(row.lastChangedAt) }}</template>
|
||||
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
|
||||
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
|
||||
</DataTable>
|
||||
@@ -230,7 +236,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<template v-else-if="selected">
|
||||
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
|
||||
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
|
||||
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
||||
</FormSection>
|
||||
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></div></div></FormSection>
|
||||
</template>
|
||||
@@ -242,5 +248,264 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-account-page{min-width:0}.finance-account-detail{width:100%;min-width:0}.finance-account-detail__surface{width:min(100%,1600px);margin:0 auto;overflow:hidden}.finance-account-detail__layout{width:100%;margin:0 auto}.finance-account-detail__layout :deep(.detail-page-layout){gap:0}.finance-account-detail__layout :deep(.detail-page-layout__header){min-height:72px;align-items:center;padding:16px 24px;border-bottom:1px solid var(--cc-border,#e3e8ef)}.finance-account-detail__layout :deep(.detail-page-layout__summary){padding:16px 24px 0}.finance-account-detail__layout :deep(.detail-page-layout__content){padding:0 24px 20px}.finance-account-detail__layout :deep(.detail-page-layout__footer){padding:0 24px 24px}.row-actions,.detail-footer,.detail-heading,.term-control{display:flex;align-items:center;gap:8px}.row-actions .el-button{margin-left:0}.detail-heading{width:100%;align-items:center}.detail-title{min-width:0}.detail-heading h2{margin:0;color:var(--cc-text);font-size:20px;line-height:1.4}.detail-heading p{margin:4px 0 0;color:var(--cc-muted);font-size:13px}.detail-back{margin-left:auto}.account-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0;padding:0 0 16px;border-bottom:1px solid var(--cc-border,#e3e8ef)}.account-metric{min-width:0;padding:0 20px}.account-metric:first-child{padding-left:0}.account-metric+.account-metric{border-left:1px solid var(--cc-border,#e3e8ef)}.account-metric::before{display:none}.account-metric span{display:block;color:var(--cc-muted);font-size:12px}.account-metric strong{display:inline-block;margin-top:7px;color:var(--cc-text);font-size:22px;line-height:1;font-variant-numeric:tabular-nums}.account-metric small{margin-left:6px;color:var(--cc-muted);font-size:11px}.ledger-section{padding:16px 0 0}.ledger-query{margin-bottom:12px}:deep(.ledger-query.query-toolbar){padding:0;border:0;background:transparent}.detail-table-scroll{overflow-x:auto}.ledger-table{min-width:0}:deep(.ledger-table .data-table__toolbar.is-compact){min-height:42px;padding:0 0 10px;border-bottom:0}:deep(.ledger-table .data-table__pagination){min-height:48px;padding:8px 12px}.term-section{width:100%;padding:16px 0}.term-editor{display:grid;grid-template-columns:120px minmax(0,220px);justify-content:start;align-items:center;gap:24px;padding:4px 0}.term-label{color:var(--cc-text);font-size:14px;font-weight:600}.term-control :deep(.el-input-number){width:220px}.term-unit{color:var(--cc-muted)}.detail-footer{width:100%;justify-content:flex-end;margin-top:-4px;padding-top:14px;border-top:0}@media(max-width:1080px){.account-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.account-metric:nth-child(3){padding-left:0;border-left:0}.finance-account-detail__surface{width:100%}}@media(max-width:720px){.detail-heading{align-items:stretch;flex-direction:column}.detail-back{margin-left:0}.detail-footer .el-button{min-height:44px}.term-editor{grid-template-columns:1fr}.account-metrics{grid-template-columns:1fr;gap:12px}.account-metric,.account-metric:first-child,.account-metric:nth-child(3){padding-left:0}.account-metric+.account-metric{border-left:0}}@media(max-width:480px){.detail-footer .el-button{flex:1 1 120px}}
|
||||
.finance-account-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__surface {
|
||||
width: min(100%, 1920px);
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout__header) {
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout__summary) {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout__content) {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.finance-account-detail__layout :deep(.detail-page-layout__footer) {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.row-actions,
|
||||
.detail-footer,
|
||||
.detail-heading,
|
||||
.term-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-actions .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--cc-text);
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail-heading p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--cc-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.account-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.account-metric {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.account-metric:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.account-metric + .account-metric {
|
||||
border-left: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.account-metric::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-metric span {
|
||||
display: block;
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-metric strong {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
color: var(--cc-text);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.account-metric small {
|
||||
margin-left: 6px;
|
||||
color: var(--cc-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ledger-section {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
.ledger-query {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ledger-query.query-toolbar) {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ledger-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ledger-identifier,
|
||||
.ledger-document {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:deep(.ledger-table .data-table__toolbar.is-compact) {
|
||||
min-height: 36px;
|
||||
padding: 0 0 6px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ledger-table .data-table__pagination) {
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.term-section {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.term-editor {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 220px);
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.term-label {
|
||||
color: var(--cc-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.term-control :deep(.el-input-number) {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.term-unit {
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
padding-top: 8px;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.account-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.account-metric:nth-child(3) {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.finance-account-detail__surface {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.detail-footer .el-button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.term-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-metric,
|
||||
.account-metric:first-child,
|
||||
.account-metric:nth-child(3) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.account-metric + .account-metric {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.detail-footer .el-button {
|
||||
flex: 1 1 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string }
|
||||
submitGeneration(): Promise<void>
|
||||
}
|
||||
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }]
|
||||
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }]
|
||||
Object.assign(vm.generationForm, { mode: 'SELECTED', customerId: '10001', period: ['2026-08-01', '2026-08-31'], remark: '八月费用' })
|
||||
await vm.submitGeneration()
|
||||
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
|
||||
|
||||
@@ -133,8 +133,8 @@ function formatDateTime(value: unknown) {
|
||||
}
|
||||
function displayOperator(value: unknown) {
|
||||
const operator = String(value || '').trim()
|
||||
if (!operator) return '系统'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '操作人信息缺失' : operator
|
||||
if (!operator) return '名称数据缺失'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
|
||||
}
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const reviewStatus = String(filters.value.reviewStatus || '')
|
||||
@@ -210,10 +210,16 @@ function statusLabel(status: string) { return financeStatusLabel(status) }
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
|
||||
function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') {
|
||||
generationForm.mode = mode; generationForm.customerId = String(filters.value.customerId || ''); generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []; generationForm.cutoffDate = generationForm.period[1] || ''; generationForm.remark = ''; generationError.value = null; generationOpen.value = true
|
||||
generationForm.mode = mode
|
||||
generationForm.customerId = ''
|
||||
generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||||
generationForm.cutoffDate = generationForm.period[1] || ''
|
||||
generationForm.remark = ''
|
||||
generationError.value = null
|
||||
generationOpen.value = true
|
||||
}
|
||||
async function submitGeneration() {
|
||||
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.settlementIncluded === true && row.statementGenerated === false)
|
||||
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.settlementIncluded === true && row.statementGenerated === false && row.customerId === generationForm.customerId)
|
||||
const period = generationForm.period
|
||||
if (!generationForm.customerId || !period[0] || !period[1]) { ElMessage.warning('请选择客户和业务期间'); return }
|
||||
if (generationForm.mode === 'SELECTED' && !eligible.length) { ElMessage.warning('请选择审核通过、参与结算且未出账的应收费用'); return }
|
||||
@@ -251,7 +257,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange">
|
||||
<template #toolbar-actions><div v-if="canCreateStatement" class="generation-actions"><el-button :disabled="!filters.customerId" @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></div></template>
|
||||
<template #toolbar-actions><div v-if="canCreateStatement" class="generation-actions"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
||||
<template #documentNo="{ row }">{{ feeDocumentLabel(row.direction, row.feeName) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #sourceId="{ row }">{{ row.sourceId }}</template>
|
||||
@@ -287,41 +293,246 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-arap-page { min-width: 0; }
|
||||
.finance-arap-detail { width: 100%; min-width: 0; }
|
||||
.finance-arap-detail__surface { width: min(100%, 1600px); margin: 0 auto; overflow: hidden; }
|
||||
.finance-arap-detail__layout { width: 100%; margin: 0 auto; }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout) { gap: 0; }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__header) { min-height: 72px; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__summary) { padding: 16px 24px 0; }
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__content) { padding: 0 24px 20px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.row-actions .el-button { margin-left: 0; }
|
||||
.generation-actions,.detail-heading,.detail-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.detail-heading { min-width: 0; align-items: flex-start; }
|
||||
.detail-title { min-width: 0; }
|
||||
.detail-heading h2 { margin: 0; font-size: 18px; }
|
||||
.detail-heading p { overflow: hidden; margin: 4px 0 0; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-actions { margin-left: auto; }
|
||||
.muted-action { color: var(--cc-muted); }
|
||||
.arap-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; padding: 0 0 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.arap-metric { min-width: 0; padding: 0 20px; }
|
||||
.arap-metric:first-child { padding-left: 0; }
|
||||
.arap-metric + .arap-metric { border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.arap-metric span { display: block; color: var(--cc-muted); font-size: 12px; }
|
||||
.arap-metric strong { display: inline-block; margin-top: 7px; color: var(--cc-text); font-size: 20px; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.arap-metric small { margin-left: 6px; color: var(--cc-muted); font-size: 11px; }
|
||||
.arap-metric.is-amount strong { color: #4f46e5; }
|
||||
.arap-detail-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, .65fr); gap: 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.arap-information,.arap-settlement,.operation-log { padding: 16px 0; }
|
||||
.arap-settlement { padding-left: 24px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.operation-log { min-height: 150px; }
|
||||
:deep(.arap-detail .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
|
||||
:deep(.arap-detail .el-descriptions__label) { width: 112px; color: var(--cc-muted); }
|
||||
.operation-log :deep(.el-empty) { padding: 12px 0 4px; }
|
||||
.operation-log :deep(.el-empty__image) { display: none; }
|
||||
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
@media (max-width: 1080px) { .finance-arap-detail__surface { width: 100%; } .arap-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .arap-metric:nth-child(3) { padding-left: 0; border-left: 0; } .arap-detail-grid { grid-template-columns: 1fr; gap: 0; } .arap-settlement { padding-left: 0; border-left: 0; border-top: 1px solid var(--cc-border, #e3e8ef); } }
|
||||
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-actions { margin-left: 0; } .arap-metrics { grid-template-columns: 1fr; gap: 12px; } .arap-metric,.arap-metric:first-child,.arap-metric:nth-child(3) { padding-left: 0; } .arap-metric + .arap-metric { border-left: 0; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
|
||||
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
|
||||
.finance-arap-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-arap-detail {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finance-arap-detail__surface {
|
||||
width: min(100%, 1920px);
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__header) {
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__summary) {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.finance-arap-detail__layout :deep(.detail-page-layout__content) {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.generation-actions,
|
||||
.detail-heading,
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.detail-heading p {
|
||||
overflow: hidden;
|
||||
margin: 3px 0 0;
|
||||
color: var(--cc-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.muted-action {
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
.arap-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.arap-metric {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.arap-metric:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.arap-metric + .arap-metric {
|
||||
border-left: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.arap-metric span {
|
||||
display: block;
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.arap-metric strong {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
color: var(--cc-text);
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.arap-metric small {
|
||||
margin-left: 6px;
|
||||
color: var(--cc-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.arap-metric.is-amount strong {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.arap-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.arap-information,
|
||||
.arap-settlement,
|
||||
.operation-log {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.arap-settlement {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
|
||||
.operation-log {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
:deep(.arap-detail .el-descriptions__content) {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:deep(.arap-detail .el-descriptions__label) {
|
||||
width: 112px;
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
.operation-log :deep(.el-empty) {
|
||||
padding: 10px 0 2px;
|
||||
}
|
||||
|
||||
.operation-log :deep(.el-empty__image) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.operation-log small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.finance-arap-detail__surface {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.arap-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.arap-metric:nth-child(3) {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.arap-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.arap-settlement {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--cc-border, #e3e8ef);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.arap-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.arap-metric,
|
||||
.arap-metric:first-child,
|
||||
.arap-metric:nth-child(3) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.arap-metric + .arap-metric {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
:deep(.arap-detail .el-descriptions__body .el-descriptions__table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:deep(.arap-detail .el-descriptions__cell) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -24,23 +25,28 @@ function memoryStorage(): Storage {
|
||||
const statement: FinanceStatementReadModel = {
|
||||
id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '',
|
||||
totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
|
||||
createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
}
|
||||
const detail: FinanceReconciliationDetail = {
|
||||
...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单',
|
||||
createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', orderId: '11', inboundId: '21', containerId: '31', feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }],
|
||||
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
||||
revisions: [],
|
||||
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
||||
revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }],
|
||||
}
|
||||
|
||||
async function mountPage(permissions = ['platform:finance:customer-statement:list']) {
|
||||
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
] })
|
||||
await router.push(initialPath); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
@@ -65,10 +71,12 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the main table and linked detail/payment tables on the same page', async () => {
|
||||
const wrapper = await mountPage()
|
||||
it('opens statement details on a dedicated route instead of below the list', async () => {
|
||||
const { wrapper, router } = await mountPage()
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
|
||||
expect(router.currentRoute.value.params.statementId).toBe('501')
|
||||
expect(document.body.textContent).toContain('账单明细表')
|
||||
expect(document.body.textContent).toContain('账单支付历史表')
|
||||
expect(document.body.textContent).toContain('REC-501')
|
||||
@@ -78,9 +86,12 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(document.body.textContent).not.toContain('hash-81')
|
||||
expect(document.body.textContent).toContain('优惠调整')
|
||||
expect(document.body.textContent).toContain('创建人A')
|
||||
expect(document.body.textContent).toContain('调整人')
|
||||
expect(document.body.textContent).toContain('2026-08-20 11:00')
|
||||
expect(document.querySelector('[aria-label="客户对账单详情"]')).toBeTruthy()
|
||||
expect(document.querySelector('[aria-label="所选对账单摘要"]')?.textContent).toContain('200.00 CNY')
|
||||
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
|
||||
expect(document.querySelector('.statement-detail-head')?.textContent).toContain('REC-501')
|
||||
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
@@ -88,15 +99,14 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
|
||||
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
|
||||
await vm.openDetail(statement)
|
||||
vm.activeDetailTab = 'payments'
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
|
||||
expect(document.querySelectorAll('.statement-detail-panel .detail-page-layout')).toHaveLength(1)
|
||||
expect(document.querySelectorAll('.statement-detail-panel .surface-panel')).toHaveLength(0)
|
||||
expect(document.querySelectorAll('.statement-detail-page')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolves customer names from the active customer list and never exposes internal customer or operator ids', async () => {
|
||||
@@ -105,26 +115,26 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: internalCustomerId, tenantName: '虫虫跨境科技', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
const internalStatement = {
|
||||
...statement, customerId: internalCustomerId, customerName: `客户${internalCustomerId}`,
|
||||
createdBy: internalOperatorId, createdAt: '2026-08-14T03:27:34Z', updatedBy: internalOperatorId, updatedAt: '2026-08-14T04:28:35Z',
|
||||
createdBy: internalOperatorId, createdByName: '', createdAt: '2026-08-14T03:27:34Z', updatedBy: internalOperatorId, updatedByName: '', updatedAt: '2026-08-14T04:28:35Z',
|
||||
}
|
||||
const internalDetail = {
|
||||
...detail, ...internalStatement,
|
||||
paymentHistory: [{ ...detail.paymentHistory[0]!, createdBy: internalOperatorId, occurredAt: '2026-08-14T04:28:35Z' }],
|
||||
paymentHistory: [{ ...detail.paymentHistory[0]!, createdBy: internalOperatorId, createdByName: '', occurredAt: '2026-08-14T04:28:35Z' }],
|
||||
revisions: [{ ...detail.revisions[0]!, operatorId: internalOperatorId, operatorName: '' }],
|
||||
}
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([internalStatement])
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue(internalDetail)
|
||||
|
||||
const wrapper = await mountPage(['platform:finance:customer-statement:list', 'platform:customer:list', 'platform:finance:reconciliation:adjust'])
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:customer:list', 'platform:finance:reconciliation:adjust'])
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(internalStatement)
|
||||
await flushPromises()
|
||||
|
||||
const text = document.body.textContent || ''
|
||||
expect(text).toContain('虫虫跨境科技')
|
||||
expect(document.querySelector('[aria-label="客户对账单列表"]')?.textContent).toContain('虫虫跨境科技')
|
||||
expect(document.querySelector('[aria-label="所选对账单联动明细"]')?.textContent).toContain('虫虫跨境科技')
|
||||
expect(document.querySelector('[aria-label="客户对账单详情"]')?.textContent).toContain('虫虫跨境科技')
|
||||
expect(text).not.toContain(`客户${internalCustomerId}`)
|
||||
expect(text).not.toContain(internalOperatorId)
|
||||
expect(text).toContain('操作人信息缺失')
|
||||
expect(text).toContain('名称数据缺失')
|
||||
expect(text).toContain('2026-08-14 03:27')
|
||||
expect(text).not.toContain('2026-08-14T03:27:34Z')
|
||||
|
||||
@@ -135,7 +145,7 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
|
||||
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void>; filters: Record<string, unknown>; filteredRows: FinanceStatementReadModel[] }
|
||||
await vm.load({
|
||||
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-31',
|
||||
@@ -151,7 +161,7 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
|
||||
it('hydrates and exports the currently filtered real statement rows as CSV', async () => {
|
||||
const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const wrapper = await mountPage()
|
||||
const { wrapper } = await mountPage()
|
||||
await (wrapper.vm as unknown as { exportStatements(): Promise<void> }).exportStatements()
|
||||
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
@@ -168,7 +178,7 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
|
||||
it('uses statement list permission for fee-source details without exposing technical trace fields', async () => {
|
||||
const wrapper = await mountPage(['platform:finance:customer-statement:list'])
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
await flushPromises()
|
||||
expect(getFinanceFeeSource).toHaveBeenCalledWith('81')
|
||||
@@ -177,19 +187,20 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
})
|
||||
|
||||
it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => {
|
||||
const wrapper = await mountPage()
|
||||
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 flushPromises()
|
||||
expect(document.body.textContent).toContain('筛选字段加载失败')
|
||||
})
|
||||
|
||||
it('keeps the failed selected row visible and exposes a detail retry state', async () => {
|
||||
const wrapper = await mountPage()
|
||||
it('keeps the dedicated detail route visible and exposes a retry state', async () => {
|
||||
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
const { router } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('已选择 1 项')
|
||||
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
|
||||
expect(document.body.textContent).toContain('客户对账单详情')
|
||||
expect(document.body.textContent).toContain('返回客户对账单')
|
||||
expect(document.body.textContent).toContain('明细加载失败')
|
||||
expect(document.body.textContent).toContain('重试')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
||||
import { ArrowLeft, Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -46,6 +47,8 @@ interface StatementLineSource {
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const filters = ref<QueryModel>({
|
||||
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
|
||||
@@ -66,7 +69,12 @@ const createOpen = ref(false)
|
||||
const createBusy = ref(false)
|
||||
const exportBusy = ref(false)
|
||||
const actionBusy = ref(false)
|
||||
const createForm = ref({ periodStart: new Date().toISOString().slice(0, 8) + '01', periodEnd: new Date().toISOString().slice(0, 10), remark: '' })
|
||||
const createForm = ref({
|
||||
customerId: '',
|
||||
periodStart: new Date().toISOString().slice(0, 8) + '01',
|
||||
periodEnd: new Date().toISOString().slice(0, 10),
|
||||
remark: '',
|
||||
})
|
||||
const adjustOpen = ref(false)
|
||||
const adjustBusy = ref(false)
|
||||
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
||||
@@ -77,11 +85,14 @@ const canView = computed(() => access.can('platform:finance:customer-statement:l
|
||||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||||
const canViewFeeSource = computed(() => access.can('platform:finance:customer-statement:list'))
|
||||
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
|
||||
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
|
||||
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
|
||||
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
|
||||
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
|
||||
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
|
||||
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '全部审核状态', options: [
|
||||
{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '审核通过', value: 'APPROVED' },
|
||||
@@ -111,9 +122,9 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
|
||||
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key: 'createdBy', label: '创建人', width: 130, slotName: 'createdBy' },
|
||||
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||||
{ key: 'updatedBy', label: '更新人', width: 130, slotName: 'updatedBy' },
|
||||
{ key: 'updatedByName', label: '更新人', width: 130, slotName: 'updatedByName' },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
|
||||
]
|
||||
const detailLineColumns: DataTableColumn[] = [
|
||||
@@ -136,7 +147,7 @@ const paymentColumns: DataTableColumn[] = [
|
||||
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
|
||||
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
|
||||
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
|
||||
{ key: 'createdBy', label: '登记人', minWidth: 140, slotName: 'createdBy' },
|
||||
{ key: 'createdByName', label: '登记人', minWidth: 140, slotName: 'createdByName' },
|
||||
{ key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' },
|
||||
{ key: 'remark', label: '备注', minWidth: 200 },
|
||||
]
|
||||
@@ -157,8 +168,8 @@ function customerDisplayName(customerId: string, value?: string) {
|
||||
|
||||
function operatorDisplayName(value?: string) {
|
||||
const candidate = String(value || '').trim()
|
||||
if (!candidate) return '系统'
|
||||
return /^\d+$/.test(candidate) ? '操作人信息缺失' : candidate
|
||||
if (!candidate || /^(?:operator-)?\d+$/i.test(candidate)) return '名称数据缺失'
|
||||
return candidate
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
@@ -172,8 +183,8 @@ function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconcilia
|
||||
periodStart: current?.periodStart || '', periodEnd: current?.periodEnd || row.billDate,
|
||||
creditDays: current ? current.creditDays : null, settled: current ? current.settled : null,
|
||||
lastPaymentAt: latestPayment, remark: current?.remark || '',
|
||||
createdBy: current?.createdBy || row.createdBy, createdAt: current?.createdAt || row.createdAt,
|
||||
updatedBy: current?.updatedBy || row.updatedBy, updatedAt: current?.updatedAt || row.updatedAt,
|
||||
createdBy: current?.createdBy || row.createdBy, createdByName: current?.createdByName || row.createdByName, createdAt: current?.createdAt || row.createdAt,
|
||||
updatedBy: current?.updatedBy || row.updatedBy, updatedByName: current?.updatedByName || row.updatedByName, updatedAt: current?.updatedAt || row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,15 +284,13 @@ async function fetchDetail(statementId: string, force = false) {
|
||||
return current
|
||||
}
|
||||
|
||||
async function openDetail(row: FinanceStatementReadModel) {
|
||||
selectedId.value = row.id
|
||||
async function loadDetail(statementId: string) {
|
||||
selectedId.value = statementId
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await fetchDetail(row.id, true)
|
||||
const index = rows.value.findIndex((item) => item.id === row.id)
|
||||
if (index >= 0) rows.value[index] = detailToRow(rows.value[index]!, detail.value)
|
||||
detail.value = await fetchDetail(statementId, true)
|
||||
} catch (cause) {
|
||||
detailError.value = getApiErrorDetails(cause, '对账单明细加载失败,请重试')
|
||||
} finally {
|
||||
@@ -289,19 +298,16 @@ async function openDetail(row: FinanceStatementReadModel) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedId.value = ''
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
async function openDetail(row: FinanceStatementReadModel) {
|
||||
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: row.id } })
|
||||
}
|
||||
|
||||
function retrySelectedDetail() {
|
||||
const row = rows.value.find((item) => item.id === selectedId.value)
|
||||
if (row) void openDetail(row)
|
||||
if (selectedId.value) void loadDetail(selectedId.value)
|
||||
}
|
||||
|
||||
function selectedRowClass({ row }: { row: StatementRow }) {
|
||||
return row.id === selectedId.value ? 'is-selected-statement' : ''
|
||||
async function returnToList() {
|
||||
await router.push({ name: 'finance-customer-statements' })
|
||||
}
|
||||
|
||||
function paymentMethodLabel(method: string) {
|
||||
@@ -327,7 +333,7 @@ async function exportStatements() {
|
||||
return
|
||||
}
|
||||
const header = ['审核状态', '对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额', '已收金额', '未收金额', '最后收款时间', '是否结清', '备注', '创建人', '创建时间', '更新人', '更新时间']
|
||||
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, customerDisplayName(row.customerId, row.customerName), row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, formatDateTime(row.lastPaymentAt), row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, operatorDisplayName(row.createdBy), formatDateTime(row.createdAt), operatorDisplayName(row.updatedBy), formatDateTime(row.updatedAt)])
|
||||
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, customerDisplayName(row.customerId, row.customerName), row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, formatDateTime(row.lastPaymentAt), row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, operatorDisplayName(row.createdByName), formatDateTime(row.createdAt), operatorDisplayName(row.updatedByName), formatDateTime(row.updatedAt)])
|
||||
const blob = new Blob([`\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
@@ -341,16 +347,17 @@ async function exportStatements() {
|
||||
|
||||
function openCreate() {
|
||||
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
|
||||
createForm.value = { ...createForm.value, customerId: '' }
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
async function createStatement() {
|
||||
if (!filters.value.customerId) return ElMessage.warning('请先选择客户')
|
||||
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
|
||||
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
|
||||
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
|
||||
createBusy.value = true
|
||||
try {
|
||||
await createReconciliationStatement({ customerId: String(filters.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
|
||||
await createReconciliationStatement({ customerId: String(createForm.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
|
||||
createOpen.value = false
|
||||
ElMessage.success('对账单已生成')
|
||||
await load(filters.value)
|
||||
@@ -403,18 +410,90 @@ async function submitAdjustment() {
|
||||
finally { adjustBusy.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load() })
|
||||
onMounted(() => { if (canListCustomers.value) void loadCustomers() })
|
||||
watch(detailRouteId, (statementId) => {
|
||||
if (statementId) void loadDetail(statementId)
|
||||
else {
|
||||
selectedId.value = ''
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
void load()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-statements-page" spacing="normal">
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" />
|
||||
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page" aria-label="客户对账单详情">
|
||||
<template #header>
|
||||
<header class="statement-detail-page__header">
|
||||
<div><h2>客户对账单详情</h2><p>{{ detail?.billNo || '正在加载对账单信息' }}</p></div>
|
||||
<el-button @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button>
|
||||
</header>
|
||||
</template>
|
||||
<template v-if="detail" #summary>
|
||||
<div class="statement-detail-head">
|
||||
<div><span>客户对账单</span><h2>{{ detail.billNo }}</h2><p>{{ customerDisplayName(detail.customerId, detail.customerName) }} · {{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</p></div>
|
||||
<el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
|
||||
</div>
|
||||
<FormSection class="statement-summary-section" title="对账单概要" aria-label="对账单摘要">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="总应收金额">{{ detail.totalAmount.toFixed(2) }} CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="已收金额">{{ detail.paidAmount.toFixed(2) }} CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="未收金额"><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></el-descriptions-item>
|
||||
<el-descriptions-item label="账期">{{ detail.creditDays }} 天</el-descriptions-item>
|
||||
<el-descriptions-item label="货币">{{ detail.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否结清">{{ detail.settled ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人">{{ operatorDisplayName(detail.createdByName) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新人">{{ operatorDisplayName(detail.updatedByName) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.updatedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detail.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</FormSection>
|
||||
</template>
|
||||
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs">
|
||||
<el-tab-pane label="账单明细表" name="lines">
|
||||
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||||
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
|
||||
<template #inboundCode="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template>
|
||||
<template #containerNo="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template>
|
||||
<template #businessDate="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template>
|
||||
<template #quantity="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template>
|
||||
<template #unitPrice="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template>
|
||||
<template #billNo>{{ detail.billNo }}</template>
|
||||
<template #periodStart>{{ detail.periodStart || '—' }}</template>
|
||||
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
|
||||
</FormSection>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="账单支付历史表" name="payments">
|
||||
<FormSection v-if="detail" title="支付历史" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
|
||||
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
|
||||
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
|
||||
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
|
||||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||||
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</FormSection>
|
||||
</el-tab-pane>
|
||||
</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="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">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" :row-class-name="selectedRowClass" @row-click="openDetail">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
|
||||
<template #toolbar-actions>
|
||||
<el-button :loading="exportBusy" :disabled="loading || exportBusy || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
|
||||
@@ -436,81 +515,18 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
|
||||
<template #lastPaymentAt="{ row }">{{ formatDateTime(row.lastPaymentAt) }}</template>
|
||||
<template #settled="{ row }"><el-tag v-if="row.settled !== null" :type="row.settled ? 'success' : 'warning'">{{ row.settled ? '是' : '否' }}</el-tag><span v-else>—</span></template>
|
||||
<template #createdBy="{ row }">{{ operatorDisplayName(row.createdBy) }}</template>
|
||||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
<template #updatedBy="{ row }">{{ operatorDisplayName(row.updatedBy) }}</template>
|
||||
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
|
||||
<DetailPageLayout aria-label="客户对账单详情">
|
||||
<template #header>
|
||||
<header class="statement-detail-header">
|
||||
<div class="statement-detail-context"><span>所选对账单</span><strong>{{ selectedId ? '已选择 1 项' : '尚未选择' }}</strong></div>
|
||||
<el-button v-if="selectedId" link @click="clearSelection">清空</el-button>
|
||||
</header>
|
||||
</template>
|
||||
<template v-if="detail" #summary>
|
||||
<div class="statement-detail-head">
|
||||
<div><span>客户对账单</span><h2>{{ detail.billNo }}</h2><p>{{ customerDisplayName(detail.customerId, detail.customerName) }} · {{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</p></div>
|
||||
<el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
|
||||
</div>
|
||||
<FormSection class="statement-summary-section" title="对账单概要" aria-label="所选对账单摘要">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="总应收金额">{{ detail.totalAmount.toFixed(2) }} CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="已收金额">{{ detail.paidAmount.toFixed(2) }} CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="未收金额"><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></el-descriptions-item>
|
||||
<el-descriptions-item label="账期">{{ detail.creditDays }} 天</el-descriptions-item>
|
||||
<el-descriptions-item label="货币">{{ detail.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否结清">{{ detail.settled ? '是' : '否' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs">
|
||||
<el-tab-pane label="账单明细表" name="lines">
|
||||
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||||
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
|
||||
<template #inboundCode="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template>
|
||||
<template #containerNo="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template>
|
||||
<template #businessDate="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template>
|
||||
<template #quantity="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template>
|
||||
<template #unitPrice="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template>
|
||||
<template #billNo>{{ detail.billNo }}</template>
|
||||
<template #periodStart>{{ detail.periodStart || '—' }}</template>
|
||||
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
|
||||
</FormSection>
|
||||
<el-empty v-else description="请在主表中选择一张对账单" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="账单支付历史表" name="payments">
|
||||
<FormSection v-if="detail" title="支付历史" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
|
||||
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
|
||||
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
|
||||
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
|
||||
<template #createdBy="{ row }">{{ operatorDisplayName(row.createdBy) }}</template>
|
||||
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</FormSection>
|
||||
<el-empty v-else description="请在主表中选择一张对账单" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(520px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
|
||||
|
||||
<AppDialog v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
|
||||
<el-form label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
@@ -522,30 +538,27 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
|
||||
<style scoped>
|
||||
.finance-statements-page { min-width: 0; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.statement-detail-panel { min-width: 0; overflow: hidden; }
|
||||
.statement-detail-panel :deep(.detail-page-layout) { gap: 0; }
|
||||
.statement-detail-header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 24px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-context { display: grid; min-width: 0; gap: 3px; }
|
||||
.statement-detail-context span { color: var(--cc-text-secondary); font-size: 11px; }
|
||||
.statement-detail-context strong { overflow: hidden; color: var(--cc-text-primary); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.statement-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; padding: 16px 24px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
|
||||
.statement-detail-page :deep(.detail-page-layout) { gap: 0; }
|
||||
.statement-detail-page__header { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-page__header h2 { margin: 0; color: var(--cc-text-primary); font-size: 20px; }
|
||||
.statement-detail-page__header p { margin: 4px 0 0; color: var(--cc-text-secondary); font-size: 13px; }
|
||||
.statement-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-head > div > span { color: var(--cc-primary); font-size: 11px; font-weight: 700; }
|
||||
.statement-detail-head h2 { margin: 2px 0 0; font-size: 16px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.statement-detail-head p { margin: 5px 0 0; color: var(--cc-text-secondary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.statement-summary-section { padding: 16px 24px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-summary-section { padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-outstanding { color: var(--el-color-warning-dark-2); }
|
||||
.statement-detail-tabs { min-width: 0; }
|
||||
.statement-detail-table { min-width: 0; }
|
||||
.detail-table-scroll { min-width: 0; overflow-x: auto; }
|
||||
.statement-detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 24px; }
|
||||
.statement-detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 16px; }
|
||||
.statement-detail-tabs :deep(.el-tabs__content) { min-width: 0; }
|
||||
.statement-detail-tabs :deep(.form-section) { padding: 20px 24px 24px; }
|
||||
.statement-detail-tabs :deep(.form-section) { padding: 12px 16px 12px; }
|
||||
.statement-line-table { min-width: 1840px; }
|
||||
.statement-payment-table { min-width: 1300px; }
|
||||
.revision-collapse { margin-top: 12px; }
|
||||
:deep(.is-selected-statement > td.el-table__cell) { background: var(--el-color-primary-light-9) !important; }
|
||||
@media (max-width: 640px) {
|
||||
.statement-detail-panel { padding-inline: 12px; }
|
||||
.statement-detail-header div { align-items: flex-start; flex-direction: column; gap: 2px; padding-block: 8px; }
|
||||
.statement-detail-page__header { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,6 +29,9 @@ describe('Finance formal settlement desktop presentation', () => {
|
||||
expect(source).toContain("RECEIPT: '收款登记'")
|
||||
expect(source).toContain("PAYMENT: '付款登记'")
|
||||
expect(source).toContain('settlementRevisionActionLabel(scope.row.action)')
|
||||
expect(source).toContain('operatorDisplayName(scope.row.createdByName)')
|
||||
expect(source).toContain('operatorDisplayName(scope.row.operatorName)')
|
||||
expect(source).toContain("'名称数据缺失'")
|
||||
})
|
||||
|
||||
it('keeps receipt and payment dialogs readable at desktop width', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ArrowLeft, CreditCard, Download, Eye, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -57,7 +57,14 @@ const createBusy = ref(false)
|
||||
const statementOptions = ref<FinanceStatementReadModel[]>([])
|
||||
const feeOptions = ref<FinanceFeePoolItem[]>([])
|
||||
const supplierOptions = ref<Supplier[]>([])
|
||||
const createForm = ref({ reconciliationStatementId: '', supplierId: '', feeSourceIds: [] as string[], dueDate: '' })
|
||||
const suppliersLoaded = ref(false)
|
||||
const createForm = ref({
|
||||
customerId: '',
|
||||
reconciliationStatementId: '',
|
||||
supplierId: '',
|
||||
feeSourceIds: [] as string[],
|
||||
dueDate: '',
|
||||
})
|
||||
const paymentOpen = ref(false)
|
||||
const paymentBusy = ref(false)
|
||||
const paymentFile = ref<File | null>(null)
|
||||
@@ -137,6 +144,10 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
|
||||
function displayDate(value?: string) { return value || '—' }
|
||||
function displayDateTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function operatorDisplayName(value?: string) {
|
||||
const candidate = String(value || '').trim()
|
||||
return candidate && !/^(?:operator-)?\d+$/i.test(candidate) ? candidate : '名称数据缺失'
|
||||
}
|
||||
function settlementRevisionActionLabel(action: string) {
|
||||
return ({
|
||||
SUBMIT: '提交审核',
|
||||
@@ -213,27 +224,67 @@ async function loadDetail(settlementId: string) {
|
||||
function backToList() { emit('back') }
|
||||
|
||||
async function openCreate() {
|
||||
const customerId = String(filters.value.customerId || '')
|
||||
if (!customerId) return ElMessage.warning('请先选择客户')
|
||||
createForm.value = { reconciliationStatementId: '', supplierId: '', feeSourceIds: [], dueDate: '' }
|
||||
createForm.value = {
|
||||
customerId: '',
|
||||
reconciliationStatementId: '',
|
||||
supplierId: '',
|
||||
feeSourceIds: [],
|
||||
dueDate: '',
|
||||
}
|
||||
createOpen.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (props.kind === 'receivable') {
|
||||
statementOptions.value = (await listFinanceStatements(customerId)).filter((item) => item.status === 'ISSUED')
|
||||
} else {
|
||||
const [fees, suppliers] = await Promise.all([
|
||||
listFinanceFeePool(customerId, 'PAYABLE'),
|
||||
listSuppliers({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
])
|
||||
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
|
||||
await refreshCreateDependencies()
|
||||
if (props.kind === 'payable' && !suppliersLoaded.value) {
|
||||
const suppliers = await listSuppliers({ page: 1, size: 200, status: 'ACTIVE' })
|
||||
supplierOptions.value = suppliers.records.filter((item) => item.status === 'ACTIVE')
|
||||
suppliersLoaded.value = true
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '结算来源加载失败') }
|
||||
}
|
||||
|
||||
function resetCreateDependentFields() {
|
||||
createForm.value.reconciliationStatementId = ''
|
||||
createForm.value.supplierId = ''
|
||||
createForm.value.feeSourceIds = []
|
||||
createForm.value.dueDate = ''
|
||||
}
|
||||
|
||||
async function refreshCreateDependencies(customerId?: string) {
|
||||
const selectedCustomerId = String((customerId ?? createForm.value.customerId) || '').trim()
|
||||
statementOptions.value = []
|
||||
feeOptions.value = []
|
||||
if (!selectedCustomerId) {
|
||||
resetCreateDependentFields()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.kind === 'receivable') {
|
||||
statementOptions.value = (await listFinanceStatements(selectedCustomerId)).filter((item) => item.status === 'ISSUED')
|
||||
return
|
||||
}
|
||||
|
||||
const fees = await listFinanceFeePool(selectedCustomerId, 'PAYABLE')
|
||||
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
|
||||
if (createForm.value.supplierId && !supplierOptions.value.find((supplier) => String(supplier.id) === String(createForm.value.supplierId))) {
|
||||
createForm.value.supplierId = ''
|
||||
}
|
||||
if (createForm.value.feeSourceIds.length) {
|
||||
const available = new Set(feeOptions.value.map((item) => item.feeSourceId).filter(Boolean) as string[])
|
||||
createForm.value.feeSourceIds = createForm.value.feeSourceIds.filter((id) => available.has(id))
|
||||
if (!createForm.value.feeSourceIds.length && feeOptions.value.length) {
|
||||
createForm.value.feeSourceIds = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => createForm.value.customerId, (customerId) => {
|
||||
if (createOpen.value) void refreshCreateDependencies(customerId)
|
||||
})
|
||||
|
||||
async function submitCreate() {
|
||||
const customerId = String(filters.value.customerId || '')
|
||||
const customerId = String(createForm.value.customerId || '')
|
||||
if (!customerId) return ElMessage.warning('请先选择客户')
|
||||
if (props.kind === 'receivable' && !createForm.value.reconciliationStatementId) return ElMessage.warning('请选择已出账对账单')
|
||||
if (props.kind === 'payable' && (!createForm.value.supplierId || !createForm.value.dueDate || !createForm.value.feeSourceIds.length)) return ElMessage.warning('请完整填写供应商、到期日和费用来源')
|
||||
createBusy.value = true
|
||||
@@ -445,6 +496,9 @@ onMounted(() => {
|
||||
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
|
||||
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||||
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
|
||||
<el-table-column prop="referenceNo" label="支付流水号" min-width="160"><template #default="scope">{{ scope.row.referenceNo || '—' }}</template></el-table-column>
|
||||
<el-table-column label="登记人" min-width="140"><template #default="scope">{{ operatorDisplayName(scope.row.createdByName) }}</template></el-table-column>
|
||||
<el-table-column label="登记时间" min-width="170"><template #default="scope">{{ displayDateTime(scope.row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
@@ -457,6 +511,7 @@ onMounted(() => {
|
||||
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
|
||||
<el-table-column label="操作人" min-width="140"><template #default="scope">{{ operatorDisplayName(scope.row.operatorName) }}</template></el-table-column>
|
||||
<el-table-column prop="occurredAt" label="时间" min-width="160"><template #default="scope">{{ displayDateTime(scope.row.occurredAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
@@ -507,8 +562,9 @@ onMounted(() => {
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
|
||||
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.name} · ${categoryLabels[item.category]}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
|
||||
</el-form>
|
||||
@@ -535,43 +591,43 @@ onMounted(() => {
|
||||
.finance-settlement-page { min-width: 0; }
|
||||
.settlement-list-toolbar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 20px; padding: 0 2px 10px; }
|
||||
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
|
||||
.settlement-summary { flex: 1; gap: 8px 0; }
|
||||
.settlement-summary { flex: 1; gap: 6px 0; }
|
||||
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: 8px; }
|
||||
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.summary-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 24px; padding-left: 24px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 18px; padding-left: 18px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.summary-item span { color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||||
.summary-item strong { color: var(--cc-text-primary, #1f2937); font-size: 18px; line-height: 1.2; }
|
||||
.summary-item strong.is-success { color: #16803c; }
|
||||
.summary-item strong.is-danger { color: #c2413a; }
|
||||
.settlement-detail-page { display: flex; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
|
||||
.settlement-detail-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
|
||||
.settlement-detail-header { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 14px; padding: 14px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-heading, .settlement-detail-actions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
|
||||
.settlement-detail-heading h1 { margin: 0; font-size: 20px; line-height: 1.35; letter-spacing: 0; }
|
||||
.settlement-detail-breadcrumb { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 14px 24px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-summary .summary-item { align-items: flex-start; flex-direction: column; gap: 5px; padding: 0 20px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 10px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-summary .summary-item { align-items: flex-start; flex-direction: column; gap: 4px; padding: 0 12px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.settlement-detail-summary .summary-item:first-child { padding-left: 0; border-left: 0; }
|
||||
.summary-item strong.is-date { font-size: 20px; }
|
||||
.settlement-detail-state { margin: 20px 24px; }
|
||||
.settlement-detail-content { min-width: 0; padding: 0 24px 20px; overflow: hidden; }
|
||||
.settlement-detail-state { margin: 14px 16px; }
|
||||
.settlement-detail-content { min-width: 0; padding: 0 16px 12px; overflow: hidden; }
|
||||
.settlement-detail-info { min-width: 0; }
|
||||
.detail-table-scroll { min-width: 0; overflow-x: auto; }
|
||||
:deep(.settlement-detail-info .el-descriptions__table) { table-layout: fixed; }
|
||||
:deep(.settlement-detail-info .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
|
||||
:deep(.detail-table-scroll .el-table) { min-width: 920px; }
|
||||
.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 18px; padding: 14px 0; border: 1px solid var(--el-color-primary-light-7); border-radius: 6px; background: var(--el-color-primary-light-9); }
|
||||
.partial-summary div { min-width: 0; padding: 0 12px; border-left: 1px solid var(--el-color-primary-light-7); }
|
||||
.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 12px; padding: 10px 0; border: 1px solid var(--el-color-primary-light-7); border-radius: 6px; background: var(--el-color-primary-light-9); }
|
||||
.partial-summary div { min-width: 0; padding: 0 10px; border-left: 1px solid var(--el-color-primary-light-7); }
|
||||
.partial-summary div:first-child { border-left: 0; }
|
||||
.partial-summary span, .partial-summary strong { display: block; overflow-wrap: anywhere; }
|
||||
.partial-summary span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||
.partial-summary strong { margin-top: 4px; color: var(--cc-text-primary, #1f2937); font-size: 15px; }
|
||||
.partial-summary strong.is-success { color: #16803c; }
|
||||
.partial-summary strong.is-danger { color: #c2413a; }
|
||||
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px; }
|
||||
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 14px; row-gap: 0; }
|
||||
.settlement-operation-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.settlement-operation-form :deep(.el-input-number) { width: 100%; }
|
||||
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
|
||||
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
|
||||
.remaining-preview strong { color: #b45309; font-size: 16px; }
|
||||
.operation-error { margin-bottom: 12px; }
|
||||
.operation-error-meta { margin: -6px 0 12px; }
|
||||
@@ -580,9 +636,9 @@ onMounted(() => {
|
||||
.settlement-detail-summary, .partial-summary, .settlement-operation-form { grid-template-columns: 1fr; }
|
||||
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
|
||||
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
|
||||
.settlement-detail-content { padding: 0 16px 16px; }
|
||||
.settlement-detail-state { margin: 16px; }
|
||||
.partial-summary { gap: 10px; padding: 12px; }
|
||||
.settlement-detail-content { padding: 0 16px 14px; }
|
||||
.settlement-detail-state { margin: 14px; }
|
||||
.partial-summary { gap: 10px; padding: 12px 10px; }
|
||||
.partial-summary div { padding: 0; border-left: 0; }
|
||||
.settlement-list-toolbar { align-items: stretch; flex-direction: column; padding: 0 0 10px; }
|
||||
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -96,7 +97,12 @@ describe('finance G3/G4 page operations', () => {
|
||||
|
||||
it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => {
|
||||
const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
] })
|
||||
await router.push('/finance/customer-statements'); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
openAdjustment(row: FinanceStatementReadModel): Promise<void>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -57,7 +58,12 @@ async function mountWithAccess(component: typeof FinanceCustomerStatementsView |
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
] })
|
||||
await router.push('/finance/customer-statements'); await router.isReady()
|
||||
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ describe('FinanceManualPage delivery contract', () => {
|
||||
|
||||
it('uses business selectors instead of asking operators for internal ids', () => {
|
||||
expect(source).toContain("type: 'select', label: '客户名称'")
|
||||
expect(source).toContain('placeholder="请选择客户"')
|
||||
expect(source).toContain("placeholder: '全部客户'")
|
||||
expect(source).toContain('clearable: true')
|
||||
expect(source).toContain("const customerId = String(filters.value.customerId || '').trim() || undefined")
|
||||
expect(source).not.toContain('if (!customerId) { allRows.value = []; total.value = 0; return }')
|
||||
expect(source).toContain('placeholder="选择订单 / 入库单"')
|
||||
expect(source).toContain('placeholder="选择费用来源"')
|
||||
expect(source).toContain("listPlatformUnifiedOrderFeeDetails(form.customerId, inboundId)")
|
||||
|
||||
@@ -103,7 +103,7 @@ const drawerTitle = computed(() => {
|
||||
const rows = computed(() => serverPaged.value ? allRows.value : allRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '业务关键词', width: 220, placeholder: '单号/费用项目/供应商', props: { 'aria-label': '业务关键词' } },
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户名称', filterable: true, loading: customerOptionsLoading.value }, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })) },
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', filterable: true, clearable: true, loading: customerOptionsLoading.value }, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })) },
|
||||
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: financeBillStatusOptions }] : []),
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => {
|
||||
@@ -330,8 +330,7 @@ async function load() {
|
||||
if (!canView.value) return
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (!customerId) { allRows.value = []; total.value = 0; return }
|
||||
const customerId = String(filters.value.customerId || '').trim() || undefined
|
||||
let source: TableRow[]
|
||||
let responseTotal = 0
|
||||
if (props.mode === 'bills') {
|
||||
|
||||
@@ -32,7 +32,9 @@ describe('finance presentation governance', () => {
|
||||
it('uses one continuous desktop detail surface with balanced summary and scroll-safe tables', () => {
|
||||
expect(settlement).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel"')
|
||||
expect(settlement).toContain('class="settlement-detail-content"')
|
||||
expect(settlement).toContain('min-height: 88px; align-items: center; justify-content: space-between')
|
||||
expect(settlement).toContain('min-height: 54px;')
|
||||
expect(settlement).toContain('align-items: center;')
|
||||
expect(settlement).toContain('justify-content: space-between')
|
||||
expect(settlement).toContain('<el-descriptions :column="4" class="settlement-detail-info">')
|
||||
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain(`title="${title}"`)
|
||||
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
|
||||
@@ -43,7 +45,8 @@ describe('finance presentation governance', () => {
|
||||
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(settlement.match(/class="settlement-operation-form"/g)).toHaveLength(2)
|
||||
expect(settlement).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))')
|
||||
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px')
|
||||
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(settlement).toContain('column-gap: 14px;')
|
||||
})
|
||||
|
||||
it('keeps settlement totals and list actions in one compact toolbar', () => {
|
||||
@@ -114,29 +117,40 @@ describe('finance presentation governance', () => {
|
||||
expect(statements).toContain(':data="adjustLines" size="small" max-height="420" border stripe')
|
||||
})
|
||||
|
||||
it('keeps the statement detail as a compact, continuous list-linked surface', () => {
|
||||
expect(statements).toContain('class="surface-panel statement-detail-panel"')
|
||||
it('opens statement details on a compact dedicated route instead of below the list', () => {
|
||||
expect(statements).toContain('v-else-if="isDetailRoute" class="surface-panel statement-detail-page"')
|
||||
expect(statements).toContain('@row-click="openDetail"')
|
||||
expect(statements).toContain('.statement-detail-panel :deep(.detail-page-layout) { gap: 0; }')
|
||||
expect(statements).toContain('min-height: 48px')
|
||||
expect(statements).toContain("name: 'finance-customer-statement-detail'")
|
||||
expect(statements).toContain('.statement-detail-page :deep(.detail-page-layout) { gap: 0; }')
|
||||
expect(statements).toContain('返回客户对账单')
|
||||
expect(statements).not.toContain('aria-label="所选对账单联动明细"')
|
||||
expect(statements).not.toContain('核对账期、金额和结清状态后查看下方业务明细。')
|
||||
expect(statements).not.toContain('展示当前对账单版本中的业务与计费明细。')
|
||||
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
|
||||
})
|
||||
|
||||
it('uses a compact financial summary row for customer accounts', () => {
|
||||
expect(accounts).toContain('.finance-account-detail__layout :deep(.detail-page-layout){gap:0}')
|
||||
expect(accounts).toContain('.account-metric+.account-metric{border-left:1px solid')
|
||||
expect(accounts).toContain('.account-metric::before{display:none}')
|
||||
expect(accounts).toContain(':deep(.ledger-query.query-toolbar){padding:0;border:0;background:transparent}')
|
||||
expect(accounts).toContain('.finance-account-detail__layout :deep(.detail-page-layout)')
|
||||
expect(accounts).toContain('gap: 0;')
|
||||
expect(accounts).toContain('.account-metric + .account-metric')
|
||||
expect(accounts).toContain('border-left: 1px solid')
|
||||
expect(accounts).toContain('.account-metric::before')
|
||||
expect(accounts).toContain('display: none;')
|
||||
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)')
|
||||
expect(accounts).toContain('padding: 0;')
|
||||
expect(accounts).toContain('border: 0;')
|
||||
expect(accounts).toContain('background: transparent;')
|
||||
expect(accounts).not.toContain('展示应收、应付及每次变动后的未收金额。')
|
||||
expect(accounts).not.toContain('到期日按账单日期加客户账期计算;每个客户独立设置,默认 30 天。')
|
||||
})
|
||||
|
||||
it('keeps fee-source details continuous without nested metric and section cards', () => {
|
||||
expect(arap).toContain('.finance-arap-detail__layout :deep(.detail-page-layout) { gap: 0; }')
|
||||
expect(arap).toContain('.arap-metric + .arap-metric { border-left: 1px solid')
|
||||
expect(arap).toContain('.arap-settlement { padding-left: 24px; border-left: 1px solid')
|
||||
expect(arap).toContain('.finance-arap-detail__layout :deep(.detail-page-layout)')
|
||||
expect(arap).toContain('.arap-metric + .arap-metric')
|
||||
expect(arap).toContain('border-left: 1px solid')
|
||||
expect(arap).toContain('.arap-settlement {')
|
||||
expect(arap).toContain('padding-left: 16px;')
|
||||
expect(arap).toContain('border-left: 1px solid')
|
||||
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
|
||||
expect(arap).not.toContain('description="费用来源与关联业务摘要"')
|
||||
expect(arap).not.toContain('description="当前审核、参与结算和出账状态"')
|
||||
|
||||
@@ -6,6 +6,7 @@ export const financeRoutes = [
|
||||
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list' } },
|
||||
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', showPageHeading: false } },
|
||||
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list' } },
|
||||
{ path: 'finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单详情', description: '查看对账单概要、费用明细、支付历史与调整记录', permission: 'platform:finance:customer-statement:list', showPageHeading: false } },
|
||||
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list' } },
|
||||
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', showPageHeading: false } },
|
||||
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list' } },
|
||||
|
||||
@@ -29,12 +29,24 @@ describe('finance status presentation', () => {
|
||||
it('translates source and ledger codes without exposing unknown values', () => {
|
||||
expect(financeSourceTypeLabel('CONTAINER_COST')).toBe('整柜成本')
|
||||
expect(financeBusinessTypeLabel('RECEIPT')).toBe('收款')
|
||||
expect(financeBusinessTypeLabel('FEE_INCLUDED')).toBe('纳入结算')
|
||||
expect(financeBusinessTypeLabel('FEE_EXCLUDED')).toBe('排除结算')
|
||||
expect(financeBusinessTypeLabel('RECONCILIATION_CREATED')).toBe('生成对账单')
|
||||
expect(financeBusinessTypeLabel('SETTLEMENT_ISSUED')).toBe('结算单生效')
|
||||
expect(financeBusinessTypeLabel('PAYMENT')).toBe('付款')
|
||||
expect(financeBusinessTypeLabel('VOID')).toBe('作废')
|
||||
expect(financeBusinessTypeLabel('NEW_BUSINESS')).toBe('未知业务类型')
|
||||
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
|
||||
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
|
||||
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('费用审核通过')
|
||||
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
|
||||
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
|
||||
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
|
||||
expect(financeDirectionLabel('RECEIVABLE')).toBe('应收')
|
||||
expect(financeDirectionLabel('PAYABLE')).toBe('应付')
|
||||
expect(financeDirectionLabel('NEW_DIRECTION')).toBe('未知方向')
|
||||
expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('其他来源')
|
||||
expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('未知来源类型')
|
||||
expect(financeDocumentTypeLabel('NEW_DOCUMENT')).toBe('未知单据类型')
|
||||
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,13 +61,30 @@ const financeSourceTypeLabels: Record<string, string> = {
|
||||
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
|
||||
}
|
||||
const financeBusinessTypeLabels: Record<string, string> = {
|
||||
BILL: '对账单', RECEIPT: '收款', COST: '成本', ADJUSTMENT: '账务调整', SETTLEMENT: '结算',
|
||||
FEE_INCLUDED: '纳入结算',
|
||||
FEE_EXCLUDED: '排除结算',
|
||||
RECONCILIATION_CREATED: '生成对账单',
|
||||
SETTLEMENT_ISSUED: '结算单生效',
|
||||
RECEIPT: '收款',
|
||||
PAYMENT: '付款',
|
||||
ADJUSTMENT: '账务调整',
|
||||
VOID: '作废',
|
||||
// The following values are still returned by older Finance read models.
|
||||
BILL: '对账单',
|
||||
COST: '成本',
|
||||
SETTLEMENT: '结算',
|
||||
}
|
||||
const financeDocumentTypeLabels: Record<string, string> = {
|
||||
BILL: '对账单', RECEIPT: '收款单', COST: '成本单', SETTLEMENT: '结算单', REVISION: '调整记录',
|
||||
}
|
||||
const financeOperationActionLabels: Record<string, string> = {
|
||||
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', INCLUDE: '参与结算', EXCLUDE: '排除结算', UPDATE: '更新',
|
||||
FEE_SOURCE_REVIEW_APPROVE: '费用审核通过', FEE_SOURCE_REVIEW_REJECT: '费用审核驳回',
|
||||
FEE_INCLUDED: '纳入结算', FEE_EXCLUDED: '排除结算',
|
||||
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
|
||||
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
|
||||
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
|
||||
RECEIPT_CREATE: '登记收款', COST_CREATE: '登记成本', VOID: '作废', REOPEN: '重开',
|
||||
}
|
||||
const financeOperationResultLabels: Record<string, string> = {
|
||||
SUCCESS: '操作成功', FAILED: '操作失败', PENDING: '待处理', PROCESSING: '处理中',
|
||||
@@ -82,9 +99,9 @@ function codeLabel(value: unknown, labels: Record<string, string>, unknownLabel:
|
||||
return labels[code] || unknownLabel
|
||||
}
|
||||
|
||||
export const financeSourceTypeLabel = (value: unknown) => codeLabel(value, financeSourceTypeLabels, '其他来源')
|
||||
export const financeBusinessTypeLabel = (value: unknown) => codeLabel(value, financeBusinessTypeLabels, '其他业务')
|
||||
export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '其他单据')
|
||||
export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '其他操作')
|
||||
export const financeSourceTypeLabel = (value: unknown) => codeLabel(value, financeSourceTypeLabels, '未知来源类型')
|
||||
export const financeBusinessTypeLabel = (value: unknown) => codeLabel(value, financeBusinessTypeLabels, '未知业务类型')
|
||||
export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '未知单据类型')
|
||||
export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '未知操作')
|
||||
export const financeOperationResultLabel = (value: unknown) => codeLabel(value, financeOperationResultLabels, '结果未知')
|
||||
export const financeDirectionLabel = (value: unknown) => codeLabel(value, financeDirectionLabels, '未知方向')
|
||||
|
||||
@@ -76,8 +76,10 @@ export interface FinanceStatementReadModel {
|
||||
status: FinanceBillStatus
|
||||
version: number
|
||||
createdBy?: string
|
||||
createdByName?: string
|
||||
createdAt?: string
|
||||
updatedBy?: string
|
||||
updatedByName?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
@@ -196,8 +198,8 @@ export interface FinanceSettlementDetail {
|
||||
status: string
|
||||
version: number
|
||||
lines: FinanceFeeSourceDetail[]
|
||||
transactions: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; voucherFileIds: string[]; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; occurredAt: string }>
|
||||
transactions: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; voucherFileIds: string[]; createdBy: string; createdByName: string; createdAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; operatorName: string; occurredAt: string }>
|
||||
}
|
||||
|
||||
export interface FinanceSettlementActionPayload {
|
||||
@@ -212,11 +214,13 @@ export interface FinanceReconciliationDetail extends FinanceBillDetail {
|
||||
creditDays: number
|
||||
settled: boolean
|
||||
createdBy?: string
|
||||
createdByName?: string
|
||||
createdAt?: string
|
||||
updatedBy?: string
|
||||
updatedByName?: string
|
||||
updatedAt?: string
|
||||
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; occurredAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; occurredAt: string }>
|
||||
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; createdByName: string; occurredAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; operatorName: string; occurredAt: string }>
|
||||
}
|
||||
|
||||
export interface CreateReconciliationPayload {
|
||||
|
||||
Reference in New Issue
Block a user