This commit is contained in:
2026-08-19 09:14:35 +08:00
parent c188482c24
commit 80cfca5056
36 changed files with 854 additions and 1044 deletions
+3 -1
View File
@@ -155,7 +155,9 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
:aria-expanded="menuExpanded"
@click="handleMenuToggle"
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
<div v-if="showPageHeading" class="page-heading">
<!-- <h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p> -->
</div>
<div class="header-user">
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" aria-label="消息中心">
@@ -105,7 +105,7 @@ describe('GlobalSmartLoadingView', () => {
it('shows one user-facing container number and hides internal manual or split references', async () => {
vi.mocked(listContainers).mockResolvedValue({
records: [{ ...container, cabinetNo: 'CAB-20260814-01', containerNo: 'SPLIT-814022117898chain1' }],
records: [{ ...container, cabinetNo: 'CAB-260814-0001', containerNo: 'SPLIT-814022117898chain1' }],
page: 1,
size: 100,
total: 1,
@@ -114,7 +114,7 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('CAB-20260814-01')
expect(wrapper.text()).toContain('CAB-260814-0001')
expect(wrapper.text()).not.toContain('SPLIT-814022117898chain1')
expect(wrapper.text()).not.toContain('LP-99')
})
@@ -19,6 +19,7 @@ const payload: SavePlatformFeeItemRequest = {
category: '仓储成本',
tradeScope: 'INTERNATIONAL',
feeTypeName: '仓库租赁费',
supplierCategory: 'TRUCKING',
pickupPayable: false,
pickupReceivable: false,
warehousePayable: true,
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { SupplierCategory } from '@/modules/suppliers'
export type FeeItemTradeScope = 'DOMESTIC' | 'INTERNATIONAL'
export type FeeItemStatus = 'ACTIVE' | 'DISABLED'
@@ -8,6 +9,7 @@ export interface PlatformFeeItemResource {
category: string
tradeScope: FeeItemTradeScope
feeCode: string | null
supplierCategory: SupplierCategory | null
feeTypeName: string
pickupPayable: boolean
pickupReceivable: boolean
@@ -26,6 +28,7 @@ export interface SavePlatformFeeItemRequest {
category: string
tradeScope: FeeItemTradeScope
feeTypeName: string
supplierCategory: SupplierCategory | null
pickupPayable: boolean
pickupReceivable: boolean
warehousePayable: boolean
@@ -16,6 +16,7 @@ import {
type PlatformFeeItemResource,
type SavePlatformFeeItemRequest,
} from '@/modules/fee-items/api/platformFeeItems'
import { categoryLabels, type SupplierCategory } from '@/modules/suppliers'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -69,6 +70,7 @@ function emptyForm(): FeeItemForm {
category: '',
tradeScope: 'DOMESTIC',
feeTypeName: '',
supplierCategory: null,
pickupPayable: false,
pickupReceivable: false,
warehousePayable: false,
@@ -84,6 +86,7 @@ const rules: FormRules<FeeItemForm> = {
tradeScope: [{ required: true, message: '请选择贸易范围', trigger: 'change' }],
feeTypeName: [{ required: true, message: '请输入费用名称', trigger: 'blur' }],
}
const hasPayableDirection = computed(() => form.pickupPayable || form.warehousePayable)
const canView = computed(() => access.can('firstleg:fee-item:view'))
const editorTitle = computed(() => form.id ? '编辑费用项目' : '新增费用项目')
@@ -117,6 +120,7 @@ const columns: DataTableColumn[] = [
{ key: 'tradeScope', label: '贸易范围', width: 110, slotName: 'tradeScope' },
{ key: 'pickup', label: '提货', minWidth: 160, slotName: 'pickup' },
{ key: 'warehouse', label: '仓库', minWidth: 160, slotName: 'warehouse' },
{ key: 'supplierCategory', label: '供应商类别', width: 120, slotName: 'supplierCategory' },
{ key: 'allocationFee', label: '分摊费', width: 100, slotName: 'allocationFee' },
{ key: 'description', label: '说明', minWidth: 220, slotName: 'description', defaultVisible: false },
{ key: 'createdByName', label: '创建人', minWidth: 120, formatter: (row: PlatformFeeItemResource) => row.createdByName || '—' },
@@ -198,6 +202,7 @@ function assignForm(item: PlatformFeeItemResource) {
category: item.category,
tradeScope: item.tradeScope,
feeTypeName: item.feeTypeName,
supplierCategory: item.supplierCategory,
pickupPayable: item.pickupPayable,
pickupReceivable: item.pickupReceivable,
warehousePayable: item.warehousePayable,
@@ -230,6 +235,7 @@ function buildPayload(): SavePlatformFeeItemRequest {
category: form.category.trim(),
tradeScope: form.tradeScope,
feeTypeName: form.feeTypeName.trim(),
supplierCategory: hasPayableDirection.value ? form.supplierCategory : null,
pickupPayable: form.pickupPayable,
pickupReceivable: form.pickupReceivable,
warehousePayable: form.warehousePayable,
@@ -242,6 +248,10 @@ function buildPayload(): SavePlatformFeeItemRequest {
async function saveFeeItem() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (hasPayableDirection.value && !form.supplierCategory) {
ElMessage.warning('启用应付方向时必须选择供应商类别')
return
}
saving.value = true
try {
if (form.id) await updatePlatformFeeItem(form.id, buildPayload())
@@ -396,6 +406,7 @@ onMounted(() => {
<template #tradeScope="{ row }"><el-tag effect="plain">{{ (row as PlatformFeeItemResource).tradeScope === 'DOMESTIC' ? '国内' : '国际' }}</el-tag></template>
<template #pickup="{ row }">{{ directionText((row as PlatformFeeItemResource).pickupPayable, (row as PlatformFeeItemResource).pickupReceivable) }}</template>
<template #warehouse="{ row }">{{ directionText((row as PlatformFeeItemResource).warehousePayable, (row as PlatformFeeItemResource).warehouseReceivable) }}</template>
<template #supplierCategory="{ row }">{{ (row as PlatformFeeItemResource).supplierCategory ? categoryLabels[(row as PlatformFeeItemResource).supplierCategory as SupplierCategory] : '未配置' }}</template>
<template #allocationFee="{ row }">{{ (row as PlatformFeeItemResource).allocationFee ? '是' : '否' }}</template>
<template #description="{ row }"><span class="description-cell">{{ (row as PlatformFeeItemResource).description || '—' }}</span></template>
<template #status="{ row }"><el-tag :type="(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as PlatformFeeItemResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
@@ -432,6 +443,11 @@ onMounted(() => {
<fieldset><legend>仓库环节</legend><el-checkbox v-model="form.warehousePayable">应付</el-checkbox><el-checkbox v-model="form.warehouseReceivable">应收</el-checkbox></fieldset>
</div>
<el-checkbox v-model="form.allocationFee">作为分摊费用</el-checkbox>
<el-form-item v-if="hasPayableDirection" label="供应商类别" required>
<el-select v-model="form.supplierCategory" placeholder="请选择 Warehouse 供应商类别">
<el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
</FormSection>
<FormSection title="适用说明" :bordered="false">
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item>
+27 -20
View File
@@ -1,12 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('queries the dedicated monthly CNY receivable statistics read model', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: {
statisticsMonth: '2026-07', currency: 'CNY', totalReceivable: '300.000000', totalReceived: '100.000000',
totalOutstanding: '200.000000', totalCustomers: 1, page: 1, pageSize: 20,
records: [{ customerId: '9007199254740993', customerName: '客户A', currency: 'CNY', totalReceivable: '300.000000', totalReceived: '100.000000', totalOutstanding: '200.000000' }],
} })
const result = await getReceivableStatistics({ month: '2026-07', customerId: '9007199254740993', page: 1, pageSize: 20 })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/receivable-statistics', { params: { month: '2026-07', customerId: '9007199254740993', page: 1, pageSize: 20 } })
expect(result).toMatchObject({ currency: 'CNY', totalReceivable: '300.000000', totalReceived: '100.000000', totalOutstanding: '200.000000', records: [{ customerId: '9007199254740993', customerName: '客户A' }] })
})
it('uses only the formal settlement customer summary and keeps money as decimal strings', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: '9007199254740993', currency: 'CNY', totalReceivable: '123456.780000', receivedAmount: '100000.000000', outstandingReceivable: '23456.780000', pendingBilling: '0.000000', outstandingPayable: '0.000000', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null } })
@@ -18,13 +31,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: [{ statementId: 1, customerId: 10001, statementNo: 'B-1', customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, lineCount: 3, sourceCount: 2, sourceAmount: 120, adjustmentAmount: -20, totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, submittedAt: '2026-08-02T09:00:00', reviewedAt: '2026-08-03T09:00:00', issuedAt: null, status: 'DRAFT', version: 2, createdBy: 7, createdByName: '创建人A', updatedBy: 8, updatedByName: '更新人B' }],
code: '200', message: 'ok', data: [{ statementId: 1, customerId: 10001, statementNo: 'B-1', customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, lineCount: 3, sourceCount: 2, sourceAmount: 120, adjustmentAmount: -20, totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, submittedAt: '2026-08-02T09:00:00', reviewedAt: '2026-08-03T09:00:00', issuedAt: null, status: 'DRAFT', version: 2, customerConfirmationStatus: 'NOT_REQUIRED', 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', periodStart: '2026-08-01', periodEnd: '2026-08-31', lineCount: 3, sourceCount: 2, sourceAmount: 120, adjustmentAmount: -20, currency: 'CNY', status: 'DRAFT', version: 2, createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' })
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', periodStart: '2026-08-01', periodEnd: '2026-08-31', lineCount: 3, sourceCount: 2, sourceAmount: 120, adjustmentAmount: -20, currency: 'CNY', status: 'DRAFT', version: 2, customerConfirmationStatus: 'NOT_REQUIRED', createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' })
})
it('never serializes an empty customerId for optional finance list filters', async () => {
@@ -64,16 +77,16 @@ describe('finance api', () => {
it('uses the formal paged fee-source path and preserves independent business fields', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({
code: '200', message: 'success', requestId: 'req-fees', traceId: 'trace-fees',
data: [{ feeSourceId: 42, sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IB-91', sourceFeeId: 81, sourceVersion: 3, direction: 'PAYABLE', customerId: 10001, customerName: '客户A', supplierId: 71, supplierName: '供应商A', orderId: 11, inboundId: 91, inboundCode: 'IB-91', containerId: 31, containerNo: 'CONT-31', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: false }],
data: [{ feeSourceId: 42, sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IB-91', sourceFeeId: 81, sourceVersion: 3, direction: 'PAYABLE', customerId: 10001, customerName: '客户A', supplierId: 71, supplierName: '供应商A', orderId: 11, inboundId: 91, inboundCode: 'IB-91', containerId: 31, containerNo: 'CONT-31', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE', statementGenerated: false }],
page: { page: 2, pageSize: 50, total: 51 },
})
const result = await listFinanceFeeSources({ page: 2, size: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: ' IB-91 ' })
const result = await listFinanceFeeSources({ page: 2, size: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: ' IB-91 ' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-sources', { params: {
page: 2, pageSize: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: 'IB-91',
page: 2, pageSize: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: 'IB-91',
} })
expect(result).toMatchObject({ total: 51, pages: 2, records: [{ feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceBusinessId: 'IB-91', supplierName: '供应商A', inboundCode: 'IB-91', containerNo: 'CONT-31', businessDate: '2026-08-11', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false }] })
expect(result).toMatchObject({ total: 51, pages: 2, records: [{ feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceBusinessId: 'IB-91', supplierName: '供应商A', inboundCode: 'IB-91', containerNo: 'CONT-31', businessDate: '2026-08-11', reviewStatus: 'APPROVED', statementGenerated: false }] })
})
it('uses fixed read-model paths for all five finance pages', async () => {
@@ -114,24 +127,24 @@ describe('finance api', () => {
expect(result.map((item) => item.feeSourceId)).toEqual(['42', null])
})
it('uses the formal G2 detail, review and settlement-inclusion contracts', async () => {
const detail = { feeSourceId: 42, sourceType: 'INBOUND_FEE', sourceFeeId: 81, sourceVersion: 3, direction: 'RECEIVABLE', customerId: 10001, customerName: '客户A', inboundId: 91, inboundCode: 'IB-91', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 4, operationLogs: [{ occurredAt: '2026-08-11T12:00:00', action: 'FEE_SOURCE_REVIEW_APPROVE', operatorName: '财务A', result: 'SUCCESS' }] }
it('uses the formal fee-source detail, review and edit contracts', async () => {
const detail = { feeSourceId: 42, sourceType: 'INBOUND_FEE', sourceFeeId: 81, sourceVersion: 3, direction: 'RECEIVABLE', customerId: 10001, customerName: '客户A', inboundId: 91, inboundCode: 'IB-91', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 4, operationLogs: [{ occurredAt: '2026-08-11T12:00:00', action: 'FEE_SOURCE_REVIEW_APPROVE', operatorName: '财务A', result: 'SUCCESS' }] }
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g2', traceId: 'trace-g2', data: detail })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'success', data: detail })
const result = await getFinanceFeeSource('42')
await reviewFinanceFeeSource('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 })
await updateFinanceFeeSourceSettlementInclusion('42', { included: true, reason: '本期结算', expectedVersion: 4 })
await editFinanceFeeSource('42', { amount: 120, reason: '更正金额', expectedVersion: 4 })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42')
expect(post).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/review', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(put).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/settlement-inclusion', { included: true, reason: '本期结算', expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4, currency: 'CNY' })
expect(put).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42', { amount: 120, reason: '更正金额', expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', operationVersion: 4, currency: 'CNY' })
})
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, 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', direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceNo: 'CONT-31', orderId: 11, inboundId: 21, inboundCode: 'IN-21', containerId: 31, containerNo: 'CONT-31', businessDate: '2026-08-15', quantity: 4, unitPrice: 30, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: 701, transactionType: 'RECEIPT', transactionDate: '2026-08-02', amount: 10, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: [9001], 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 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: 'ISSUED', version: 4, customerConfirmationStatus: 'CONFIRMED', customerConfirmedBy: 17, customerConfirmedByName: '客户确认人', customerConfirmedAt: '2026-08-05T10:00:00', 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', direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceNo: 'CONT-31', orderId: 11, inboundId: 21, inboundCode: 'IN-21', containerId: 31, containerNo: 'CONT-31', businessDate: '2026-08-15', quantity: 4, unitPrice: 30, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: 701, transactionType: 'RECEIPT', transactionDate: '2026-08-02', amount: 10, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: [9001], 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 })
@@ -144,6 +157,7 @@ describe('finance api', () => {
expect(get).toHaveBeenCalledWith('/platform/finance/v1/reconciliation-statements/501')
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({ voucherFileIds: ['9001'], createdBy: '9', createdByName: '收款登记人' })
expect(loaded).toMatchObject({ customerConfirmationStatus: 'CONFIRMED', customerConfirmedBy: '17', customerConfirmedByName: '客户确认人', customerConfirmedAt: '2026-08-05T10:00:00' })
expect(loaded.revisions[0]).toMatchObject({ operatorId: '10', operatorName: '调整人' })
})
@@ -241,11 +255,4 @@ describe('finance api', () => {
expect(post).toHaveBeenNthCalledWith(2, '/platform/finance/v1/bills/81/void', { expectedVersion: 3, reason: '重复账单' }, { headers: { 'Idempotency-Key': expect.any(String) } })
})
it('issues a bill with optimistic version and an idempotency key', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} })
await issueFinanceBill('81001', 3)
expect(post).toHaveBeenCalledWith('/platform/finance/v1/bills/81001/issue', { expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } })
})
})
+44 -22
View File
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceFeeSourceSummary, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceSettlementLine, FinanceStatementReadModel, FinanceVoucherResource, ReconciliationActionPayload } from '../types'
import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceFeeSourceSummary, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceSettlementLine, FinanceStatementReadModel, FinanceVoucherResource, ReceivableStatisticsResult, ReconciliationActionPayload } from '../types'
const basePath = '/platform/finance/v1'
export interface FinanceCustomerAccountSummaryResource {
@@ -18,7 +18,7 @@ export const financePhase1Capabilities = {
accountLedger: true,
feeSourceDetail: true,
feeSourceReview: true,
feeSourceSettlementInclusion: true,
feeSourceEditReviewLifecycle: true,
reconciliationCreate: true,
reconciliationActions: true,
receivableSettlementCreate: true,
@@ -26,6 +26,27 @@ export const financePhase1Capabilities = {
settlementDetail: true,
settlementPayment: true,
} as const
export async function getReceivableStatistics(params: { month: string; customerId?: string; page?: number; pageSize?: number }): Promise<ReceivableStatisticsResult> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/receivable-statistics`, { params: {
month: params.month,
customerId: params.customerId?.trim() || undefined,
page: params.page ?? 1,
pageSize: params.pageSize ?? 20,
} })
const item = unwrapApiResponse(response)
return {
statisticsMonth: stringValue(item.statisticsMonth), currency: 'CNY',
totalReceivable: stringValue(item.totalReceivable), totalReceived: stringValue(item.totalReceived),
totalOutstanding: stringValue(item.totalOutstanding), totalCustomers: numberValue(item.totalCustomers),
page: numberValue(item.page), pageSize: numberValue(item.pageSize),
records: (Array.isArray(item.records) ? item.records : []).map((value) => {
const row = value as RawResource
return { customerId: stringValue(row.customerId), customerName: stringValue(row.customerName), currency: 'CNY' as const,
totalReceivable: stringValue(row.totalReceivable), totalReceived: stringValue(row.totalReceived), totalOutstanding: stringValue(row.totalOutstanding) }
}),
}
}
function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
type RawResource = Record<string, unknown>
function stringValue(value: unknown) { return value == null ? '' : String(value) }
@@ -122,8 +143,7 @@ export async function listFinanceFeeSources(params: {
customerId?: string
direction?: 'RECEIVABLE' | 'PAYABLE'
status?: string
reviewStatus?: 'PENDING' | 'APPROVED' | 'REJECTED'
settlementIncluded?: boolean
reviewStatus?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'
statementGenerated?: boolean
businessDateFrom?: string
businessDateTo?: string
@@ -132,7 +152,7 @@ export async function listFinanceFeeSources(params: {
const query = {
page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined,
direction: params.direction || undefined, status: params.status || undefined,
reviewStatus: params.reviewStatus || undefined, settlementIncluded: params.settlementIncluded,
reviewStatus: params.reviewStatus || undefined,
statementGenerated: params.statementGenerated, businessDateFrom: params.businessDateFrom || undefined,
businessDateTo: params.businessDateTo || undefined, keyword: params.keyword?.trim() || undefined,
}
@@ -159,7 +179,7 @@ function normalizeFeeSourceSummary(item: RawResource): FinanceFeeSourceSummary {
orderId: nullableString(item.orderId), inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode),
containerId: nullableString(item.containerId), containerNo: stringValue(item.containerNo), businessDate: stringValue(item.businessDate),
feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice), amount: numberValue(item.amount), currency: 'CNY',
reviewStatus: stringValue(item.reviewStatus) as FinanceFeeSourceDetail['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded),
reviewStatus: stringValue(item.reviewStatus) as FinanceFeeSourceDetail['reviewStatus'],
sourceStatus: stringValue(item.sourceStatus), status: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
createdAt: stringValue(item.businessDate),
}
@@ -168,7 +188,7 @@ function normalizeFeeSourceSummary(item: RawResource): FinanceFeeSourceSummary {
function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
return {
...normalizeFeeSourceSummary(item), sourceFeeId: stringValue(item.sourceFeeId),
reviewReason: stringValue(item.reviewReason) || undefined, settlementReason: stringValue(item.settlementReason) || undefined,
reviewReason: stringValue(item.reviewReason) || undefined,
sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion),
operationLogs: (Array.isArray(item.operationLogs) ? item.operationLogs : []).map((entry) => { const row = entry as RawResource; return { occurredAt: stringValue(row.occurredAt), action: stringValue(row.action), operatorName: stringValue(row.operatorName), result: stringValue(row.result), remark: stringValue(row.remark) || undefined } }),
}
@@ -178,8 +198,20 @@ export async function reviewFinanceFeeSource(feeSourceId: string, payload: { act
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/review`, payload, mutationHeaders('fee-source-review'))
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: string, payload: { included: boolean; reason: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
const response = await http.put<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`, payload, mutationHeaders('fee-source-settlement'))
export async function editFinanceFeeSource(feeSourceId: string, payload: { amount: number; reason: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
const response = await http.put<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}`, payload, mutationHeaders('fee-source-edit'))
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function submitFinanceFeeSource(feeSourceId: string, payload: { reason: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/submit`, payload, mutationHeaders('fee-source-submit'))
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function adjustFinanceFeeSource(feeSourceId: string, payload: { amount: number; reason: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/adjust`, payload, mutationHeaders('fee-source-adjust'))
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function voidFinanceFeeSource(feeSourceId: string, payload: { reason: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/void`, payload, mutationHeaders('fee-source-void'))
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
function normalizeReconciliationDetail(item: RawResource): FinanceReconciliationDetail {
@@ -197,7 +229,7 @@ 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), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt), lines,
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), customerConfirmationStatus: stringValue(item.customerConfirmationStatus) as FinanceBillResource['customerConfirmationStatus'], customerConfirmedBy: stringValue(item.customerConfirmedBy) || undefined, customerConfirmedByName: stringValue(item.customerConfirmedByName) || undefined, customerConfirmedAt: stringValue(item.customerConfirmedAt) || undefined, 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), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], 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) } }),
}
@@ -223,7 +255,7 @@ function normalizeSettlementLine(item: RawResource): FinanceSettlementLine {
supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName), orderId: nullableString(item.orderId), orderNo: stringValue(item.orderNo),
inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode), containerId: nullableString(item.containerId), containerNo: stringValue(item.containerNo),
businessDate: stringValue(item.businessDate), feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice),
amount: numberValue(item.amount), currency: 'CNY', reviewStatus: stringValue(item.reviewStatus) as FinanceSettlementLine['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded), sourceStatus: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
amount: numberValue(item.amount), currency: 'CNY', reviewStatus: stringValue(item.reviewStatus) as FinanceSettlementLine['reviewStatus'], sourceStatus: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
}
}
@@ -262,7 +294,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.statementId ?? item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.statementNo ?? item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.periodEnd ?? item.billDate), dueDate: stringValue(item.dueDate), periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: item.creditDays == null ? undefined : numberValue(item.creditDays), lineCount: item.lineCount == null ? undefined : numberValue(item.lineCount), sourceCount: item.sourceCount == null ? undefined : numberValue(item.sourceCount), sourceAmount: item.sourceAmount == null ? undefined : numberValue(item.sourceAmount), adjustmentAmount: item.adjustmentAmount == null ? undefined : numberValue(item.adjustmentAmount), totalAmount: numberValue(item.totalReceivable ?? item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', settled: item.settled == null ? undefined : Boolean(item.settled), lastReceiptAt: stringValue(item.lastReceiptAt), submittedAt: stringValue(item.submittedAt), reviewedAt: stringValue(item.reviewedAt), issuedAt: stringValue(item.issuedAt), status: stringValue(item.status) as FinanceStatementReadModel['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) }))
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.statementId ?? item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.statementNo ?? item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.periodEnd ?? item.billDate), dueDate: stringValue(item.dueDate), periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: item.creditDays == null ? undefined : numberValue(item.creditDays), lineCount: item.lineCount == null ? undefined : numberValue(item.lineCount), sourceCount: item.sourceCount == null ? undefined : numberValue(item.sourceCount), sourceAmount: item.sourceAmount == null ? undefined : numberValue(item.sourceAmount), adjustmentAmount: item.adjustmentAmount == null ? undefined : numberValue(item.adjustmentAmount), totalAmount: numberValue(item.totalReceivable ?? item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', settled: item.settled == null ? undefined : Boolean(item.settled), lastReceiptAt: stringValue(item.lastReceiptAt), submittedAt: stringValue(item.submittedAt), reviewedAt: stringValue(item.reviewedAt), issuedAt: stringValue(item.issuedAt), status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), customerConfirmationStatus: stringValue(item.customerConfirmationStatus) as FinanceStatementReadModel['customerConfirmationStatus'], customerConfirmedBy: stringValue(item.customerConfirmedBy) || undefined, customerConfirmedByName: stringValue(item.customerConfirmedByName) || undefined, customerConfirmedAt: stringValue(item.customerConfirmedAt) || undefined, 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) }))
}
export async function listFinanceReceivableSettlements(customerId?: string): Promise<FinanceReceivableSettlement[]> {
@@ -306,11 +338,6 @@ export async function updateFinanceCustomerTerms(customerId: string, payload: {
return normalizeCustomerTerms(unwrapApiResponse(response), customerId)
}
export async function createFinanceBill(payload: Record<string, unknown>) {
const response = await http.post<ApiEnvelope<FinanceBillResource>, ApiEnvelope<FinanceBillResource>>(`${basePath}/bills`, { ...payload }, mutationHeaders('bill'))
return unwrapApiResponse(response)
}
export async function getFinanceBill(billId: string) {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/bills/${billId}`)
return normalizeBillDetail(unwrapApiResponse(response))
@@ -369,8 +396,3 @@ export async function createFinanceCost(payload: Record<string, unknown>, vouche
const response = await http.post<ApiEnvelope<FinanceCostResource>, ApiEnvelope<FinanceCostResource>>(`${basePath}/cost-entries`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('cost') } })
return unwrapApiResponse(response)
}
export async function issueFinanceBill(billId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<FinanceBillResource>, ApiEnvelope<FinanceBillResource>>(`${basePath}/bills/${billId}/issue`, { expectedVersion }, mutationHeaders('bill-issue'))
return unwrapApiResponse(response)
}
+8 -6
View File
@@ -66,22 +66,24 @@ describe('finance five-page delivery contract', () => {
for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state)
})
it('connects G2 fee sources to real detail, review and settlement operations', () => {
it('connects fee sources to the real edit and review lifecycle', () => {
const page = source('src/modules/finance/pages/FinanceCustomerArapView.vue')
for (const marker of [
'`${basePath}/fee-sources/${feeSourceId}`',
'`${basePath}/fee-sources/${feeSourceId}/review`',
'`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`',
'`${basePath}/fee-sources/${feeSourceId}/submit`',
'`${basePath}/fee-sources/${feeSourceId}/adjust`',
'`${basePath}/fee-sources/${feeSourceId}/void`',
]) expect(api).toContain(marker)
for (const operation of ['getFinanceFeeSource', 'reviewFinanceFeeSource', 'updateFinanceFeeSourceSettlementInclusion']) expect(page).toContain(operation)
for (const permission of ['platform:finance:customer-arap:list', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) expect(page).toContain(permission)
for (const operation of ['getFinanceFeeSource', 'reviewFinanceFeeSource', 'editFinanceFeeSource', 'submitFinanceFeeSource', 'adjustFinanceFeeSource', 'voidFinanceFeeSource']) expect(page).toContain(operation)
for (const permission of ['platform:finance:customer-arap:list', 'platform:finance:fee-source:review', 'platform:finance:fee-source:edit']) expect(page).toContain(permission)
expect(page).toContain('expectedVersion: detail.value.operationVersion')
const columns = page.slice(page.indexOf('const columns:'))
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'direction'"))
expect(page).toContain('<ApiErrorMeta :details="detailError"')
expect(page).toContain('<OperationErrorToast :details="reviewError || inclusionError || generationError" />')
expect(page).toContain('<OperationErrorToast :details="reviewError || mutationError || generationError" />')
expect(page).not.toContain('<ApiErrorMeta :details="reviewError"')
expect(page).not.toContain('<ApiErrorMeta :details="inclusionError"')
expect(page).not.toContain('<ApiErrorMeta :details="mutationError"')
expect(page).not.toContain('接口尚未提供')
expect(page).not.toContain('disabled title=')
})
@@ -11,7 +11,7 @@ import { createPlatformExportTask } from '@/modules/downloads'
import { ApiResponseError } from '@/shared/api/http'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import type { FinanceFeeSourceDetail, FinanceFeeSourceSummary } from '../types'
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
@@ -22,7 +22,7 @@ vi.mock('../api/finance', () => ({
listFinanceFeeSources: vi.fn(),
getFinanceFeeSource: vi.fn(),
reviewFinanceFeeSource: vi.fn(),
updateFinanceFeeSourceSettlementInclusion: vi.fn(),
editFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(),
createReconciliationStatement: vi.fn(),
}))
@@ -44,14 +44,14 @@ const detail: FinanceFeeSourceDetail = {
poolItemId: '42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceFeeId: '81', sourceVersion: 3,
sourceSystem: 'FIRSTLEG', sourceBusinessId: 'IB-91',
customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '',
businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING',
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [],
}
const feeSummary: FinanceFeeSourceSummary = {
poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IB-91',
sourceId: '81', sourceVersion: 3, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null,
supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '', businessDate: '2026-08-11',
feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING',
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-11',
}
@@ -92,7 +92,10 @@ describe('FinanceCustomerArapView G2 operations', () => {
vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [feeSummary], total: 1, pages: 1 })
vi.mocked(getFinanceFeeSource).mockReset().mockResolvedValue(detail)
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 })
vi.mocked(editFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'DRAFT', operationVersion: 4 })
vi.mocked(submitFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'PENDING', operationVersion: 4 })
vi.mocked(adjustFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'DRAFT', operationVersion: 4 })
vi.mocked(voidFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, sourceStatus: 'VOIDED', status: 'VOIDED', operationVersion: 4 })
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({} as never)
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-3', taskNo: 'EXP-3', taskName: '客户应收应付费用来源', status: 'PENDING' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
@@ -109,7 +112,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' })
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
expect(reviewFinanceFeeSource).not.toHaveBeenCalled()
expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled()
expect(editFinanceFeeSource).not.toHaveBeenCalled()
})
it('loads all customers by default and keeps explicit customer filtering', async () => {
@@ -136,17 +139,14 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.text()).toContain('客户B')
})
it('loads detail and submits review and settlement changes with the returned version', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement'])
it('loads detail and submits review with the returned version', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:review'])
await loadRows(wrapper)
const vm = wrapper.vm as unknown as {
openDetail(row: Record<string, unknown>): Promise<void>
openReview(row: Record<string, unknown>): Promise<void>
submitReview(): Promise<void>
openInclusion(row: Record<string, unknown>): Promise<void>
submitInclusion(): Promise<void>
reviewForm: { action: 'APPROVE' | 'REJECT'; reason: string }
inclusionForm: { included: boolean; reason: string }
}
const row = { id: 'RECEIVABLE:42', feeSourceId: '42' }
@@ -158,9 +158,6 @@ describe('FinanceCustomerArapView G2 operations', () => {
await vm.openReview(row); vm.reviewForm.action = 'APPROVE'; vm.reviewForm.reason = '核对无误'; await vm.submitReview()
expect(reviewFinanceFeeSource).toHaveBeenCalledWith('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 })
await vm.openInclusion(row); vm.inclusionForm.included = true; vm.inclusionForm.reason = '本期结算'; await vm.submitInclusion()
expect(updateFinanceFeeSourceSettlementInclusion).toHaveBeenCalledWith('42', { included: true, reason: '本期结算', expectedVersion: 3 })
})
it('renders request and trace ids when detail loading fails', async () => {
@@ -211,8 +208,8 @@ describe('FinanceCustomerArapView G2 operations', () => {
it('renders all permitted fee-source row operations as small plain primary buttons with icons', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
expect(page).toContain('<el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>')
expect(page).toContain('<el-button v-if="canReviewSource" size="small" plain type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button>')
expect(page).toContain('<el-button v-if="canSettleSource" size="small" plain type="primary" @click="openInclusion(row)"><Link2 :size="15" />参与结算</el-button>')
expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"")
expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"")
expect(page).not.toMatch(/<template #actions[\s\S]*?<el-button[^>]*\blink\b/)
expect(page).toContain('white-space: nowrap')
})
@@ -263,7 +260,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', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }]
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', status: 'AVAILABLE', 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: '八月费用' })
@@ -273,14 +270,14 @@ describe('FinanceCustomerArapView G2 operations', () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:customer-arap:export', 'platform:download:list'])
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportFeeSources(): Promise<void> }
Object.assign(vm.filters, {
customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: 'true',
customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED',
statementGenerated: 'false', businessRange: ['2026-08-01', '2026-08-31'], keyword: ' 运费 ',
})
await vm.exportFeeSources()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源',
parameters: {
customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true,
customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED',
statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: '运费',
},
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Download, Eye, FileCheck2, FilePlus2, Link2 } from '@lucide/vue'
import { ArrowLeft, Ban, Download, Eye, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -18,7 +18,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
@@ -44,7 +44,6 @@ interface ArapRow {
occurredAt: string
businessDate: string
reviewStatus: string
settlementIncluded: boolean | null
statementGenerated: boolean | null
}
@@ -54,7 +53,7 @@ const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', settlementIncluded: '', statementGenerated: '', businessRange: [], keyword: '' })
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
const allRows = ref<ArapRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -67,10 +66,12 @@ const reviewOpen = ref(false)
const reviewSaving = ref(false)
const reviewError = ref<ApiErrorDetails | null>(null)
const reviewForm = reactive<{ action: 'APPROVE' | 'REJECT'; reason: string }>({ action: 'APPROVE', reason: '' })
const inclusionOpen = ref(false)
const inclusionSaving = ref(false)
const inclusionError = ref<ApiErrorDetails | null>(null)
const inclusionForm = reactive({ included: true, reason: '' })
const mutationOpen = ref(false)
const mutationSaving = ref(false)
const mutationError = ref<ApiErrorDetails | null>(null)
const mutationMode = ref<'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID'>('EDIT')
const mutationForm = reactive({ amount: 0, reason: '' })
const mutationTitle = computed(() => ({ EDIT: '编辑费用', ADJUST: '调整费用', SUBMIT: '提交审核', VOID: '作废费用' } as const)[mutationMode.value])
const generationOpen = ref(false)
const generationSaving = ref(false)
const generationError = ref<ApiErrorDetails | null>(null)
@@ -85,7 +86,7 @@ const canView = computed(() => access.can('platform:finance:customer-arap:list')
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
const canViewSource = computed(() => access.can('platform:finance:customer-arap:list'))
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
const canEditSource = computed(() => access.can('platform:finance:fee-source:edit'))
const canCreateStatement = computed(() => access.can('platform:finance:reconciliation:create'))
const canExport = computed(() => access.can('platform:finance:customer-arap:export') && access.can('platform:download:list'))
const stateError = computed(() => customersError.value || loadError.value)
@@ -95,7 +96,6 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '全部状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions },
{ key: 'settlementIncluded', type: 'select', label: '参与结算', width: 140, placeholder: '全部', options: [{ label: '参与', value: 'true' }, { label: '不参与', value: 'false' }] },
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '全部', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
@@ -105,7 +105,7 @@ const columns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'settlementIncluded', label: '参与结算', width: 110, slotName: 'included' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
]
function customerName(customerId: string, snapshot?: string) {
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
@@ -185,12 +185,11 @@ async function load(model: QueryModel = filters.value) {
const result = await listFinanceFeeSources({
page: page.value, size: pageSize.value, customerId: customerId || undefined, direction: direction || undefined,
status: String(filters.value.status || '') || undefined,
reviewStatus: (String(filters.value.reviewStatus || '') || undefined) as 'PENDING' | 'APPROVED' | 'REJECTED' | undefined,
settlementIncluded: String(filters.value.settlementIncluded || '') ? String(filters.value.settlementIncluded) === 'true' : undefined,
reviewStatus: (String(filters.value.reviewStatus || '') || undefined) as 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | undefined,
statementGenerated: String(filters.value.statementGenerated || '') ? String(filters.value.statementGenerated) === 'true' : undefined,
businessDateFrom: range[0], businessDateTo: range[1], keyword: String(filters.value.keyword || '').trim() || undefined,
})
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, settlementIncluded: item.settlementIncluded, statementGenerated: item.statementGenerated }))
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, statementGenerated: item.statementGenerated }))
total.value = result.total
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
@@ -224,21 +223,26 @@ async function submitReview() {
} catch (error) { reviewError.value = getApiErrorDetails(error, '费用来源审核失败,请重试') }
finally { reviewSaving.value = false }
}
async function openInclusion(row: Record<string, unknown>) {
async function openMutation(row: Record<string, unknown>, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
const target = row as unknown as ArapRow; selected.value = target
const loaded = await loadDetail(target)
if (!loaded) { detailOpen.value = true; return }
Object.assign(inclusionForm, { included: !loaded.settlementIncluded, reason: '' }); inclusionError.value = null; inclusionOpen.value = true
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; mutationOpen.value = true
}
async function submitInclusion() {
async function submitMutation() {
if (!detail.value) return
if (!inclusionForm.reason.trim()) { ElMessage.warning('请填写调整原因'); return }
inclusionSaving.value = true; inclusionError.value = null
if (!mutationForm.reason.trim()) { ElMessage.warning('请填写操作原因'); return }
if ((mutationMode.value === 'EDIT' || mutationMode.value === 'ADJUST') && mutationForm.amount <= 0) { ElMessage.warning('金额必须大于 0'); return }
mutationSaving.value = true; mutationError.value = null
try {
detail.value = await updateFinanceFeeSourceSettlementInclusion(detail.value.feeSourceId, { included: inclusionForm.included, reason: inclusionForm.reason.trim(), expectedVersion: detail.value.operationVersion })
inclusionOpen.value = false; ElMessage.success(inclusionForm.included ? '已纳入结算' : '已排除结算'); await load()
} catch (error) { inclusionError.value = getApiErrorDetails(error, '参与结算状态更新失败,请重试') }
finally { inclusionSaving.value = false }
const action = { reason: mutationForm.reason.trim(), expectedVersion: detail.value.operationVersion }
if (mutationMode.value === 'EDIT') detail.value = await editFinanceFeeSource(detail.value.feeSourceId, { ...action, amount: mutationForm.amount })
else if (mutationMode.value === 'ADJUST') detail.value = await adjustFinanceFeeSource(detail.value.feeSourceId, { ...action, amount: mutationForm.amount })
else if (mutationMode.value === 'SUBMIT') detail.value = await submitFinanceFeeSource(detail.value.feeSourceId, action)
else detail.value = await voidFinanceFeeSource(detail.value.feeSourceId, action)
mutationOpen.value = false; ElMessage.success(({ EDIT: '费用已保存为草稿', ADJUST: '费用调整已保存,待重新提交审核', SUBMIT: '费用已提交审核', VOID: '费用已作废' } as const)[mutationMode.value]); await load()
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用操作失败,请重试') }
finally { mutationSaving.value = false }
}
function statusLabel(status: string) { return financeStatusLabel(status) }
function money(value: unknown) { return Number(value || 0).toFixed(2) }
@@ -253,10 +257,10 @@ function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') {
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 && row.customerId === generationForm.customerId)
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && row.amount > 0 && 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 }
if (generationForm.mode === 'SELECTED' && !eligible.length) { ElMessage.warning('请选择审核通过、有效且未出账的应收费用'); return }
generationSaving.value = true; generationError.value = null
try { await createReconciliationStatement({ customerId: generationForm.customerId, currency: 'CNY', generationMode: generationForm.mode, feeSourceIds: generationForm.mode === 'SELECTED' ? eligible.map((row) => row.feeSourceId as string) : undefined, periodStart: period[0], periodEnd: period[1], remark: generationForm.remark.trim() }); generationOpen.value = false; selectedRows.value = []; ElMessage.success('对账单草稿已生成'); await load() } catch (error) { generationError.value = getApiErrorDetails(error, '对账单生成失败,请重试') } finally { generationSaving.value = false }
}
@@ -265,7 +269,6 @@ async function exportFeeSources() {
const customerId = String(filters.value.customerId || '').trim()
const direction = String(filters.value.direction || '').trim()
const reviewStatus = String(filters.value.reviewStatus || '').trim()
const included = String(filters.value.settlementIncluded || '')
const generated = String(filters.value.statementGenerated || '')
const keyword = String(filters.value.keyword || '').trim()
exportBusy.value = true
@@ -276,7 +279,6 @@ async function exportFeeSources() {
...(customerId ? { customerId } : {}),
...(direction ? { direction } : {}),
...(reviewStatus ? { reviewStatus } : {}),
...(included ? { settlementIncluded: included === 'true' } : {}),
...(generated ? { statementGenerated: generated === 'true' } : {}),
...(range[0] ? { businessDateFrom: range[0] } : {}),
...(range[1] ? { businessDateTo: range[1] } : {}),
@@ -293,7 +295,7 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
try {
detail.value = await getFinanceFeeSource(feeSourceId)
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, statementGenerated: detail.value.statementGenerated }
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
finally { detailLoading.value = false }
}
@@ -313,7 +315,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template>
<AdminPage as="div" class="finance-arap-page" spacing="normal">
<OperationErrorToast :details="reviewError || inclusionError || generationError" />
<OperationErrorToast :details="reviewError || mutationError || generationError" />
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
@@ -321,11 +323,10 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<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="total" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><template v-if="canCreateStatement"><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></template></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" size="small" plain type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" size="small" plain type="primary" @click="openInclusion(row)"><Link2 :size="15" />参与结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :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 #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
<template #included="{ row }">{{ row.settlementIncluded == null ? '待加载' : row.settlementIncluded ? '参与' : '不参与' }}</template>
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
</DataTable></section>
@@ -333,18 +334,18 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
<section class="surface-panel finance-arap-detail__surface">
<DetailPageLayout class="finance-arap-detail__layout" aria-label="费用来源详情">
<template #header><div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div class="detail-title"><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div></template>
<template v-if="detail" #summary><section class="arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><span>费用金额</span><strong>{{ money(detail.amount) }}</strong><small>CNY</small></div><div class="arap-metric"><span>收付方向</span><strong>{{ financeDirectionLabel(detail.direction) }}</strong></div><div class="arap-metric"><span>参与结算</span><strong>{{ detail.settlementIncluded ? '是' : '否' }}</strong></div><div class="arap-metric"><span>审核状态</span><strong>{{ statusLabel(detail.reviewStatus) }}</strong></div></section></template>
<template #header><div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div class="detail-title"><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><section class="arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><span>费用金额</span><strong>{{ money(detail.amount) }}</strong><small>CNY</small></div><div class="arap-metric"><span>收付方向</span><strong>{{ financeDirectionLabel(detail.direction) }}</strong></div><div class="arap-metric"><span>来源状态</span><strong>{{ sourceStatusDisplay(detail.sourceStatus) }}</strong></div><div class="arap-metric"><span>审核状态</span><strong>{{ statusLabel(detail.reviewStatus) }}</strong></div></section></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item><el-descriptions-item label="结算调整原因">{{ detail.settlementReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template>
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="inclusionOpen" title="参与结算设置" confirm-text="保存设置" :confirm-loading="inclusionSaving" @confirm="submitInclusion">
<el-form label-position="top"><el-form-item label="结算状态"><el-switch v-model="inclusionForm.included" active-text="参与结算" inactive-text="排除结算" /></el-form-item><el-form-item label="调整原因"><el-input v-model="inclusionForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请说明参与或排除结算的原因" show-word-limit /></el-form-item></el-form>
<AppDialog v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
<el-form label-position="top"><el-form-item v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'" label="金额(CNY)"><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" style="width:100%" /></el-form-item><el-form-item label="操作原因"><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="generationOpen" :title="generationForm.mode === 'BY_CUTOFF' ? '按截止日期生成对账单' : '按勾选生成对账单'" confirm-text="生成草稿" :confirm-loading="generationSaving" @confirm="submitGeneration">
<el-form label-position="top"><el-form-item label="客户"><el-select v-model="generationForm.customerId" filterable placeholder="请选择客户" style="width:100%"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item><el-form-item label="业务期间"><el-date-picker v-model="generationForm.period" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="截止日期" style="width:100%" /></el-form-item><el-form-item v-if="generationForm.mode === 'SELECTED'" label="已选费用"><span>{{ selectedRows.length }} 笔,合计 {{ money(selectedAmount) }} CNY</span></el-form-item><el-form-item label="备注"><el-input v-model="generationForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
@@ -34,6 +34,7 @@ const statement: FinanceStatementReadModel = {
sourceAmount: 320, adjustmentAmount: -20, settled: false, lastReceiptAt: '2026-08-20 10:00:00',
submittedAt: '2026-08-02 09:00:00', reviewedAt: '2026-08-03 09:00:00', issuedAt: '2026-08-04 09:00:00', remark: '八月账单',
totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
customerConfirmationStatus: 'CONFIRMED', customerConfirmedBy: '17', customerConfirmedByName: '客户确认人', customerConfirmedAt: '2026-08-05 10: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 = {
@@ -97,7 +98,9 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(17)
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20)
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00')
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()
@@ -48,6 +48,8 @@ const filters = ref<QueryModel>({
settled: '', lastPaymentRange: [], createdRange: [],
})
const rows = ref<StatementRow[]>([])
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const customerError = ref<ApiErrorDetails | null>(null)
@@ -103,6 +105,7 @@ const fields = computed<QueryField[]>(() => [
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '审核状态', width: 120, slotName: 'status' },
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus' },
{ key: 'billNo', label: '对账单号', minWidth: 180 },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'periodStart', label: '结算起始日', width: 130 },
@@ -124,6 +127,8 @@ const columns: DataTableColumn[] = [
{ key: 'submittedAt', label: '提交时间', minWidth: 170, slotName: 'submittedAt' },
{ key: 'reviewedAt', label: '审核时间', minWidth: 170, slotName: 'reviewedAt' },
{ key: 'issuedAt', label: '出账时间', minWidth: 170, slotName: 'issuedAt' },
{ key: 'customerConfirmedByName', label: '客户确认人', width: 140, slotName: 'customerConfirmedByName' },
{ key: 'customerConfirmedAt', label: '客户确认时间', minWidth: 170, slotName: 'customerConfirmedAt' },
{ key: 'updatedByName', label: '更新人', width: 130, slotName: 'updatedByName' },
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
]
@@ -215,6 +220,10 @@ const filteredRows = computed(() => rows.value.filter((row) => {
&& inDateRange(row.lastPaymentAt, 'lastPaymentRange')
&& inDateRange(row.createdAt || '', 'createdRange')
}))
const paginatedRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
async function loadCustomers() {
if (!canListCustomers.value) return
@@ -230,7 +239,10 @@ async function loadCustomers() {
async function load(model?: QueryModel) {
const hydrateDetails = model !== undefined
if (model) filters.value = { ...model }
if (model) {
filters.value = { ...model }
page.value = 1
}
rows.value = []
selectedId.value = ''
detail.value = null
@@ -462,6 +474,9 @@ watch(detailRouteId, (statementId) => {
<div class="statement-summary-item"><span>提交时间</span><strong>{{ formatDateTime(revisionTime(['SUBMIT'])) }}</strong></div>
<div class="statement-summary-item"><span>审核时间</span><strong>{{ formatDateTime(revisionTime(['APPROVE', 'REJECT'])) }}</strong></div>
<div class="statement-summary-item"><span>出账时间</span><strong>{{ formatDateTime(revisionTime(['ISSUE'])) }}</strong></div>
<div class="statement-summary-item"><span>客户确认状态</span><strong>{{ financeStatusLabel(detail.customerConfirmationStatus || 'NOT_REQUIRED') }}</strong></div>
<div class="statement-summary-item"><span>客户确认人</span><strong>{{ operatorDisplayName(detail.customerConfirmedByName) }}</strong></div>
<div class="statement-summary-item"><span>客户确认时间</span><strong>{{ formatDateTime(detail.customerConfirmedAt) }}</strong></div>
<div class="statement-summary-item"><span>更新人</span><strong>{{ operatorDisplayName(detail.updatedByName) }}</strong></div>
<div class="statement-summary-item"><span>更新时间</span><strong>{{ formatDateTime(detail.updatedAt) }}</strong></div>
<div class="statement-summary-item statement-summary-item--remark"><span>备注</span><strong>{{ detail.remark || '—' }}</strong></div>
@@ -512,7 +527,7 @@ watch(detailRouteId, (statementId) => {
<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-click="openDetail">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-actions>
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @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>
@@ -533,6 +548,7 @@ watch(detailRouteId, (statementId) => {
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" size="small" plain type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template>
<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>
@@ -541,6 +557,8 @@ watch(detailRouteId, (statementId) => {
<template #submittedAt="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
<template #issuedAt="{ row }">{{ formatDateTime(row.issuedAt) }}</template>
<template #customerConfirmedByName="{ row }">{{ row.customerConfirmationStatus === 'CONFIRMED' ? operatorDisplayName(row.customerConfirmedByName) : '—' }}</template>
<template #customerConfirmedAt="{ row }">{{ formatDateTime(row.customerConfirmedAt) }}</template>
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
@@ -42,7 +42,7 @@ describe('formal settlement field contract', () => {
outstandingAmount: 200, currency: 'CNY', generatedDate: '2026-08-17', dueDate: '2026-09-16',
paymentStatus: 'PARTIALLY_PAID', status: 'PARTIALLY_PAID', createdBy: 7, createdByName: '财务员',
createdAt: '2026-08-17T09:00:00', updatedAt: '2026-08-17T10:00:00', lastPaymentAt: '2026-08-17T10:00:00', version: 1,
lines: [{ feeSourceId: 21, sourceType: 'CONTAINER_FEE', sourceFeeId: 9001, sourceVersion: 1, direction: 'PAYABLE', customerId: 10001, supplierId: 81, supplierName: '承运供应商', orderId: 11, orderNo: 'FL-11', inboundId: 12, inboundCode: 'IN-12', containerId: 13, containerNo: 'CONT-13', businessDate: '2026-08-15', feeItemName: '拖车费', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: true }],
lines: [{ feeSourceId: 21, sourceType: 'CONTAINER_FEE', sourceFeeId: 9001, sourceVersion: 1, direction: 'PAYABLE', customerId: 10001, supplierId: 81, supplierName: '承运供应商', orderId: 11, orderNo: 'FL-11', inboundId: 12, inboundCode: 'IN-12', containerId: 13, containerNo: 'CONT-13', businessDate: '2026-08-15', feeItemName: '拖车费', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE', statementGenerated: true }],
transactions: [{ transactionId: 31, transactionType: 'PAYMENT', transactionDate: '2026-08-17', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'BANK-31', voucherFileIds: [41], createdBy: 7, createdByName: '财务员', createdAt: '2026-08-17T10:00:00' }],
revisions: [{ revisionNo: 1, action: 'CREATE', beforeAmount: 0, afterAmount: 300, operatorId: 7, operatorName: '财务员', occurredAt: '2026-08-17T09:00:00' }],
} } as never)
@@ -3,14 +3,27 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
describe('Finance formal settlement desktop presentation', () => {
it('keeps settlement totals and list actions in one compact toolbar', () => {
expect(source).toContain('<section class="settlement-list-toolbar" aria-label="结算汇总与列表操作">')
it('keeps settlement totals on the left and list actions on the right of the table toolbar', () => {
expect(source).toContain('<template #toolbar-start>')
expect(source).toContain('<div class="settlement-summary" aria-label="结算汇总">')
expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('<div class="settlement-list-actions">')
expect(source).toContain('.settlement-list-toolbar { display: flex; min-height: 54px;')
expect(source).not.toContain('<template #toolbar-actions>')
expect(source).not.toContain('class="settlement-list-toolbar"')
})
it('paginates customer statements and both settlement lists after filtering', () => {
for (const pageSource of [statementsSource, source]) {
expect(pageSource).toContain('const page = ref(1)')
expect(pageSource).toContain('const pageSize = ref(20)')
expect(pageSource).toContain('const paginatedRows = computed(() => {')
expect(pageSource).toContain('filteredRows.value.slice(start, start + pageSize.value)')
expect(pageSource).toContain('v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows"')
expect(pageSource).toContain(':total="filteredRows.length"')
expect(pageSource).toContain('page.value = 1')
}
})
it('renders up to three row actions as compact icon buttons without wrapping', () => {
@@ -50,6 +50,8 @@ const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
const rows = ref<SettlementRow[]>([])
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const customerError = ref<ApiErrorDetails | null>(null)
@@ -118,6 +120,10 @@ const filteredRows = computed(() => {
&& (!settlementNo || item.settlementNo.toLowerCase().includes(settlementNo))
&& (!supplierName || ('supplierName' in item && item.supplierName === supplierName)))
})
const paginatedRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
const totals = computed(() => filteredRows.value.reduce((result, row) => ({
amount: result.amount + totalAmount(row),
paid: result.paid + paidAmount(row),
@@ -224,8 +230,11 @@ async function loadCustomers() {
catch (cause) { customerError.value = getApiErrorDetails(cause, '客户列表加载失败') }
}
async function load(model: QueryModel = filters.value) {
filters.value = { ...model }
async function load(model?: QueryModel) {
if (model) {
filters.value = { ...model }
page.value = 1
}
rows.value = []
error.value = null
loading.value = true
@@ -571,25 +580,27 @@ onMounted(() => {
</DetailPageLayout>
<ListPageLayout v-else :aria-label="title">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" @reset="load" />
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" />
</template>
<template v-if="customerError || error" #state>
<LoadErrorState :title="`${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="settlement-list-toolbar" aria-label="结算汇总与列表操作">
<div class="settlement-summary" aria-label="结算汇总">
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</span><strong>{{ money(totals.amount) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收' : '已付' }}</span><strong class="is-success">{{ money(totals.paid) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '待收' : '待付' }}</span><strong class="is-danger">{{ money(totals.outstanding) }}</strong></div>
</div>
<div class="settlement-list-actions">
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
<el-button v-if="canExport" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportRows"><Download :size="15" />导出</el-button>
</div>
</section>
<section class="surface-panel table-panel">
<DataTable :data="filteredRows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
<template #toolbar-start>
<div class="settlement-summary" aria-label="结算汇总">
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</span><strong>{{ money(totals.amount) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收' : '已付' }}</span><strong class="is-success">{{ money(totals.paid) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '待收' : '待付' }}</span><strong class="is-danger">{{ money(totals.outstanding) }}</strong></div>
</div>
</template>
<template #toolbar-actions>
<div class="settlement-list-actions">
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
<el-button v-if="canExport" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportRows"><Download :size="15" />导出</el-button>
</div>
</template>
<template #actions="{ row }">
<div class="settlement-row-actions">
<el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>
@@ -644,9 +655,8 @@ onMounted(() => {
<style scoped>
.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: 6px 0; }
.settlement-summary { 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; }
.settlement-row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 6px; white-space: nowrap; }
@@ -695,12 +705,6 @@ onMounted(() => {
row-gap: 8px;
}
.settlement-list-toolbar {
min-height: 48px;
gap: 14px;
padding: 0 0 8px;
}
.settlement-summary .summary-item {
gap: 6px;
}
@@ -771,7 +775,6 @@ onMounted(() => {
.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; }
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
.settlement-list-actions { width: 100%; justify-content: flex-start; }
@@ -1,101 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
describe('FinanceManualPage delivery contract', () => {
it('keeps every business table operation column first and fixed left', () => {
const declarations = [...source.matchAll(/return \[([^\n]+)\]/g)].map((match) => match[1] || '').filter((value) => value.includes("key: 'actions'"))
expect(declarations).toHaveLength(3)
for (const declaration of declarations) {
expect(declaration.trim().startsWith("{ key: 'actions'")).toBe(true)
expect(declaration).toContain("fixed: 'left'")
}
})
it('exposes supported bill operations with permission checks and traceable errors', () => {
expect(source).toContain("platform:finance:bill:update")
expect(source).toContain("platform:finance:bill:adjust")
expect(source).toContain("platform:finance:bill:void")
expect(source).toContain("openBill(row, 'detail')")
expect(source).toContain("openBill(row, 'edit')")
expect(source).toContain("openBill(row, 'adjust')")
expect(source).toContain('<ApiErrorMeta :details="actionError"')
expect(source).toContain('<ApiErrorMeta :details="drawerError"')
})
it('uses real voucher uploads and declares narrow-screen layouts', () => {
expect(source).toContain('uploadFinanceVoucher(form.customerId, file.value)')
expect(source).toContain('voucherFileIds = uploadedVoucher.value ? [uploadedVoucher.value.fileId] : []')
expect(source).toContain("form.costType === 'CONTAINER_SHARED' ? form.referenceNo : undefined")
expect(source).toContain('由后端从 Firstleg 获取真实柜内订单体积快照并完成分摊')
expect(source).not.toContain('containerOrders')
expect(source).not.toContain('体积(CBM)')
expect(source).toContain('type="file" accept="image/*" :disabled="saving"')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('@media (max-width: 480px)')
expect(source).toContain('grid-template-columns: 1fr')
})
it('loads and displays real revision history without fabricated data', () => {
expect(source).toContain('listFinanceBillRevisions(detail.id, detail.customerId)')
expect(source).toContain('v-for="revision in billRevisions"')
expect(source).toContain('<ApiErrorMeta :details="revisionError"')
expect(source).toContain('暂无历史修订记录')
expect(source).not.toContain('mockRevision')
})
it('renders list and detail statuses through the finance translation contract', () => {
expect(source).toContain('financeBillStatusOptions')
expect(source).toContain('financeStatusLabel(selectedBill.status)')
expect(source).toContain('financeStatusLabel(revision.status)')
expect(source).toContain('financeStatusLabel(selectedLedgerRow.status)')
expect(source).not.toContain('{{ selectedBill.status }}')
})
it('uses business selectors instead of asking operators for internal ids', () => {
expect(source).toContain("type: 'select', label: '客户名称'")
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)")
expect(source).toContain('placeholder="请选择未结清账单"')
expect(source).toContain('placeholder="请选择客户订单"')
expect(source).toContain('placeholder="请选择客户订单关联的柜次"')
expect(source).toContain('const records = await listFinanceStatements(customerId)')
expect(source).not.toContain('listFinanceBills')
expect(source).toContain('listContainers({ page: 1, size: 100 })')
expect(source).not.toContain('placeholder="请输入客户ID"')
expect(source).not.toContain('placeholder="费用来源ID"')
expect(source).not.toContain('placeholder="账单ID"')
expect(source).not.toContain("'请输入订单ID'")
expect(source).not.toContain("'请输入柜次ID'")
expect(source).not.toContain('label="客户ID"')
expect(source).toContain('customerNameById(selectedLedgerRow.customerId)')
expect(source).toContain('options: activeCustomers.value.map')
expect(source).toContain('v-for="customer in selectableFormCustomers"')
expect(source).not.toContain("disabled: customer.status !== 'ACTIVE'")
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
})
it('keeps customer selection separate from business keyword matching', () => {
expect(source).toContain("placeholder: '单号/费用项目/供应商'")
expect(source).toContain('searchableRowValues(row)')
expect(source).not.toContain('Object.values(row).some')
})
it('exposes loading, empty and retry states for dependent business options', () => {
expect(source).toContain('该客户暂无未结清账单')
expect(source).toContain('该客户暂无可选订单')
expect(source).toContain('该客户订单暂无关联柜次')
expect(source).toContain('重新加载未结清账单')
expect(source).toContain('重新加载业务对象')
expect(source).toContain('重新加载客户订单')
expect(source).toContain('由账单分配金额自动汇总')
expect(source).toContain('账单分配金额不能超过该账单的未收金额')
})
})
@@ -1,621 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Eye, FileCheck2, FileUp, MoreHorizontal, Pencil, Plus, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { adjustFinanceBill, createFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBillRevisions, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, uploadFinanceVoucher, voidFinanceBill } from '../api/finance'
import type { FinanceBillDetail, FinanceBillRevision, FinancePayableSettlement, FinanceReceivableSettlement, FinanceStatementReadModel, FinanceVoucherResource } from '../types'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import {
listPlatformUnifiedOrderFeeDetails,
listPlatformUnifiedOrders,
type UnifiedOrderFeeDetailResource,
type UnifiedOrderResource,
} from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { financeBillStatusOptions, financeStatusLabel, financeStatusTone } from '../status'
type Mode = 'bills' | 'receipts' | 'costs'
type DrawerMode = 'create' | 'detail' | 'edit' | 'adjust'
type TableRow = Record<string, string | number | null>
interface BillLineDraft {
sourceFeeId: string
sourceFeeVersion: number
feeItemName: string
amount: string
orderId: string
inboundId: string
}
const props = defineProps<{ mode: Mode }>()
const access = useAccessPreviewStore()
const allRows = ref<TableRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const serverPaged = ref(false)
const filters = ref<QueryModel>({ keyword: '', customerId: '', status: '' })
const drawerOpen = ref(false)
const drawerMode = ref<DrawerMode>('create')
const drawerLoading = ref(false)
const drawerError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const selectedBill = ref<FinanceBillDetail | null>(null)
const selectedLedgerRow = ref<TableRow | null>(null)
const billRevisions = ref<FinanceBillRevision[]>([])
const revisionLoading = ref(false)
const revisionError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
const file = ref<File | null>(null)
const uploadedVoucher = ref<FinanceVoucherResource | null>(null)
const voucherError = ref('')
const customers = ref<PlatformTenantResource[]>([])
const customerOptionsLoading = ref(false)
const customerOptionsError = ref<ApiErrorDetails | null>(null)
const billingOrders = ref<UnifiedOrderResource[]>([])
const billingOrdersLoading = ref(false)
const billingOrdersError = ref('')
const receiptBills = ref<FinanceStatementReadModel[]>([])
const receiptBillsLoading = ref(false)
const receiptBillsError = ref('')
const costOrders = ref<UnifiedOrderResource[]>([])
const costContainers = ref<ContainerBookingResource[]>([])
const costOptionsLoading = ref(false)
const costOptionsError = ref('')
const feeSourcesByInbound = reactive<Record<string, UnifiedOrderFeeDetailResource[]>>({})
const feeSourcesLoading = reactive<Record<string, boolean>>({})
const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
const selectableFormCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === form.customerId
)))
const allocations = reactive([{ billId: '', amount: '' }])
const billLines = reactive<BillLineDraft[]>([emptyBillLine()])
let billingOrdersRequest = 0
let receiptBillsRequest = 0
let costOptionsRequest = 0
const title = computed(() => ({ bills: '客户对账单', receipts: '结算单应收', costs: '结算单应付' }[props.mode]))
const viewPermission = computed(() => ({ bills: 'platform:finance:customer-statement:list', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view' }[props.mode]))
const createPermission = computed(() => ({ bills: 'platform:finance:bill:create', receipts: 'platform:finance:receipt:create', costs: 'platform:finance:cost:create' }[props.mode]))
const canView = computed(() => access.can(viewPermission.value))
const canCreate = computed(() => Boolean(createPermission.value) && access.can(createPermission.value))
const canIssue = computed(() => access.can('platform:finance:bill:issue'))
const canUpdate = computed(() => access.can('platform:finance:bill:update'))
const canAdjust = computed(() => access.can('platform:finance:bill:adjust'))
const canVoid = computed(() => access.can('platform:finance:bill:void'))
const stateError = computed(() => customerOptionsError.value || loadError.value)
const drawerTitle = computed(() => {
if (props.mode !== 'bills') return drawerMode.value === 'detail' ? props.mode === 'receipts' ? '应收结算详情' : '应付结算详情' : props.mode === 'receipts' ? '登记人工收款' : '登记应付成本'
return { create: '新建客户对账单', detail: '对账单详情', edit: '编辑草稿对账单', adjust: '调整已出账对账单' }[drawerMode.value]
})
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, 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[]>(() => {
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
if (props.mode === 'receipts') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '结算单号', minWidth: 170 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '应收(CNY)', width: 130 }, { key: 'paidAmount', label: '已收(CNY)', width: 130 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 130 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
if (props.mode === 'costs') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '结算单号', minWidth: 170 }, { key: 'costType', label: '成本类型', width: 130, slotName: 'costType' }, { key: 'referenceNo', label: '来源追溯', minWidth: 180 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'supplierName', label: '供应商', minWidth: 150 }, { key: 'amount', label: '应付(CNY)', width: 130 }, { key: 'paidAmount', label: '已付(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'createdAt', label: '登记时间', width: 150 }]
return []
})
const rules: FormRules = {
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
billDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }],
referenceNo: [{ required: true, message: '请选择关联业务对象', trigger: 'change' }],
feeName: [{ required: true, message: '请输入费用项目', trigger: 'blur' }],
paymentMethod: [{ required: true, message: '请选择收款方式', trigger: 'change' }],
}
function mapStatement(item: FinanceStatementReadModel): TableRow { return { id: item.id, customerId: item.customerId, billNo: item.billNo, customerName: item.customerName, billDate: item.billDate, dueDate: item.dueDate, amount: item.totalAmount, paidAmount: item.receivedAmount, outstandingAmount: item.outstandingAmount, status: item.status, version: item.version } }
function mapReceivable(item: FinanceReceivableSettlement): TableRow { return { id: item.id, customerId: item.customerId, settlementNo: item.settlementNo, billDate: item.billDate, dueDate: item.dueDate, amount: item.receivableAmount, paidAmount: item.receivedAmount, outstandingAmount: item.outstandingAmount, status: item.status, version: item.version } }
function mapPayable(item: FinancePayableSettlement): TableRow { return { id: item.id, customerId: item.customerId, settlementNo: item.settlementNo, costType: item.costType, referenceNo: item.containerId ? `柜次 ${item.containerId}` : item.inboundId ? `入库单 ${item.inboundId}` : item.orderId ? `订单 ${item.orderId}` : '—', feeName: item.feeItemName, supplierName: item.supplierName || '未填写', amount: item.payableAmount, paidAmount: item.paidAmount, outstandingAmount: item.outstandingAmount, status: item.status, createdAt: item.createdAt, version: item.version } }
function customerNameById(customerId: string | number | null) {
return customers.value.find((customer) => String(customer.id) === String(customerId))?.tenantName || '客户信息暂不可用'
}
function searchableRowValues(row: TableRow) {
if (props.mode === 'bills') return [row.billNo]
if (props.mode === 'receipts') return [row.settlementNo]
return [row.settlementNo, row.referenceNo, row.feeName, row.supplierName]
}
function emptyBillLine(): BillLineDraft {
return { sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }
}
async function loadCustomerOptions() {
customerOptionsLoading.value = true
customerOptionsError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customerOptionsError.value = getApiErrorDetails(error, '客户列表加载失败')
} finally {
customerOptionsLoading.value = false
}
}
async function loadBillingOrders(customerId: string) {
const request = ++billingOrdersRequest
billingOrders.value = []
billingOrdersError.value = ''
if (!customerId) return
billingOrdersLoading.value = true
try {
const result = await listPlatformUnifiedOrders({ tenantId: customerId, customerId, page: 1, size: 200 })
if (request === billingOrdersRequest) billingOrders.value = result.records.filter((order) => order.inboundId != null)
} catch (error) {
if (request === billingOrdersRequest) billingOrdersError.value = getApiErrorDetails(error, '客户订单加载失败').message
} finally {
if (request === billingOrdersRequest) billingOrdersLoading.value = false
}
}
async function listAllCustomerOrders(customerId: string) {
const records: UnifiedOrderResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformUnifiedOrders({ tenantId: customerId, customerId, page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
return records
}
async function loadReceiptBills(customerId: string) {
const request = ++receiptBillsRequest
receiptBills.value = []
receiptBillsError.value = ''
if (!customerId) return
receiptBillsLoading.value = true
try {
const records = await listFinanceStatements(customerId)
if (request === receiptBillsRequest) receiptBills.value = records.filter((bill) => ['ISSUED', 'PARTIALLY_PAID'].includes(bill.status) && bill.outstandingAmount > 0)
} catch (error) {
if (request === receiptBillsRequest) receiptBillsError.value = getApiErrorDetails(error, '未结清账单加载失败').message
} finally {
if (request === receiptBillsRequest) receiptBillsLoading.value = false
}
}
async function loadCostOptions(customerId: string) {
const request = ++costOptionsRequest
costOrders.value = []
costContainers.value = []
costOptionsError.value = ''
if (!customerId) return
costOptionsLoading.value = true
try {
const orders = await listAllCustomerOrders(customerId)
if (request !== costOptionsRequest) return
costOrders.value = orders
const relatedContainerKeys = new Set(orders.flatMap((order) => [order.transport?.containerNo, order.transport?.cabinetNo]).filter((value): value is string => Boolean(value)))
if (!relatedContainerKeys.size) return
const firstPage = await listContainers({ page: 1, size: 100 })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
if (request !== costOptionsRequest) return
costContainers.value = [firstPage, ...remainingPages]
.flatMap((result) => result.records)
.filter((container) => relatedContainerKeys.has(container.containerNo || '') || relatedContainerKeys.has(container.cabinetNo))
} catch (error) {
if (request === costOptionsRequest) costOptionsError.value = getApiErrorDetails(error, '关联订单和柜次加载失败').message
} finally {
if (request === costOptionsRequest) costOptionsLoading.value = false
}
}
function orderLabel(order: UnifiedOrderResource) {
return `${order.orderNo || `订单 ${order.orderId}`} · ${order.inboundCode || `入库单 ${order.inboundId}`}`
}
async function loadFeeSources(inboundId: string) {
if (!inboundId || feeSourcesByInbound[inboundId] || feeSourcesLoading[inboundId]) return
feeSourcesLoading[inboundId] = true
try {
feeSourcesByInbound[inboundId] = await listPlatformUnifiedOrderFeeDetails(form.customerId, inboundId)
} catch (error) {
feeSourcesByInbound[inboundId] = []
ElMessage.error(getApiErrorDetails(error, '费用来源加载失败').message)
} finally {
feeSourcesLoading[inboundId] = false
}
}
function handleCustomerChange(customerId: string) {
uploadedVoucher.value = null
file.value = null
billingOrders.value = []
receiptBills.value = []
costOrders.value = []
costContainers.value = []
form.referenceNo = ''
if (props.mode !== 'bills') form.amount = ''
allocations.splice(0, allocations.length, { billId: '', amount: '' })
for (const key of Object.keys(feeSourcesByInbound)) delete feeSourcesByInbound[key]
billLines.splice(0, billLines.length, emptyBillLine())
if (props.mode === 'bills') void loadBillingOrders(customerId)
else if (props.mode === 'receipts') void loadReceiptBills(customerId)
else void loadCostOptions(customerId)
}
function handleCostTypeChange() {
form.referenceNo = ''
}
function orderOptionLabel(order: UnifiedOrderResource) {
return [order.orderNo || `订单 ${order.orderId}`, order.inboundCode ? `入库单 ${order.inboundCode}` : ''].filter(Boolean).join(' · ')
}
function containerOptionLabel(container: ContainerBookingResource) {
return [container.cabinetNo || `柜次 ${container.id}`, container.containerNo ? `柜号 ${container.containerNo}` : '', container.warehouseName].filter(Boolean).join(' · ')
}
function receiptBillLabel(bill: FinanceStatementReadModel) {
return `${bill.billNo || `账单 ${bill.id}`} · 未收 ${bill.outstandingAmount.toFixed(2)} CNY`
}
function allocationBillDisabled(billId: string, index: number) {
return allocations.some((allocation, allocationIndex) => allocationIndex !== index && allocation.billId === billId)
}
function syncReceiptAmount() {
form.amount = allocations.reduce((sum, allocation) => sum + (positiveNumber(allocation.amount) ? Number(allocation.amount) : 0), 0).toFixed(2)
}
function handleAllocationBillChange(index: number, billId: string) {
const allocation = allocations[index]
if (!allocation) return
allocation.billId = billId
const bill = receiptBills.value.find((item) => item.id === billId)
allocation.amount = bill ? bill.outstandingAmount.toFixed(2) : ''
syncReceiptAmount()
}
function handleLineOrderChange(line: BillLineDraft, orderId: string) {
const order = billingOrders.value.find((item) => String(item.orderId) === orderId)
line.orderId = orderId
line.inboundId = order?.inboundId == null ? '' : String(order.inboundId)
line.sourceFeeId = ''
line.sourceFeeVersion = 1
line.feeItemName = ''
line.amount = ''
if (line.inboundId) void loadFeeSources(line.inboundId)
}
function handleFeeSourceChange(line: BillLineDraft, sourceFeeId: string) {
const source = (feeSourcesByInbound[line.inboundId] || []).find((item) => String(item.sourceFeeId ?? '') === sourceFeeId)
line.sourceFeeId = sourceFeeId
line.sourceFeeVersion = Number(source?.sourceFeeVersion || 1)
line.feeItemName = source?.feeTypeNameSnapshot || ''
line.amount = source?.amount == null ? '' : String(source.amount)
}
function feeSourceOptions(line: BillLineDraft) {
return (feeSourcesByInbound[line.inboundId] || []).filter((item) => item.sourceFeeId != null && Number(item.sourceFeeVersion) > 0)
}
function feeSourceLabel(source: UnifiedOrderFeeDetailResource) {
return `${source.feeTypeNameSnapshot || '未命名费用'} · ${source.amount ?? 0} ${source.currency || 'CNY'}`
}
async function load() {
if (!canView.value) return
loading.value = true; loadError.value = null
try {
const customerId = String(filters.value.customerId || '').trim() || undefined
let source: TableRow[]
let responseTotal = 0
if (props.mode === 'bills') {
const result = await listFinanceStatements(customerId)
source = result.map(mapStatement)
responseTotal = result.length; serverPaged.value = false
} else if (props.mode === 'receipts') {
const result = await listFinanceReceivableSettlements(customerId); source = result.map(mapReceivable); responseTotal = result.length; serverPaged.value = false
} else {
const result = await listFinancePayableSettlements(customerId); source = result.map(mapPayable); responseTotal = result.length; serverPaged.value = false
}
const keyword = String(filters.value.keyword || '').trim().toLowerCase()
const status = String(filters.value.status || '')
allRows.value = source.filter((row) => (!keyword || searchableRowValues(row).some((value) => String(value ?? '').toLowerCase().includes(keyword))) && (!status || row.status === status))
total.value = keyword || status ? allRows.value.length : responseTotal
} catch (error) { allRows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
finally { loading.value = false }
}
function applyFilters(model: QueryModel) { Object.assign(filters.value, model); page.value = 1; void load() }
function changePageSize() { page.value = 1; void load() }
function openCreate() {
billingOrdersRequest += 1; receiptBillsRequest += 1; costOptionsRequest += 1
drawerMode.value = 'create'; selectedBill.value = null; selectedLedgerRow.value = null; billRevisions.value = []; revisionError.value = null; drawerError.value = null
Object.assign(form, { customerId: '', billDate: new Date().toISOString().slice(0, 10), amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
allocations.splice(0, allocations.length, { billId: '', amount: '' })
billLines.splice(0, billLines.length, emptyBillLine())
billingOrders.value = []; billingOrdersError.value = ''; receiptBills.value = []; receiptBillsError.value = ''; costOrders.value = []; costContainers.value = []; costOptionsError.value = ''
file.value = null; uploadedVoucher.value = null; voucherError.value = ''; drawerOpen.value = true
}
function selectFile(event: Event) {
const selected = (event.target as HTMLInputElement).files?.[0] || null
uploadedVoucher.value = null; voucherError.value = ''
if (selected && !selected.type.startsWith('image/')) { file.value = null; voucherError.value = '凭证仅支持图片文件'; return }
file.value = selected
}
function positiveNumber(value: string) { const amount = Number(value); return Number.isFinite(amount) && amount > 0 }
function addAllocation() { allocations.push({ billId: '', amount: '' }) }
function removeAllocation(index: number) { if (allocations.length > 1) { allocations.splice(index, 1); syncReceiptAmount() } }
function addBillLine() { billLines.push(emptyBillLine()) }
function removeBillLine(index: number) { if (billLines.length > 1) billLines.splice(index, 1) }
function billPayloadLines() { return billLines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, orderId: line.orderId || undefined, inboundId: line.inboundId || undefined, feeItemName: line.feeItemName.trim(), amount: Number(line.amount) })) }
function validateBusinessForm() {
if (!/^\d+$/.test(form.customerId)) return '请选择有效客户'
if (props.mode !== 'costs' && !form.billDate) return '请选择日期'
if (props.mode !== 'bills' && !positiveNumber(form.amount)) return '金额必须大于 0'
if (props.mode === 'bills' && billLines.some((line) => !/^\d+$/.test(line.sourceFeeId) || !line.orderId || !line.inboundId || !line.feeItemName.trim() || !positiveNumber(line.amount))) return '请为每条账单明细选择订单和已生成的费用来源'
if (drawerMode.value === 'adjust' && !form.remark.trim()) return '调整原因不能为空'
if (props.mode === 'costs' && (!form.referenceNo || !form.feeName.trim())) return `请选择${form.costType === 'SINGLE_ORDER' ? '订单' : '柜次'}并填写费用项目`
if (props.mode === 'receipts') {
if (allocations.some((item) => !receiptBills.value.some((bill) => bill.id === item.billId) || !positiveNumber(item.amount))) return '每条分配都必须选择未结清账单并填写大于 0 的金额'
if (new Set(allocations.map((item) => item.billId)).size !== allocations.length) return '同一账单不能重复分配'
const exceededBill = allocations.find((item) => Number(item.amount) > (receiptBills.value.find((bill) => bill.id === item.billId)?.outstandingAmount || 0) + 0.000001)
if (exceededBill) return '账单分配金额不能超过该账单的未收金额'
const allocated = allocations.reduce((sum, item) => sum + Number(item.amount), 0)
if (Math.abs(allocated - Number(form.amount)) > 0.000001) return `分配金额合计 ${allocated.toFixed(2)} 元,必须等于收款金额`
}
if (props.mode !== 'bills' && uploadedVoucher.value && uploadedVoucher.value.customerId !== form.customerId) return '客户ID已变化,请重新选择并上传对应客户的凭证'
if (props.mode !== 'bills' && !file.value && !uploadedVoucher.value) return '请先选择收款或成本凭证图片'
return ''
}
async function submit() {
const valid = await formRef.value?.validate().catch(() => false); if (!valid) return
const validationMessage = validateBusinessForm(); if (validationMessage) { ElMessage.warning(validationMessage); return }
saving.value = true
drawerError.value = null
try {
if (props.mode === 'bills') {
const lines = billPayloadLines()
if (drawerMode.value === 'edit' && selectedBill.value) await updateFinanceBill(selectedBill.value.id, { customerId: form.customerId, billDate: form.billDate, lines, expectedVersion: selectedBill.value.version })
else if (drawerMode.value === 'adjust' && selectedBill.value) await adjustFinanceBill(selectedBill.value.id, { expectedVersion: selectedBill.value.version, reason: form.remark.trim(), lines })
else await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, lines })
} else {
if (!uploadedVoucher.value && file.value) uploadedVoucher.value = await uploadFinanceVoucher(form.customerId, file.value)
const voucherFileIds = uploadedVoucher.value ? [uploadedVoucher.value.fileId] : []
if (!voucherFileIds.length) throw new Error('凭证上传未返回有效 fileId')
if (props.mode === 'receipts') {
await createFinanceReceipt({ customerId: form.customerId, receivedDate: form.billDate, amount: String(form.amount), paymentMethod: form.paymentMethod, remark: form.remark, allocations: allocations.map((item) => ({ billId: item.billId, amount: String(item.amount) })) }, voucherFileIds)
} else {
await createFinanceCost({ customerId: form.customerId, costType: form.costType, orderId: form.costType === 'SINGLE_ORDER' ? form.referenceNo : undefined, containerId: form.costType === 'CONTAINER_SHARED' ? form.referenceNo : undefined, expenseName: form.feeName.trim(), amount: String(form.amount), remark: form.remark }, voucherFileIds)
}
}
drawerOpen.value = false; ElMessage.success(`${title.value}已保存`); await load()
} catch (error) { const details = getApiErrorDetails(error, `${title.value}保存失败,请检查 Finance 接口后重试`); drawerError.value = details; ElMessage.error(details.message) }
finally { saving.value = false }
}
async function issueBill(row: TableRow) {
actionError.value = null
try {
await ElMessageBox.confirm('出账后账单将冻结,不能再按草稿方式修改。确认出账吗?', '确认出账', { type: 'warning', confirmButtonText: '确认出账', cancelButtonText: '取消' })
await issueFinanceBill(String(row.id), Number(row.version))
ElMessage.success('账单已出账并冻结'); await load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
const details = getApiErrorDetails(error, '账单出账失败,请重试'); actionError.value = details; ElMessage.error(details.message)
}
}
async function openBill(row: TableRow, mode: DrawerMode) {
drawerMode.value = mode; drawerOpen.value = true; drawerLoading.value = true; drawerError.value = null; revisionError.value = null; billRevisions.value = []; selectedBill.value = null
try {
const detail = await getFinanceBill(String(row.id)); selectedBill.value = detail
Object.assign(form, { customerId: detail.customerId, billDate: detail.billDate, remark: '' })
billLines.splice(0, billLines.length, ...detail.lines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, feeItemName: line.feeItemName, amount: String(line.amount), orderId: line.orderId || '', inboundId: line.inboundId || '' })))
if (!billLines.length) addBillLine()
await loadBillingOrders(detail.customerId)
await Promise.all([...new Set(billLines.map((line) => line.inboundId).filter(Boolean))].map(loadFeeSources))
if (mode === 'detail') {
revisionLoading.value = true
try { billRevisions.value = await listFinanceBillRevisions(detail.id, detail.customerId) }
catch (error) { revisionError.value = getApiErrorDetails(error, '账单修订记录加载失败,请重试') }
finally { revisionLoading.value = false }
}
} catch (error) { drawerError.value = getApiErrorDetails(error, '账单详情加载失败,请重试') }
finally { drawerLoading.value = false }
}
function openLedgerDetail(row: TableRow) {
drawerMode.value = 'detail'
selectedLedgerRow.value = row
drawerError.value = null
drawerOpen.value = true
}
async function voidBill(row: TableRow) {
actionError.value = null
try {
const result = await ElMessageBox.prompt('请输入作废原因。已有收款的账单不能作废。', '作废账单', { type: 'warning', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await voidFinanceBill(String(row.id), Number(row.version), result.value.trim())
ElMessage.success('账单已作废'); await load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
const details = getApiErrorDetails(error, '账单作废失败,请重试'); actionError.value = details; ElMessage.error(details.message)
}
}
function handleBillCommand(command: string, row: TableRow) {
if (command === 'issue') void issueBill(row)
else if (command === 'void') void voidBill(row)
}
onMounted(() => { void load(); void loadCustomerOptions() })
</script>
<template>
<AdminPage as="div" class="finance-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问财务模块" sub-title="请联系管理员开通平台财务权限" />
<ListPageLayout v-else aria-label="财务人工模块">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="queryFields" :preference-key="`finance-${props.mode}-query`" @search="applyFilters" /></template>
<template v-if="stateError" #state><LoadErrorState :title="customerOptionsError ? '客户选项加载失败' : `${title}加载失败`" :message="stateError.message" :loading="customerOptionsLoading || loading" @retry="customerOptionsError ? loadCustomerOptions() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<div v-if="actionError" class="surface-panel operation-error" role="alert"><el-alert type="error" :closable="true" :title="actionError.message" @close="actionError = null" /><ApiErrorMeta :details="actionError" /></div>
<section v-if="!loadError && !customerOptionsError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`finance-${props.mode}-table`" :aria-label="`${title}列表`" row-key="id" empty-text="暂无数据" @update:page="load" @update:page-size="changePageSize">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.mode === 'bills' ? '新建对账单' : props.mode === 'receipts' ? '登记收款' : '登记应付成本' }}</el-button></template>
<template #actions="{ row }">
<div v-if="props.mode === 'bills'" class="row-actions">
<el-button link type="primary" @click="openBill(row, 'detail')"><Eye :size="15" />查看</el-button>
<el-button v-if="row.status === 'DRAFT' && canUpdate" link type="primary" @click="openBill(row, 'edit')"><Pencil :size="15" />编辑</el-button>
<el-button v-if="['ISSUED', 'PARTIALLY_PAID', 'PAID'].includes(String(row.status)) && canAdjust" link type="primary" @click="openBill(row, 'adjust')"><Pencil :size="15" />调整</el-button>
<el-button v-if="row.status === 'ISSUED' && canVoid" link type="danger" @click="voidBill(row)"><Trash2 :size="15" />作废</el-button>
<el-dropdown v-if="row.status === 'DRAFT' && (canIssue || canVoid)" trigger="click" @command="handleBillCommand($event, row)">
<el-button link aria-label="更多账单操作"><MoreHorizontal :size="17" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canIssue" command="issue"><FileCheck2 :size="15" />出账</el-dropdown-item><el-dropdown-item v-if="canVoid" command="void" divided><Trash2 :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
<el-button v-else link type="primary" @click="openLedgerDetail(row)"><Eye :size="15" />查看</el-button>
</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #costType="{ row }">{{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</template>
<template #empty><el-empty description="暂无符合条件的财务记录" /></template>
<template #pagination-left>币种:CNY · 数据由平台财务人工维护</template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="drawerTitle" size="min(760px, 94vw)" :loading="drawerLoading" :confirm-loading="saving">
<div v-if="drawerError" class="drawer-error" role="alert"><el-alert type="error" :closable="false" :title="drawerError.message" /><ApiErrorMeta :details="drawerError" /></div>
<template v-if="props.mode === 'bills' && drawerMode === 'detail' && selectedBill">
<el-descriptions :column="2" border class="bill-summary">
<el-descriptions-item label="账单编号">{{ selectedBill.billNo }}</el-descriptions-item><el-descriptions-item label="当前版本">v{{ selectedBill.version }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedBill.status) }}</el-descriptions-item>
<el-descriptions-item label="账单日期">{{ selectedBill.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedBill.dueDate }}</el-descriptions-item>
<el-descriptions-item label="账单金额">{{ selectedBill.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedBill.outstandingAmount }} CNY</el-descriptions-item>
</el-descriptions>
<FormSection title="费用明细" description="当前账单版本中的真实费用明细。">
<div v-for="(line, index) in selectedBill.lines" :key="`${line.sourceFeeId}-${index}`" class="detail-line"><strong>{{ line.feeItemName }}</strong><span>{{ line.inboundId ? `入库单 ${line.inboundId} · ` : '' }}{{ line.orderId ? `订单 ${line.orderId} · ` : '' }}来源 {{ line.sourceFeeId }}/v{{ line.sourceFeeVersion }}</span><b>{{ line.amount }} CNY</b></div>
<el-empty v-if="!selectedBill.lines.length" description="暂无费用明细" />
</FormSection>
<FormSection title="修订记录" description="按版本倒序展示后端保存的调整前账单快照。">
<el-skeleton v-if="revisionLoading" :rows="2" animated />
<div v-else-if="revisionError" class="revision-error" role="alert"><el-alert type="error" :closable="false" :title="revisionError.message" /><ApiErrorMeta :details="revisionError" /></div>
<el-empty v-else-if="!billRevisions.length" description="暂无历史修订记录" />
<div v-else class="revision-list">
<section v-for="revision in billRevisions" :key="revision.id" class="revision-item">
<div class="revision-heading"><strong>调整前版本 v{{ revision.version }}</strong><span>{{ revision.createdAt || '时间未知' }}</span></div>
<div class="revision-meta"><span>原因:{{ revision.reason || '未填写' }}</span><span>操作人ID:{{ revision.createdBy || '—' }}</span><span>状态:{{ financeStatusLabel(revision.status) }}</span><span>金额:{{ revision.totalAmount }} CNY</span></div>
<div v-for="(line, index) in revision.lines" :key="`${revision.id}-${line.sourceFeeId}-${index}`" class="revision-line"><span>{{ line.feeItemName || `费用来源 ${line.sourceFeeId}` }}</span><b>{{ line.amount }} CNY</b></div>
<p v-if="!revision.lines.length" class="revision-empty">该版本没有费用明细快照</p>
</section>
</div>
</FormSection>
</template>
<template v-else-if="props.mode !== 'bills' && drawerMode === 'detail' && selectedLedgerRow">
<el-descriptions :column="2" border class="bill-summary">
<el-descriptions-item label="结算单号">{{ selectedLedgerRow.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerNameById(selectedLedgerRow.customerId) }}</el-descriptions-item>
<template v-if="props.mode === 'receipts'"><el-descriptions-item label="账单日期">{{ selectedLedgerRow.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedLedgerRow.dueDate }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedLedgerRow.status) }}</el-descriptions-item></template>
<template v-else><el-descriptions-item label="费用项目">{{ selectedLedgerRow.feeName }}</el-descriptions-item><el-descriptions-item label="成本类型">{{ selectedLedgerRow.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</el-descriptions-item><el-descriptions-item label="关联来源">{{ selectedLedgerRow.referenceNo }}</el-descriptions-item><el-descriptions-item label="应付金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已付金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未付金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="登记时间">{{ selectedLedgerRow.createdAt }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedLedgerRow.status) }}</el-descriptions-item></template>
</el-descriptions>
</template>
<el-form v-else-if="drawerMode !== 'detail'" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || drawerLoading">
<FormSection title="基本信息" description="首期只支持 CNY,由平台财务人工填写并留存凭证。">
<div class="form-grid two-cols">
<el-form-item label="客户" prop="customerId"><el-select v-model="form.customerId" filterable placeholder="请选择客户" :loading="customerOptionsLoading" :loading-text="'客户加载中'" :no-data-text="customerOptionsError?.message || '暂无可选客户'" :disabled="props.mode === 'bills' && drawerMode !== 'create'" style="width: 100%" @change="handleCustomerChange"><el-option v-for="customer in selectableFormCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item v-if="props.mode !== 'costs'" :label="props.mode === 'receipts' ? '收款日期' : '账单日期'" prop="billDate"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" :placeholder="props.mode === 'receipts' ? '请选择收款日期' : '请选择账单日期'" style="width: 100%" :disabled="props.mode === 'bills' && drawerMode === 'adjust'" /></el-form-item>
</div>
<div v-if="customerOptionsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="customerOptionsError.message" show-icon /><ApiErrorMeta :details="customerOptionsError" /><el-button link type="primary" :loading="customerOptionsLoading" @click="loadCustomerOptions">重新加载客户</el-button></div>
<template v-if="props.mode === 'bills'">
<div class="allocation-title">费用明细</div>
<div v-if="billingOrdersError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="billingOrdersError" show-icon /><el-button link type="primary" :loading="billingOrdersLoading" @click="loadBillingOrders(form.customerId)">重新加载客户订单</el-button></div>
<p v-else class="form-hint">先选择客户订单,再选择该入库单已生成的费用来源;系统会自动带出费用项目、版本和金额。</p>
<div v-for="(line, index) in billLines" :key="index" class="bill-line-row">
<el-select :model-value="line.orderId" filterable placeholder="选择订单 / 入库单" :loading="billingOrdersLoading" loading-text="订单加载中" :no-data-text="form.customerId ? '该客户暂无已入库订单' : '请先选择客户'" :disabled="!form.customerId" @update:model-value="handleLineOrderChange(line, String($event || ''))"><el-option v-for="order in billingOrders" :key="String(order.orderId)" :label="orderLabel(order)" :value="String(order.orderId)" /></el-select>
<el-select :model-value="line.sourceFeeId" filterable placeholder="选择费用来源" :loading="Boolean(feeSourcesLoading[line.inboundId])" loading-text="费用来源加载中" :no-data-text="line.inboundId ? '该入库单暂无可选费用' : '请先选择订单'" :disabled="!line.inboundId" @update:model-value="handleFeeSourceChange(line, String($event || ''))"><el-option v-for="source in feeSourceOptions(line)" :key="`${source.sourceFeeId}-${source.sourceFeeVersion}`" :label="feeSourceLabel(source)" :value="String(source.sourceFeeId)" /></el-select>
<el-input v-model="line.feeItemName" placeholder="费用项目(自动带出)" disabled />
<el-input v-model="line.amount" inputmode="decimal" placeholder="金额(CNY)" />
<el-button link type="danger" aria-label="删除费用明细" @click="removeBillLine(index)"><Trash2 :size="16" /></el-button>
</div>
<el-button link type="primary" @click="addBillLine"><Plus :size="15" />添加费用明细</el-button>
<el-form-item v-if="drawerMode === 'adjust'" label="调整原因" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请说明本次调整原因" /></el-form-item>
</template>
<template v-else>
<div class="form-grid two-cols">
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" placeholder="请选择成本类型" style="width: 100%" @change="handleCostTypeChange"><el-option label="单票成本" value="SINGLE_ORDER" /><el-option label="整柜公共成本" value="CONTAINER_SHARED" /></el-select></el-form-item>
<el-form-item label="金额(CNY)" prop="amount"><el-input v-model="form.amount" inputmode="decimal" :readonly="props.mode === 'receipts'" :placeholder="props.mode === 'receipts' ? '由账单分配金额自动汇总' : '请输入成本金额'" /></el-form-item>
</div>
<div v-if="props.mode === 'costs' && costOptionsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="costOptionsError" show-icon /><el-button link type="primary" :loading="costOptionsLoading" @click="loadCostOptions(form.customerId)">重新加载业务对象</el-button></div>
<el-form-item v-if="props.mode === 'costs' && form.costType === 'SINGLE_ORDER'" label="关联订单" prop="referenceNo"><el-select v-model="form.referenceNo" filterable placeholder="请选择客户订单" :loading="costOptionsLoading" loading-text="订单加载中" :no-data-text="form.customerId ? '该客户暂无可选订单' : '请先选择客户'" :disabled="!form.customerId" style="width: 100%"><el-option v-for="order in costOrders" :key="String(order.orderId)" :label="orderOptionLabel(order)" :value="String(order.orderId)" /></el-select></el-form-item>
<el-form-item v-if="props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" label="关联柜次" prop="referenceNo"><el-select v-model="form.referenceNo" filterable placeholder="请选择客户订单关联的柜次" :loading="costOptionsLoading" loading-text="柜次加载中" :no-data-text="form.customerId ? '该客户订单暂无关联柜次' : '请先选择客户'" :disabled="!form.customerId" style="width: 100%"><el-option v-for="container in costContainers" :key="container.id" :label="containerOptionLabel(container)" :value="container.id" /></el-select></el-form-item>
<template v-if="props.mode === 'receipts'">
<el-form-item label="收款方式" prop="paymentMethod"><el-select v-model="form.paymentMethod" placeholder="请选择收款方式" style="width: 100%"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item>
<div class="allocation-title">账单分配</div>
<div v-if="receiptBillsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="receiptBillsError" show-icon /><el-button link type="primary" :loading="receiptBillsLoading" @click="loadReceiptBills(form.customerId)">重新加载未结清账单</el-button></div>
<p v-else class="form-hint">选择未结清对账单后自动带出未收金额,可按本次实际到账金额调整分配。</p>
<div v-for="(allocation, index) in allocations" :key="index" class="allocation-row"><el-select :model-value="allocation.billId" filterable placeholder="请选择未结清账单" :loading="receiptBillsLoading" loading-text="账单加载中" :no-data-text="form.customerId ? '该客户暂无未结清账单' : '请先选择客户'" :disabled="!form.customerId" @update:model-value="handleAllocationBillChange(index, String($event || ''))"><el-option v-for="bill in receiptBills" :key="bill.id" :label="receiptBillLabel(bill)" :value="bill.id" :disabled="allocationBillDisabled(bill.id, index)" /></el-select><el-input v-model="allocation.amount" inputmode="decimal" placeholder="本次分配金额(CNY)" @input="syncReceiptAmount" /><el-button link type="danger" aria-label="删除分配" @click="removeAllocation(index)"><Trash2 :size="16" /></el-button></div>
<el-button link type="primary" @click="addAllocation"><Plus :size="15" />添加账单分配</el-button>
</template>
<el-form-item v-if="props.mode === 'costs'" label="费用项目" prop="feeName"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" /></el-form-item>
<el-alert v-if="props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" type="info" :closable="false" show-icon title="整柜成本保存时由后端从 Firstleg 获取真实柜内订单体积快照并完成分摊。" />
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(可选)" /></el-form-item>
</template>
</FormSection>
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="上传真实收付款凭证后再保存财务记录。">
<label class="file-picker" :class="{ 'is-disabled': saving }"><FileUp :size="16" />{{ uploadedVoucher ? `已上传:${uploadedVoucher.fileName}` : file?.name || '选择凭证图片' }}<input type="file" accept="image/*" :disabled="saving" @change="selectFile" /></label>
<p v-if="voucherError" class="voucher-error" role="alert">{{ voucherError }}</p>
<p v-else-if="uploadedVoucher" class="voucher-success">凭证已上传,创建失败时可直接重试。</p>
</FormSection>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : saving ? '上传并保存中' : '上传并保存' }}</el-button></div></template>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-page { min-width: 0; }
.operation-error, .drawer-error { display: grid; gap: 8px; padding: 12px; }
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
.row-actions .el-button { margin-left: 0; }
.file-picker { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 10px 14px; border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; color: var(--cc-text); overflow-wrap: anywhere; }
.file-picker.is-disabled { cursor: not-allowed; color: var(--cc-muted); background: var(--el-fill-color-light); }
.file-picker input { display: none; }
.voucher-error { margin: 8px 0 0; color: var(--el-color-danger); }
.voucher-success { margin: 8px 0 0; color: var(--el-color-success); overflow-wrap: anywhere; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
.allocation-title { margin: 4px 0 8px; font-weight: 600; }
.option-error { display: grid; justify-items: start; gap: 4px; margin-bottom: 10px; }
.option-error .el-alert { width: 100%; }
.allocation-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
.bill-line-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(180px, 1.2fr) minmax(140px, 1fr) minmax(110px, .7fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
.form-hint { margin: 0 0 10px; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.bill-summary { margin-bottom: 16px; }
.revision-error { display: grid; gap: 8px; }
.revision-list { display: grid; gap: 12px; }
.revision-item { padding-bottom: 12px; border-bottom: 1px solid var(--el-border-color-lighter); }
.revision-heading, .revision-meta, .revision-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.revision-heading span, .revision-meta, .revision-empty { color: var(--cc-muted); }
.revision-meta { flex-wrap: wrap; justify-content: flex-start; margin: 6px 0; font-size: 13px; }
.revision-line { padding: 5px 0; }
.revision-line b { white-space: nowrap; }
.revision-empty { margin: 6px 0 0; }
.detail-line { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .8fr) auto; gap: 12px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
.detail-line span { color: var(--cc-muted); overflow-wrap: anywhere; }
.detail-line b { white-space: nowrap; }
@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } .bill-line-row { grid-template-columns: 1fr 1fr; } .drawer-footer .el-button { min-height: 44px; } :deep(.bill-summary .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { .drawer-footer { flex-wrap: wrap; } .drawer-footer .el-button { flex: 1 1 120px; } .allocation-row, .bill-line-row, .detail-line { grid-template-columns: 1fr; } .allocation-row .el-button, .bill-line-row .el-button { justify-self: end; } .revision-heading, .revision-line { align-items: flex-start; } :deep(.bill-summary .el-descriptions__cell) { display: block; width: 100%; } }
</style>
@@ -7,7 +7,6 @@ const financeRoutes = readFileSync(resolve(process.cwd(), 'src/modules/finance/r
const accounts = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8')
const statements = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
const arap = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
describe('finance presentation governance', () => {
it('shows customer names instead of internal customer IDs in formal settlements', () => {
@@ -32,7 +31,7 @@ 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: 54px;')
expect(settlement).toContain('.settlement-detail-header { display: flex; min-height: 68px;')
expect(settlement).toContain('align-items: center;')
expect(settlement).toContain('justify-content: space-between')
expect(settlement).toContain('<el-descriptions :column="4" class="settlement-detail-info">')
@@ -49,13 +48,13 @@ describe('finance presentation governance', () => {
expect(settlement).toContain('column-gap: 14px;')
})
it('keeps settlement totals and list actions in one compact toolbar', () => {
expect(settlement).toContain('class="settlement-list-toolbar"')
it('keeps settlement totals and list actions in the table toolbar', () => {
expect(settlement).toContain('<template #toolbar-start>')
expect(settlement).toContain('class="settlement-summary" aria-label="结算汇总"')
expect(settlement).toContain('<template #toolbar-actions>')
expect(settlement).toContain('class="settlement-list-actions"')
expect(settlement).toContain('min-height: 54px; align-items: center; justify-content: space-between')
expect(settlement).toContain('.settlement-summary .summary-item + .summary-item')
expect(settlement).not.toContain('<template #toolbar-actions>')
expect(settlement).not.toContain('class="settlement-list-toolbar"')
})
it('keeps settlement details focused on finance-facing labels and fields', () => {
@@ -91,12 +90,7 @@ describe('finance presentation governance', () => {
expect(arap).toContain('financeDirectionLabel(detail.direction)')
expect(arap).not.toContain("detail.direction === 'RECEIVABLE' ? '应收' : '应付'")
expect(arap).toContain('请填写驳回原因')
expect(arap).toContain('请说明参与或排除结算的原因')
})
it('does not expose the uploaded voucher file ID in the form', () => {
expect(manual).toContain('凭证已上传,创建失败时可直接重试')
expect(manual).not.toContain('凭证 fileId:')
expect(arap).toContain('请填写操作原因')
})
it('exposes frozen G3 controls without unavailable placeholders', () => {
@@ -153,7 +147,7 @@ describe('finance presentation governance', () => {
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="当前审核、参与结算和出账状态"')
expect(arap).not.toContain('description="当前审核和出账状态"')
expect(arap).not.toContain('description="按时间查看审核与结算调整记录"')
})
})
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8')
describe('receivable statistics page', () => {
it('renders only the approved CNY derived metrics and current-filter actions', () => {
for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label)
expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'")
expect(page).toContain('window.print()')
for (const excluded of ['FBA直发', 'FBA专线', '零头减免', 'USD', '>编辑<']) expect(page).not.toContain(excluded)
})
it('keeps query reset, loading, empty, error and permission states explicit', () => {
expect(page).toContain('function reset()')
expect(page).toContain('无权访问应收款统计表')
expect(page).toContain('<LoadErrorState')
expect(page).toContain('当前月份暂无应收数据')
expect(page).toContain(':loading="loading"')
})
})
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Download, Printer, RotateCcw, Search } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getReceivableStatistics } from '../api/finance'
import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../types'
const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const access = useAccessPreviewStore()
const router = useRouter()
const month = ref(defaultMonth)
const customerId = ref('')
const customers = ref<PlatformTenantResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const result = ref<ReceivableStatisticsResult | null>(null)
const canView = computed(() => access.can('platform:finance:receivable-statistics:list'))
const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list'))
const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? [])
const columns: DataTableColumn[] = [
{ key: 'customerName', label: '应收单位', minWidth: 240 },
{ key: 'currency', label: '币种', width: 100 },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, slotName: 'totalOutstanding' },
]
async function load() {
if (!canView.value || !month.value) return
loading.value = true
error.value = null
try {
result.value = await getReceivableStatistics({ month: month.value, customerId: customerId.value || undefined, page: page.value, pageSize: pageSize.value })
} catch (reason) { error.value = getApiErrorDetails(reason, '应收款统计加载失败,请重试') }
finally { loading.value = false }
}
async function loadCustomers() {
try {
const records: PlatformTenantResource[] = []
let current = 1; let pages = 1
do { const response = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...response.records); pages = response.pages; current += 1 } while (current <= pages)
customers.value = records
} catch { /* 客户筛选失败不阻断统计,仍可查询全部客户。 */ }
}
function search() { page.value = 1; void load() }
function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() }
function printCurrent() { window.print() }
function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` }
async function exportCurrent() {
exporting.value = true
try {
const task = await createPlatformExportTask({ exportType: 'FINANCE_RECEIVABLE_STATISTICS', format: 'XLSX', taskName: `应收款统计表-${month.value}`,
parameters: { month: month.value, ...(customerId.value ? { customerId: customerId.value } : {}) } })
const open = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (open) await router.push({ name: 'download-center' })
} catch (reason) { ElMessage.error(getApiErrorMessage(reason, '应收款统计导出任务创建失败')) }
finally { exporting.value = false }
}
function money(value?: string) { return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="receivable-statistics-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else>
<section class="surface-panel statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
<div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div>
</section>
<LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<section class="statistics-summary" aria-label="CNY应收汇总">
<div><span>应收总额</span><strong>CNY {{ money(result?.totalReceivable) }}</strong></div>
<div><span>已收合计</span><strong class="is-received">CNY {{ money(result?.totalReceived) }}</strong></div>
<div><span>余额未收</span><strong class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</strong></div>
</section>
<section class="surface-panel statistics-table">
<header><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
</DataTable>
</section>
</template>
</template>
</AdminPage>
</template>
<style scoped>
.statistics-query { display: flex; align-items: end; gap: 12px; padding: 16px; }
.query-field { display: grid; min-width: 220px; gap: 6px; color: var(--cc-muted); font-size: 13px; }
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
.query-actions, .report-actions { display: flex; gap: 8px; }
.report-actions { margin-left: auto; }
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.statistics-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.statistics-summary > div { padding: 16px; border: 1px solid var(--cc-border, #e3e8ef); border-radius: 8px; background: var(--cc-surface, #fff); }
.statistics-summary span { display: block; margin-bottom: 8px; color: var(--cc-muted); font-size: 13px; }
.statistics-summary strong { font-size: 22px; font-variant-numeric: tabular-nums; }
.statistics-summary .is-received { color: var(--el-color-success); }
.statistics-summary .is-outstanding, .outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; }
.statistics-table > header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.statistics-table h2 { margin: 0; font-size: 16px; }
.statistics-table header span { color: var(--cc-muted); font-size: 13px; }
@media print {
.statistics-query, :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; color: #000; }
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; }
}
</style>
+1 -1
View File
@@ -38,7 +38,7 @@ describe('finance status presentation', () => {
expect(financeBusinessTypeLabel('NEW_BUSINESS')).toBe('未知业务类型')
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('费用审核通过')
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('审核通过')
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
+5 -3
View File
@@ -24,6 +24,8 @@ const financeStatuses: Record<string, FinanceStatusDefinition> = {
BILLED: { label: '已入账', tone: 'success' },
RECORDED: { label: '已登记', tone: 'primary' },
CONFIRMED: { label: '已确认', tone: 'success' },
PENDING_CONFIRMATION: { label: '待客户确认', tone: 'warning' },
NOT_REQUIRED: { label: '无需确认', tone: 'info' },
}
export const financeBillStatusOptions = [
@@ -78,9 +80,9 @@ 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: '排除结算',
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', UPDATE: '更新',
FEE_SOURCE_EDIT: '编辑费用', FEE_SOURCE_SUBMIT: '提交审核', FEE_SOURCE_REVIEW_APPROVE: '审核通过',
FEE_SOURCE_REVIEW_REJECT: '审核驳回', FEE_SOURCE_ADJUST: '调整费用', FEE_SOURCE_VOID: '作废费用',
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
+31 -4
View File
@@ -2,10 +2,11 @@ export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' |
export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export type FinanceReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export type FinanceReviewStatus = 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'
export type FinanceReconciliationStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'ISSUED' | 'VOIDED'
export type FinanceSettlementStatus = 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
export type FinancePaymentStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID'
export type FinanceCustomerConfirmationStatus = 'NOT_REQUIRED' | 'PENDING_CONFIRMATION' | 'CONFIRMED'
export interface FinanceVoucherResource {
fileId: string
@@ -75,7 +76,6 @@ export interface FinanceFeeSourceSummary extends FinanceFeePoolItem {
quantity: number | null
unitPrice: number | null
reviewStatus: FinanceReviewStatus
settlementIncluded: boolean
sourceStatus: string
statementGenerated: boolean
}
@@ -100,6 +100,10 @@ export interface FinanceStatementReadModel {
currency: 'CNY'
status: FinanceBillStatus
version: number
customerConfirmationStatus?: FinanceCustomerConfirmationStatus
customerConfirmedBy?: string
customerConfirmedByName?: string
customerConfirmedAt?: string
settled?: boolean
lastReceiptAt?: string
submittedAt?: string
@@ -184,6 +188,27 @@ export interface FinanceAccountSummary {
lastChangedAt: string
}
export interface ReceivableStatisticsRow {
customerId: string
customerName: string
currency: 'CNY'
totalReceivable: string
totalReceived: string
totalOutstanding: string
}
export interface ReceivableStatisticsResult {
statisticsMonth: string
currency: 'CNY'
totalReceivable: string
totalReceived: string
totalOutstanding: string
totalCustomers: number
page: number
pageSize: number
records: ReceivableStatisticsRow[]
}
export interface FinanceLedgerEntry {
ledgerId: string
occurredAt: string
@@ -201,7 +226,6 @@ export interface FinanceLedgerEntry {
export interface FinanceFeeSourceDetail extends FinanceFeeSourceSummary {
sourceFeeId: string
reviewReason?: string
settlementReason?: string
sourceHash?: string
operationLogs?: Array<{ occurredAt: string; action: string; operatorName: string; result: string; remark?: string }>
operationVersion: number
@@ -230,7 +254,6 @@ export interface FinanceSettlementLine {
amount: number
currency: 'CNY'
reviewStatus: FinanceReviewStatus
settlementIncluded: boolean
sourceStatus: string
statementGenerated: boolean
}
@@ -341,6 +364,10 @@ export interface FinanceBillResource {
outstandingAmount: number
status: FinanceBillStatus
version: number
customerConfirmationStatus?: FinanceCustomerConfirmationStatus
customerConfirmedBy?: string
customerConfirmedByName?: string
customerConfirmedAt?: string
}
export interface FinanceBillLine {
@@ -4,12 +4,15 @@ import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import {
adjustPublishedInboundFee,
confirmInboundFee,
getLatestInboundFee,
sendInboundFee,
} from './api'
import type { InboundFeeSnapshot } from './types'
import InboundFeePanel from './InboundFeePanel.vue'
@@ -20,6 +23,8 @@ vi.mock('./api', async () => {
...actual,
getLatestInboundFee: vi.fn(),
adjustPublishedInboundFee: vi.fn(),
confirmInboundFee: vi.fn(),
sendInboundFee: vi.fn(),
}
})
@@ -52,7 +57,12 @@ function setupPermissions(permissions: string[]) {
return pinia
}
function mountPanel(permissions = ['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) {
function mountPanel(permissions = [
'firstleg:fba-pricing:view',
'firstleg:fba-pricing:adjust',
'firstleg:fba-pricing:confirm',
'firstleg:inbound:send-fee',
]) {
const pinia = setupPermissions(permissions)
return mount(InboundFeePanel, {
attachTo: document.body,
@@ -67,6 +77,8 @@ describe('InboundFeePanel', () => {
document.body.innerHTML = ''
vi.mocked(getLatestInboundFee).mockResolvedValue(snapshot)
vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' })
vi.mocked(confirmInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CONFIRMED', confirmedAt: '2026-08-04T10:01:00', sentAt: null })
vi.mocked(sendInboundFee).mockResolvedValue(snapshot)
})
it('shows the immutable calculation basis, versions, amount, and timestamps', async () => {
@@ -88,6 +100,7 @@ describe('InboundFeePanel', () => {
expect(document.body.textContent).toContain('时间记录')
expect(document.body.textContent).toContain('发布')
expect(document.body.textContent).not.toContain('重新试算')
expect(document.body.textContent).not.toContain('确认费用')
expect(document.body.textContent).not.toContain('发送费用')
})
@@ -201,6 +214,33 @@ describe('InboundFeePanel', () => {
expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true)
})
it('confirms and then sends a calculated fee through the implemented backend transitions', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mountPanel()
await flushPromises()
await wrapper.get('[data-testid="confirm-fee-button"]').trigger('click')
await flushPromises()
expect(confirmInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-confirm-/))
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(true)
await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
await flushPromises()
expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
expect(wrapper.emitted('changed')).toHaveLength(2)
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
})
it('does not expose fee transitions without their independent permissions', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust'])
await flushPromises()
expect(wrapper.find('[data-testid="confirm-fee-button"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
})
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
const wrapper = mountPanel()
+62 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { CircleDollarSign, PencilLine, RefreshCw } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Check, CircleDollarSign, PencilLine, RefreshCw, Send } from '@lucide/vue'
import { firstLegBusinessTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -14,8 +14,11 @@ import {
} from '@/shared/api/http'
import {
adjustPublishedInboundFee,
confirmInboundFee,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
getLatestInboundFee,
sendInboundFee,
} from './api'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
@@ -34,6 +37,7 @@ const emit = defineEmits<{
const access = useAccessPreviewStore()
const loading = ref(false)
const adjusting = ref(false)
const feeAction = ref<'confirm' | 'send' | null>(null)
const notFound = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const adjustmentError = ref<ApiErrorDetails | null>(null)
@@ -65,6 +69,8 @@ const adjustmentRules: FormRules = {
const canView = computed(() => access.can('firstleg:fba-pricing:view'))
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
const customerContextId = computed(() => props.customerId || props.tenantId || '')
const normalizedInboundId = computed(() => {
const identifier = String(props.inboundId ?? '').trim()
@@ -171,6 +177,44 @@ function openAdjustment() {
adjustOpen.value = true
}
async function transitionFee(action: 'confirm' | 'send') {
if (!snapshot.value || feeAction.value) return
const confirming = action === 'confirm'
const expectedStatus = confirming ? 'CALCULATED' : 'CONFIRMED'
if (snapshot.value.feeStatus !== expectedStatus) return
try {
await ElMessageBox.confirm(
confirming
? '确认后费用金额与计费依据将进入已确认状态,下一步需要发送费用。是否继续?'
: '发送后费用将正式发布,并进入客户应收应付费用来源。是否继续?',
confirming ? '确认头程费用' : '发送头程费用',
{
type: confirming ? 'warning' : 'success',
confirmButtonText: confirming ? '确认费用' : '确认发送',
cancelButtonText: '取消',
},
)
} catch {
return
}
feeAction.value = action
adjustmentError.value = null
try {
const key = createFeeActionIdempotencyKey(action)
const result = confirming
? await confirmInboundFee(customerContextId.value, normalizedInboundId.value, key)
: await sendInboundFee(customerContextId.value, normalizedInboundId.value, key)
snapshot.value = result
emit('changed', result)
ElMessage.success(confirming ? '费用已确认,请继续发送费用' : '费用已发送并发布')
} catch (error) {
adjustmentError.value = getApiErrorDetails(error, confirming ? '费用确认失败,请刷新后重试' : '费用发送失败,请刷新后重试')
await loadFee()
} finally {
feeAction.value = null
}
}
function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
return {
expectedFeeVersion: adjustmentForm.expectedFeeVersion,
@@ -261,6 +305,22 @@ defineExpose({ reload: loadFee })
type="primary"
@click="openAdjustment"
><PencilLine :size="16" />调整初算费用</el-button>
<el-button
v-if="canConfirm && snapshot?.feeStatus === 'CALCULATED'"
data-testid="confirm-fee-button"
type="primary"
:loading="feeAction === 'confirm'"
:disabled="Boolean(feeAction)"
@click="transitionFee('confirm')"
><Check :size="16" />确认费用</el-button>
<el-button
v-if="canSend && snapshot?.feeStatus === 'CONFIRMED'"
data-testid="send-fee-button"
type="primary"
:loading="feeAction === 'send'"
:disabled="Boolean(feeAction)"
@click="transitionFee('send')"
><Send :size="16" />发送费用</el-button>
</div>
</header>
+22 -1
View File
@@ -2,13 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
adjustPublishedInboundFee,
confirmInboundFee,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
getLatestInboundFee,
sendInboundFee,
} from './api'
vi.mock('@/shared/api/http', async () => {
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
return { ...actual, http: { get: vi.fn(), put: vi.fn() } }
return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }
})
const snapshot = {
@@ -50,6 +53,22 @@ describe('inbound fee API', () => {
)
})
it('confirms and sends a calculated fee with independent idempotency keys', async () => {
vi.mocked(http.post)
.mockResolvedValueOnce({ code: '200', message: 'success', data: { ...snapshot, feeStatus: 'CONFIRMED' } })
.mockResolvedValueOnce({ code: '200', message: 'success', data: snapshot })
await expect(confirmInboundFee('10001', '88', 'fee-confirm-1')).resolves.toMatchObject({ feeStatus: 'CONFIRMED' })
await expect(sendInboundFee('10001', '88', 'fee-send-1')).resolves.toMatchObject({ feeStatus: 'SENT' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/88/fees/confirm', undefined, {
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-confirm-1' },
})
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/88/fees/send', undefined, {
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-send-1' },
})
})
it('preserves backend conflict metadata for stale-version recovery', async () => {
vi.mocked(http.put).mockResolvedValue({
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
@@ -73,5 +92,7 @@ describe('inbound fee API', () => {
expect(first).toMatch(/^fee-adjust-/)
expect(first.length).toBeLessThanOrEqual(128)
expect(second).not.toBe(first)
expect(createFeeActionIdempotencyKey('confirm')).toMatch(/^fee-confirm-/)
expect(createFeeActionIdempotencyKey('send')).toMatch(/^fee-send-/)
})
})
+28 -2
View File
@@ -15,6 +15,28 @@ export async function getLatestInboundFee(tenantId: string, inboundId: string) {
return unwrapApiResponse(response)
}
async function transitionInboundFee(
tenantId: string,
inboundId: string,
action: 'confirm' | 'send',
idempotencyKey: string,
) {
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
`${feeBasePath(inboundId)}/${action}`,
undefined,
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
export function confirmInboundFee(tenantId: string, inboundId: string, idempotencyKey: string) {
return transitionInboundFee(tenantId, inboundId, 'confirm', idempotencyKey)
}
export function sendInboundFee(tenantId: string, inboundId: string, idempotencyKey: string) {
return transitionInboundFee(tenantId, inboundId, 'send', idempotencyKey)
}
export async function adjustPublishedInboundFee(
tenantId: string,
inboundId: string,
@@ -30,7 +52,11 @@ export async function adjustPublishedInboundFee(
}
export function createFeeAdjustmentIdempotencyKey() {
return createFeeActionIdempotencyKey('adjust')
}
export function createFeeActionIdempotencyKey(action: 'adjust' | 'confirm' | 'send') {
const uuid = globalThis.crypto?.randomUUID?.()
if (uuid) return `fee-adjust-${uuid}`
return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
if (uuid) return `fee-${action}-${uuid}`
return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
}
@@ -1,13 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees'
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees'
import { listSuppliers } from '@/modules/suppliers'
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
describe('container fee API', () => {
beforeEach(() => vi.restoreAllMocks())
it('passes accountId and exact controller DTOs', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, loadingPlanId: 9, containerBookingId: 99, feeItemId: 7, feeItemNameSnapshot: '操作费', direction: 'PAYABLE', partnerId: 41, partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1 } })
const values = { feeItemId: '7', direction: 'PAYABLE' as const, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, loadingPlanId: 9, containerBookingId: 99, feeItemId: 7, feeItemNameSnapshot: '操作费', direction: 'PAYABLE', supplierId: 41, supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1 } })
const values = { feeItemId: '7', direction: 'PAYABLE' as const, supplierId: '41', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, remark: null }
await listContainerFees('9', '11'); const created = await createContainerFee('9', '11', values)
await approveContainerFee('9', '11', '3'); await rejectContainerFee('9', '11', '3', '资料不全')
await adjustContainerFee('9', '11', '3', { expectedSourceVersion: 2, businessDate: '2026-08-13', amount: 120 }); await voidContainerFee('9', '11', '3', 3)
@@ -15,13 +18,17 @@ describe('container fee API', () => {
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', values, { params: { accountId: '11' } })
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/adjust', { expectedSourceVersion: 2, businessDate: '2026-08-13', amount: 120 }, { params: { accountId: '11' } })
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/void', { expectedSourceVersion: 3 }, { params: { accountId: '11' } })
expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A' })
expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING' })
})
it('loads only ACTIVE counterparties for payable selection', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { records: [{ id: 41, partnerName: '承运商A' }] } })
await expect(listContainerFeePartnerOptions()).resolves.toEqual([{ id: '41', partnerName: '承运商A' }])
expect(get).toHaveBeenCalledWith('/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } })
it('loads only ACTIVE suppliers in the requested category', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [
{ id: '41', name: '承运商A', status: 'ACTIVE', category: 'TRUCKING' },
{ id: '42', name: '停用承运商', status: 'INACTIVE', category: 'TRUCKING' },
{ id: '43', name: '报关行', status: 'ACTIVE', category: 'CUSTOMS' },
], page: 1, size: 200, total: 3, pages: 1 } as never)
await expect(listContainerFeeSupplierOptions('TRUCKING')).resolves.toEqual([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
expect(listSuppliers).toHaveBeenCalledWith({ status: 'ACTIVE', category: 'TRUCKING', page: 1, size: 200 })
})
it('reads allocation rows from Finance and never calculates them', async () => {
+19 -14
View File
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { listSuppliers, type SupplierCategory } from '@/modules/suppliers'
export type ContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export type ContainerFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
@@ -11,12 +12,12 @@ export interface ContainerFeeResource {
feeItemId: string
feeItemName: string
direction: ContainerFeeDirection
partnerId: string | null
partnerName: string | null
supplierId: string | null
supplierName: string | null
supplierCategory: SupplierCategory | null
businessDate: string
amount: number
currency: 'CNY'
settlementIncluded: boolean
reviewStatus: ContainerFeeReviewStatus
sourceStatus: 'ACTIVE' | 'VOIDED'
sourceVersion: number
@@ -29,12 +30,10 @@ export interface ContainerFeeResource {
export interface ContainerFeeSaveRequest {
feeItemId: string
direction: ContainerFeeDirection
partnerId: string | null
partnerName: string | null
supplierId: string | null
businessDate: string
amount: number
currency: 'CNY'
settlementIncluded: boolean
remark: string | null
}
@@ -47,15 +46,18 @@ export interface ContainerFeeAdjustRequest {
export interface ApplicableFeeItem {
id: string
name: string
feeCode: string
supplierCategory: SupplierCategory | null
warehouseReceivable: boolean
warehousePayable: boolean
pickupReceivable: boolean
pickupPayable: boolean
}
export interface ContainerFeePartnerOption {
export interface ContainerFeeSupplierOption {
id: string
partnerName: string
supplierName: string
category: SupplierCategory
}
export interface ContainerCostAllocation {
@@ -80,6 +82,8 @@ export interface ContainerCostDetail {
const id = (value: unknown) => value == null ? '' : String(value)
const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0
const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
const supplierCategory = (value: unknown): SupplierCategory | null => supplierCategories.includes(value as SupplierCategory) ? value as SupplierCategory : null
const base = (planId: string) => `/platform/v1/loading-plans/${encodeURIComponent(planId)}/fee-details`
const params = (accountId: string) => ({ accountId })
@@ -88,9 +92,10 @@ function normalizeFee(raw: Record<string, unknown>): ContainerFeeResource {
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), containerBookingId: raw.containerBookingId == null ? null : id(raw.containerBookingId),
containerNo: raw.containerNo == null ? null : String(raw.containerNo), feeItemId: id(raw.feeItemId),
feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection,
partnerId: raw.partnerId == null ? null : id(raw.partnerId), partnerName: raw.partnerName == null ? null : String(raw.partnerName),
supplierId: raw.supplierId == null ? null : id(raw.supplierId), supplierName: raw.supplierName == null ? null : String(raw.supplierName),
supplierCategory: supplierCategory(raw.supplierCategory),
businessDate: String(raw.businessDate || ''), amount: numberValue(raw.amount), currency: 'CNY',
settlementIncluded: Boolean(raw.settlementIncluded), reviewStatus: raw.reviewStatus as ContainerFeeReviewStatus,
reviewStatus: raw.reviewStatus as ContainerFeeReviewStatus,
sourceStatus: raw.sourceStatus as ContainerFeeResource['sourceStatus'], sourceVersion: numberValue(raw.sourceVersion),
remark: raw.remark == null ? null : String(raw.remark), reviewerName: raw.reviewerName == null ? null : String(raw.reviewerName),
reviewedAt: raw.reviewedAt == null ? null : String(raw.reviewedAt), reviewReason: raw.reviewReason == null ? null : String(raw.reviewReason),
@@ -126,12 +131,12 @@ export async function voidContainerFee(planId: string, accountId: string, feeId:
export async function listApplicableFeeItems() {
const response = await http.get<ApiEnvelope<{ records: Record<string, unknown>[] }>, ApiEnvelope<{ records: Record<string, unknown>[] }>>('/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } })
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), feeCode: String(raw.feeCode || ''), supplierCategory: supplierCategory(raw.supplierCategory), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
}
export async function listContainerFeePartnerOptions(): Promise<ContainerFeePartnerOption[]> {
const response = await http.get<ApiEnvelope<{ records: Record<string, unknown>[] }>, ApiEnvelope<{ records: Record<string, unknown>[] }>>('/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } })
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), partnerName: String(raw.partnerName || '') }))
export async function listContainerFeeSupplierOptions(category: SupplierCategory): Promise<ContainerFeeSupplierOption[]> {
const page = await listSuppliers({ status: 'ACTIVE', category, page: 1, size: 200 })
return page.records.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === category).map((supplier) => ({ id: supplier.id, supplierName: supplier.name, category: supplier.category }))
}
export async function listContainerCostDetails(customerId: string, containerId: string): Promise<ContainerCostDetail[]> {
@@ -6,12 +6,12 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees } from '../api/containerFees'
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees } from '../api/containerFees'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
vi.mock('../api/containerFees', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/containerFees')>()
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeePartnerOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
})
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
@@ -30,7 +30,7 @@ async function mountPanel(permissions: string[], customers = [{ accountId: '11',
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage())
vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([])
vi.mocked(listContainerFeePartnerOptions).mockReset().mockResolvedValue([])
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
document.body.innerHTML = ''
@@ -61,12 +61,12 @@ describe('ContainerFeesPanel contract', () => {
expect(panel).toContain('voidContainerFee')
expect(panel).toContain('调整仅允许修改业务日期和金额')
expect(panel).toContain('请先作废后新增')
expect(panel).toContain("row.partnerName || '服务端未返回'")
expect(panel).toContain("row.supplierName || '服务端未返回'")
})
it('adjusts only business date and amount while preserving the approved snapshot', async () => {
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
const item = { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '拖车费', direction: 'PAYABLE' as const, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, reviewStatus: 'APPROVED' as const, sourceStatus: 'ACTIVE' as const, sourceVersion: 3, remark: '原备注', reviewerName: null, reviewedAt: null, reviewReason: null }
const item = { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '拖车费', direction: 'PAYABLE' as const, supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING' as const, businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, reviewStatus: 'APPROVED' as const, sourceStatus: 'ACTIVE' as const, sourceVersion: 3, remark: '原备注', reviewerName: null, reviewedAt: null, reviewReason: null }
const state = (wrapper.vm.$ as unknown as { setupState: { openAdjust(value: typeof item): void; save(): Promise<void>; form: { businessDate: string; amount: number } } }).setupState
state.openAdjust(item); await flushPromises()
state.form.businessDate = '2026-08-13'; state.form.amount = 120
@@ -93,7 +93,7 @@ describe('ContainerFeesPanel contract', () => {
state.accountId = '11'; await flushPromises(); state.editorOpen = true
state.accountId = '22'; await flushPromises()
expect(state.fees).toEqual([]); expect(state.costs).toEqual([]); expect(state.editorOpen).toBe(false)
resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
await flushPromises()
expect(state.fees).toEqual([])
expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything())
@@ -107,7 +107,7 @@ describe('ContainerFeesPanel contract', () => {
it('uses the summary container ID when fee rows agree', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '费用A', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '费用A', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
await mountPanel(['firstleg:inbound:fee-detail:view'])
expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99')
@@ -115,7 +115,7 @@ describe('ContainerFeesPanel contract', () => {
it('fails closed when fee rows disagree with the summary container', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '2', loadingPlanId: '9', containerBookingId: '100', containerNo: null, feeItemId: '2', feeItemName: '费用B', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
{ id: '2', loadingPlanId: '9', containerBookingId: '100', containerNo: null, feeItemId: '2', feeItemName: '费用B', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 20, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:fee-item:view'])
expect(wrapper.text()).toContain('柜次费用加载失败')
@@ -132,46 +132,70 @@ describe('ContainerFeesPanel contract', () => {
it('hides all mutation buttons when permissions are missing', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 20, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
})
it('keeps receivable usable when counterparty permission is missing', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
it('keeps receivable usable when supplier permission is missing', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; partnerId: string | null; partnerName: string | null } } }).setupState
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; supplierId: string | null } } }).setupState
state.openCreate(); await flushPromises()
expect(wrapper.text()).toContain('当前账号缺少往来单位查看权限,仅可维护应收费用')
expect(wrapper.text()).toContain('当前账号缺少供应商查看权限,仅可维护应收费用')
expect(listApplicableFeeItems).toHaveBeenCalledTimes(1)
expect(listContainerFeePartnerOptions).not.toHaveBeenCalled()
expect(listContainerFeeSupplierOptions).not.toHaveBeenCalled()
expect(state.form.direction).toBe('RECEIVABLE')
})
it('isolates a counterparty load failure and clears partner fields for receivable', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeePartnerOptions).mockRejectedValue(new Error('partners unavailable'))
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; partnerId: string | null; partnerName: string | null }; feeItems: unknown[] } }).setupState
it('isolates a supplier load failure and clears supplier fields for receivable', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockRejectedValue(new Error('suppliers unavailable'))
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; feeItemId: string; supplierId: string | null }; feeItems: unknown[] } }).setupState
state.openCreate(); await flushPromises()
expect(state.feeItems).toHaveLength(1)
expect(wrapper.text()).toContain('往来单位加载失败')
state.form.direction = 'PAYABLE'; await flushPromises()
state.form.partnerId = '41'; state.form.partnerName = '旧快照'; state.form.direction = 'RECEIVABLE'; await flushPromises()
expect(state.form.partnerId).toBeNull(); expect(state.form.partnerName).toBeNull()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises()
expect(wrapper.text()).toContain('供应商加载失败')
state.form.supplierId = '41'; state.form.direction = 'RECEIVABLE'; await flushPromises()
expect(state.form.supplierId).toBeNull()
})
it('submits the selected ACTIVE counterparty snapshot for payable fees', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '承运商A' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; partnerId: string | null; amount: number } } }).setupState
it('submits only the selected supplier ID for payable fees', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
state.openCreate(); await flushPromises()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; state.form.partnerId = '41'; state.form.amount = 100; await flushPromises()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100
await state.save()
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A' }))
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' }))
expect(createContainerFee).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ partnerId: expect.anything() }))
})
it('fails closed when the payable fee item has no supplier category', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
state.openCreate(); await flushPromises()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; state.form.supplierId = '41'; state.form.amount = 100; await flushPromises()
await state.save(); await flushPromises()
expect(createContainerFee).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('该应付费用项目未配置供应商类别,已阻断保存')
})
it('fails closed when a supplier candidate does not match the fee-item category', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
state.openCreate(); await flushPromises()
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100
await state.save(); await flushPromises()
expect(createContainerFee).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('所选供应商与费用项目要求的供应商类别不匹配')
})
it('requires fee-item permission before opening a write form', async () => {
@@ -9,36 +9,38 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeePartnerOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
const access = useAccessPreviewStore()
const accountId = ref('')
const fees = ref<ContainerFeeResource[]>([])
const feeItems = ref<ApplicableFeeItem[]>([])
const partners = ref<ContainerFeePartnerOption[]>([])
const suppliers = ref<ContainerFeeSupplierOption[]>([])
const costs = ref<ContainerCostDetail[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const feeItemsError = ref<ApiErrorDetails | null>(null)
const partnersError = ref<ApiErrorDetails | null>(null)
const suppliersError = ref<ApiErrorDetails | null>(null)
const feeItemsLoading = ref(false)
const partnersLoading = ref(false)
const suppliersLoading = ref(false)
const loadedSupplierCategory = ref<string | null>(null)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const rejectOpen = ref(false)
const saving = ref(false)
const target = ref<ContainerFeeResource | null>(null)
const formRef = ref<FormInstance>()
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', supplierId: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
const rejection = ref('')
let loadSequence = 0
let supplierLoadSequence = 0
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit'))
const canReview = computed(() => access.can('firstleg:inbound:fee-detail:review'))
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
const canViewPartners = computed(() => access.can('platform:counterparty:list'))
const canViewSuppliers = computed(() => access.can('platform:supplier:list'))
const customerOptions = computed(() => {
const unique = new Map<string, string>()
for (const item of props.customers) if (item.accountId) unique.set(item.accountId, item.customerName || '客户信息缺失')
@@ -47,16 +49,16 @@ const customerOptions = computed(() => {
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
const selectedFeeItem = computed(() => feeItems.value.find((item) => item.id === form.feeItemId))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 270, slotName: 'actions', hideable: false, movable: false },
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
{ key: 'partner', label: '往来单位/客户', minWidth: 170, slotName: 'partner' },
{ key: 'supplier', label: '供应商/客户', minWidth: 170, slotName: 'supplier' },
{ key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' },
{ key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 },
{ key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' },
{ key: 'sourceStatus', label: '来源状态', width: 100, slotName: 'sourceStatus' },
{ key: 'settlementIncluded', label: '参与结算', width: 100, slotName: 'settlementIncluded' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
]
const allocationColumns: DataTableColumn[] = [
@@ -70,8 +72,8 @@ const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费
function resetForm(item?: ContainerFeeResource) {
target.value = item || null
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, partnerId: item.partnerId, partnerName: item.partnerName, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', settlementIncluded: item.settlementIncluded, remark: item.remark }
: { feeItemId: '', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, supplierId: item.supplierId, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', remark: item.remark }
: { feeItemId: '', direction: 'RECEIVABLE', supplierId: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
}
async function loadFeeItems() {
@@ -83,13 +85,21 @@ async function loadFeeItems() {
finally { feeItemsLoading.value = false }
}
async function loadPartners() {
partners.value = []; partnersError.value = null
if (!canViewPartners.value) return
partnersLoading.value = true
try { partners.value = await listContainerFeePartnerOptions() }
catch (cause) { partnersError.value = getApiErrorDetails(cause, '往来单位加载失败') }
finally { partnersLoading.value = false }
async function loadSuppliers() {
const sequence = ++supplierLoadSequence
suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null
const category = selectedFeeItem.value?.supplierCategory
if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !category) return
suppliersLoading.value = true
try {
const result = await listContainerFeeSupplierOptions(category)
if (sequence === supplierLoadSequence && form.direction === 'PAYABLE' && selectedFeeItem.value?.supplierCategory === category) {
suppliers.value = result
loadedSupplierCategory.value = category
}
}
catch (cause) { if (sequence === supplierLoadSequence) suppliersError.value = getApiErrorDetails(cause, '供应商加载失败') }
finally { if (sequence === supplierLoadSequence) suppliersLoading.value = false }
}
async function load() {
@@ -114,11 +124,11 @@ async function load() {
finally { if (sequence === loadSequence) loading.value = false }
}
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
async function save() {
if (!canEdit.value || !accountId.value || (!target.value && !canViewFeeItems.value)) return
if (!target.value && form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return }
if (!target.value && form.direction === 'PAYABLE' && !canViewSuppliers.value) { ElMessage.warning('当前账号缺少供应商查看权限,无法保存应付费用'); return }
if (!await formRef.value?.validate().catch(() => false)) return
editorError.value = null
if (target.value) {
@@ -138,9 +148,13 @@ async function save() {
} finally { saving.value = false }
return
}
const partner = partners.value.find((item) => item.id === String(form.partnerId))
const payload: ContainerFeeSaveRequest = { ...form, partnerId: form.direction === 'PAYABLE' ? form.partnerId : null, partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || null : null }
if (form.direction === 'PAYABLE' && !payload.partnerName) { ElMessage.warning('请选择有效的往来单位'); return }
if (form.direction === 'PAYABLE' && !selectedFeeItem.value?.supplierCategory) { ElMessage.warning('该应付费用项目未配置供应商类别,已阻断保存'); return }
if (form.direction === 'PAYABLE' && suppliersError.value) { ElMessage.warning('供应商列表加载失败,无法校验应付费用供应商'); return }
if (form.direction === 'PAYABLE' && loadedSupplierCategory.value !== selectedFeeItem.value?.supplierCategory) { ElMessage.warning('供应商候选尚未按费用项目类别加载完成,无法保存'); return }
if (form.direction === 'PAYABLE' && !form.supplierId) { ElMessage.warning('应付费用必须选择供应商'); return }
const supplier = suppliers.value.find((item) => item.id === String(form.supplierId))
if (form.direction === 'PAYABLE' && (!supplier || supplier.category !== selectedFeeItem.value?.supplierCategory)) { ElMessage.warning('所选供应商与费用项目要求的供应商类别不匹配'); return }
const payload: ContainerFeeSaveRequest = { ...form, supplierId: form.direction === 'PAYABLE' ? form.supplierId : null }
saving.value = true
try {
await createContainerFee(props.planId, accountId.value, payload)
@@ -166,7 +180,8 @@ function money(value: number) { return value.toLocaleString('zh-CN', { minimumFr
function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' }
function clearCustomerContext() {
loadSequence += 1
fees.value = []; costs.value = []; feeItems.value = []; partners.value = []; error.value = null; feeItemsError.value = null; partnersError.value = null; loading.value = false; feeItemsLoading.value = false; partnersLoading.value = false
supplierLoadSequence += 1
fees.value = []; costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = null
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
resetForm()
}
@@ -174,8 +189,10 @@ watch(customerOptions, initializeCustomer, { immediate: true })
watch(accountId, () => { clearCustomerContext(); void load() })
watch(() => form.direction, (direction) => {
if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = ''
if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; suppliersLoading.value = false }
else void loadSuppliers()
})
watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() })
onMounted(() => void load())
</script>
@@ -198,40 +215,38 @@ onMounted(() => void load())
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
<template #partner="{ row }">{{ row.direction === 'PAYABLE' ? row.partnerName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template>
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template><template #settlementIncluded="{ row }">{{ row.settlementIncluded ? '是' : '否' }}</template>
<template #supplier="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template>
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
</DataTable>
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>往来单位:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
</template>
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
<div v-if="target" class="adjust-snapshot">
<p>调整仅允许修改业务日期和金额;如需变更方向、费用项目、往来单位或其他信息,请先作废后新增。</p>
<p>调整仅允许修改业务日期和金额;如需变更方向、费用项目、供应商或其他信息,请先作废后新增。</p>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="方向">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item>
<el-descriptions-item label="费用项目">{{ target.feeItemName }}</el-descriptions-item>
<el-descriptions-item label="往来单位">{{ target.partnerName || '—' }}</el-descriptions-item>
<el-descriptions-item label="供应商">{{ target.supplierName || '—' }}</el-descriptions-item>
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
<el-descriptions-item label="参与结算">{{ target.settlementIncluded ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
</el-descriptions>
</div>
<template v-else>
<el-alert v-if="!canViewPartners" type="warning" :closable="false" show-icon title="当前账号缺少往来单位查看权限,仅可维护应收费用" />
<el-alert v-else-if="partnersError" type="warning" :closable="false" show-icon :title="partnersError.message" />
<el-alert v-if="!canViewSuppliers" type="warning" :closable="false" show-icon title="当前账号缺少供应商查看权限,仅可维护应收费用" />
<el-alert v-else-if="suppliersError" type="warning" :closable="false" show-icon :title="suppliersError.message" />
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form">
<template v-if="!target">
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewPartners || !!partnersError }]" /></el-form-item>
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item>
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners || !!partnersError" placeholder="请选择启用的往来单位"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="供应商" required><el-select v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem?.supplierCategory || !!suppliersError" placeholder="请先选择已配置供应商类别的费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item>
</template>
<el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item>
<template v-if="!target">
<el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</template>
</el-form>
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
listInboundFeeItemOptions, listInboundFeeSupplierOptions, reviewInboundFeeDetail,
updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
} from './inboundFeeDetails'
vi.mock('@/shared/api/http', async () => {
@@ -18,7 +19,7 @@ describe('inbound fee-detail API', () => {
})
it('maps the complete write contract and optimistic version', async () => {
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, settlementIncluded: true, partnerId: '41', partnerName: '测试供应商', remark: '验收' }
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, supplierId: '41', remark: '验收' }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: payload })
@@ -32,4 +33,31 @@ describe('inbound fee-detail API', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11/void', { expectedSourceVersion: 3 }, { params: { accountId: '100' } })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11', { params: { accountId: '100' } })
})
it('fails closed when a fee item has no recognized supplier category', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [
{ id: 31, feeTypeName: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehousePayable: true },
{ id: 32, feeTypeName: '未知费用', feeCode: 'UNMAPPED', supplierCategory: 'UNKNOWN', warehousePayable: true },
{ id: 33, feeTypeName: '缺少类别', feeCode: 'MISSING', warehousePayable: true },
] } })
await expect(listInboundFeeItemOptions()).resolves.toMatchObject([
{ id: 31, feeCode: 'TRUCKING', supplierCategory: 'TRUCKING' },
{ id: 32, feeCode: 'UNMAPPED', supplierCategory: null },
{ id: 33, feeCode: 'MISSING', supplierCategory: null },
])
})
it('requests and retains only ACTIVE suppliers in the required category', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [
{ id: 41, tenantId: 0, name: '承运商A', category: 'TRUCKING', status: 'ACTIVE', version: 1, deleted: false },
{ id: 42, tenantId: 0, name: '停用承运商', category: 'TRUCKING', status: 'INACTIVE', version: 1, deleted: false },
{ id: 43, tenantId: 0, name: '报关行', category: 'CUSTOMS', status: 'ACTIVE', version: 1, deleted: false },
], page: 1, size: 200, total: 3, pages: 1 } })
await expect(listInboundFeeSupplierOptions('TRUCKING')).resolves.toEqual([
{ id: '41', supplierName: '承运商A', category: 'TRUCKING' },
])
expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers', { params: {
page: 1, size: 200, category: 'TRUCKING', region: undefined, status: 'ACTIVE', keyword: undefined,
} })
})
})
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { listSuppliers, type SupplierCategory } from '@/modules/suppliers'
export type InboundFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export type InboundFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
@@ -11,8 +12,9 @@ export interface InboundFeeDetail {
sourceFeeVersion: number
inboundCode: string | null
direction: InboundFeeDirection
partnerId: string | number | null
partnerName: string | null
supplierId: string | number | null
supplierName: string | null
supplierCategory: SupplierCategory | null
businessDate: string
customerName: string | null
feeTypeNameSnapshot: string
@@ -25,7 +27,6 @@ export interface InboundFeeDetail {
reviewerName: string | null
reviewedAt: string | null
publicationStatus: string
settlementIncluded: boolean
}
export interface SaveInboundFeeDetailRequest {
@@ -35,20 +36,26 @@ export interface SaveInboundFeeDetailRequest {
unitPrice: number
currency: string
amount: number
settlementIncluded: boolean
partnerId: string | number | null
partnerName: string | null
supplierId: string | number | null
remark: string | null
}
export interface InboundFeeItemOption {
id: string | number
feeTypeName: string
feeCode: string
supplierCategory: SupplierCategory | null
warehousePayable: boolean
warehouseReceivable: boolean
}
export interface InboundFeePartnerOption { id: string | number; partnerName: string }
export interface InboundFeeSupplierOption { id: string; supplierName: string; category: SupplierCategory }
const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
function supplierCategory(value: unknown): SupplierCategory | null {
return supplierCategories.includes(value as SupplierCategory) ? value as SupplierCategory : null
}
function basePath(inboundId: string) {
const identifier = String(inboundId ?? '').trim()
@@ -95,15 +102,22 @@ export async function voidInboundFeeDetail(accountId: string, inboundId: string,
}
export async function listInboundFeeItemOptions() {
const response = await http.get<unknown, ApiEnvelope<{ records: InboundFeeItemOption[] }>>(
const response = await http.get<unknown, ApiEnvelope<{ records: Record<string, unknown>[] }>>(
'/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } },
)
return unwrapApiResponse(response).records || []
return (unwrapApiResponse(response).records || []).map((raw): InboundFeeItemOption => ({
id: raw.id as string | number,
feeTypeName: String(raw.feeTypeName || ''),
feeCode: String(raw.feeCode || ''),
supplierCategory: supplierCategory(raw.supplierCategory),
warehousePayable: Boolean(raw.warehousePayable),
warehouseReceivable: Boolean(raw.warehouseReceivable),
}))
}
export async function listInboundFeePartnerOptions() {
const response = await http.get<unknown, ApiEnvelope<{ records: InboundFeePartnerOption[] }>>(
'/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } },
)
return unwrapApiResponse(response).records || []
export async function listInboundFeeSupplierOptions(category: SupplierCategory) {
const page = await listSuppliers({ status: 'ACTIVE', category, page: 1, size: 200 })
return page.records.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === category).map((supplier): InboundFeeSupplierOption => ({
id: supplier.id, supplierName: supplier.name, category: supplier.category,
}))
}
@@ -255,7 +255,6 @@ export interface UnifiedOrderFeeDetailResource {
inboundedAt: string | null
outboundAt?: string | null
storageDays?: number | null
settlementIncluded: boolean | null
billNo?: string | null
settlementStartDate?: string | null
settlementEndDate?: string | null
@@ -9,24 +9,24 @@ import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import InboundFeeDetailManager from './InboundFeeDetailManager.vue'
import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails'
vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({
listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeePartnerOptions: vi.fn(),
listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeeSupplierOptions: vi.fn(),
createInboundFeeDetail: vi.fn(), updateInboundFeeDetail: vi.fn(), deleteInboundFeeDetail: vi.fn(),
reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), voidInboundFeeDetail: vi.fn(),
}))
const pending: InboundFeeDetail = {
id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1,
inboundCode: 'RK-88', direction: 'RECEIVABLE', partnerId: null, partnerName: null,
inboundCode: 'RK-88', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null,
businessDate: '2026-08-12', customerName: '测试客户', feeTypeNameSnapshot: '操作费',
unitPrice: 10, currency: 'CNY', amount: 100, remark: 'marker', reviewStatus: 'PENDING',
reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT', settlementIncluded: true,
reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT',
}
const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' }
const source = readFileSync(join(process.cwd(), 'src/modules/unified-orders/components/InboundFeeDetailManager.vue'), 'utf8')
@@ -44,8 +44,8 @@ describe('InboundFeeDetailManager', () => {
beforeEach(() => {
vi.clearAllMocks(); document.body.innerHTML = ''
vi.mocked(listInboundFeeDetails).mockResolvedValue([pending, approved])
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', warehouseReceivable: true, warehousePayable: true }])
vi.mocked(listInboundFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '测试往来单位' }])
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true }])
vi.mocked(listInboundFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '测试供应商', category: 'TRUCKING' }])
vi.mocked(createInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' })
@@ -144,14 +144,14 @@ describe('InboundFeeDetailManager', () => {
expect(source).toContain('@keyframes fee-refresh-spin')
})
it('loads RECEIVABLE fee items without requesting counterparties', async () => {
it('loads RECEIVABLE fee items without requesting suppliers', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
await flushPromises()
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
expect(listInboundFeePartnerOptions).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('缺少往来单位查看权限')
expect(listInboundFeeSupplierOptions).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('缺少供应商查看权限')
expect(document.body.textContent).toContain('应收费用不受影响')
})
@@ -162,25 +162,41 @@ describe('InboundFeeDetailManager', () => {
expect(listInboundFeeItemOptions).not.toHaveBeenCalled()
})
it('keeps fee items usable when the independent counterparty request fails', async () => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', partnerId: '41', partnerName: '测试往来单位' }])
vi.mocked(listInboundFeePartnerOptions).mockRejectedValue(new ApiResponseError('往来单位不可用', 'A0403', 403, null, 'request-partner', 'trace-partner'))
it('keeps fee items usable but blocks payable save when the supplier request fails', async () => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', supplierCategory: 'TRUCKING' }])
vi.mocked(listInboundFeeSupplierOptions).mockRejectedValue(new ApiResponseError('供应商不可用', 'A0403', 403, null, 'request-supplier', 'trace-supplier'))
const wrapper = mountManager([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
'firstleg:fee-item:view', 'platform:counterparty:list',
'firstleg:fee-item:view', 'platform:supplier:list',
])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('编辑'))?.trigger('click')
await flushPromises()
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
expect(listInboundFeePartnerOptions).toHaveBeenCalledTimes(1)
expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith('TRUCKING')
expect(document.body.textContent).toContain('当前账号无权访问该数据')
expect(document.body.textContent).toContain('A0403')
expect(document.body.textContent).toContain('request-partner')
expect(document.body.textContent).toContain('trace-partner')
expect(document.body.textContent).toContain('request-supplier')
expect(document.body.textContent).toContain('trace-supplier')
expect(document.querySelector('.fee-form .el-select')?.classList.contains('is-disabled')).toBe(false)
})
it('fails closed when a payable fee item has no supplier category', async () => {
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true }])
const wrapper = mountManager([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
'firstleg:fee-item:view', 'platform:supplier:list',
])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; state.form.supplierId = '41'; state.form.amount = 100; await flushPromises()
await state.save(); await flushPromises()
expect(createInboundFeeDetail).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('该应付费用项目未配置供应商类别,已阻断保存')
})
it('refreshes fee rows and the order detail after every successful write', () => {
expect(source).toContain("async function finishAction(message: string) {\n await load(); emit('changed')")
expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/)
@@ -10,12 +10,12 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import type {
InboundFeeDetail, InboundFeeItemOption,
InboundFeePartnerOption, SaveInboundFeeDetailRequest,
InboundFeeSupplierOption, SaveInboundFeeDetailRequest,
} from '@/modules/unified-orders/api/inboundFeeDetails'
const props = defineProps<{ accountId: string; inboundId: string }>()
@@ -24,12 +24,12 @@ const emit = defineEmits<{ changed: []; summary: [value: OtherFeeSummary] }>()
const access = useAccessPreviewStore()
const rows = ref<InboundFeeDetail[]>([])
const feeItems = ref<InboundFeeItemOption[]>([])
const partners = ref<InboundFeePartnerOption[]>([])
const suppliers = ref<InboundFeeSupplierOption[]>([])
const feeItemsLoading = ref(false)
const partnersLoading = ref(false)
const suppliersLoading = ref(false)
const loadedSupplierCategory = ref<string | null>(null)
const feeItemsError = ref<ApiErrorDetails | null>(null)
const partnersError = ref<ApiErrorDetails | null>(null)
const partnersLoadAttempted = ref(false)
const suppliersError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -40,11 +40,12 @@ const target = ref<InboundFeeDetail | null>(null)
const formRef = ref<FormInstance>()
const rejectOpen = ref(false)
const rejectReason = ref('')
let supplierLoadSequence = 0
const emptyForm = (): SaveInboundFeeDetailRequest => ({
feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 0, currency: 'CNY', amount: 0, settlementIncluded: true,
partnerId: null, partnerName: null, remark: null,
unitPrice: 0, currency: 'CNY', amount: 0,
supplierId: null, remark: null,
})
const form = reactive<SaveInboundFeeDetailRequest>(emptyForm())
const rules: FormRules = {
@@ -60,12 +61,13 @@ const normalizedInboundId = computed(() => {
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
const canViewPartners = computed(() => access.can('platform:counterparty:list'))
const canViewSuppliers = computed(() => access.can('platform:supplier:list'))
const directionOptions = computed(() => [
{ label: '应收', value: 'RECEIVABLE' },
{ label: '应付', value: 'PAYABLE', disabled: !canViewPartners.value },
{ label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers.value },
])
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
const selectedFeeItem = computed(() => feeItems.value.find((item) => String(item.id) === String(form.feeItemId)))
function isManual(row: InboundFeeDetail) { return row.sourceType === 'MANUAL' }
function canEdit(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'PENDING' }
@@ -84,7 +86,7 @@ async function load() {
loading.value = true; loadError.value = null
try {
rows.value = await listInboundFeeDetails(props.accountId, normalizedInboundId.value)
const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' && row.settlementIncluded)
const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED')
emit('summary', {
currency: approved.find((row) => row.currency)?.currency || 'CNY',
approvedReceivable: approved.filter((row) => row.direction === 'RECEIVABLE').reduce((sum, row) => sum + Number(row.amount || 0), 0),
@@ -103,37 +105,42 @@ async function loadFeeItems() {
finally { feeItemsLoading.value = false }
}
async function loadPartners() {
if (!canViewPartners.value || partnersLoading.value || partners.value.length || partnersLoadAttempted.value) return
partnersLoadAttempted.value = true
partnersLoading.value = true; partnersError.value = null
try { partners.value = await listInboundFeePartnerOptions() }
catch (error) { partnersError.value = getApiErrorDetails(error, '往来单位加载失败') }
finally { partnersLoading.value = false }
async function loadSuppliers() {
const sequence = ++supplierLoadSequence
const item = selectedFeeItem.value
suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null
if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !item?.supplierCategory) return
const requestedCategory = item.supplierCategory
suppliersLoading.value = true
try {
const result = await listInboundFeeSupplierOptions(requestedCategory)
if (sequence === supplierLoadSequence && form.direction === 'PAYABLE' && selectedFeeItem.value?.supplierCategory === requestedCategory) {
suppliers.value = result
loadedSupplierCategory.value = requestedCategory
}
}
catch (error) { if (sequence === supplierLoadSequence) suppliersError.value = getApiErrorDetails(error, '供应商加载失败') }
finally { if (sequence === supplierLoadSequence) suppliersLoading.value = false }
}
function assignForm(row?: InboundFeeDetail) {
Object.assign(form, row ? {
feeItemId: row.feeItemId, direction: row.direction, businessDate: row.businessDate,
unitPrice: Number(row.unitPrice), currency: row.currency, amount: Number(row.amount),
settlementIncluded: Boolean(row.settlementIncluded), partnerId: row.partnerId,
partnerName: row.partnerName, remark: row.remark,
supplierId: row.supplierId, remark: row.remark,
} : emptyForm())
}
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
partnersLoadAttempted.value = false
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
feeItemsError.value = null; partnersError.value = null
feeItemsError.value = null; suppliersError.value = null
editorOpen.value = true
if (!feeItems.value.length) await loadFeeItems()
if (form.direction === 'PAYABLE') await loadPartners()
if (form.direction === 'PAYABLE') await loadSuppliers()
}
function normalizePayload(): SaveInboundFeeDetailRequest {
const partner = partners.value.find((item) => String(item.id) === String(form.partnerId))
return { ...form, currency: 'CNY', partnerId: form.direction === 'PAYABLE' ? form.partnerId : null,
partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || form.partnerName : null,
return { ...form, currency: 'CNY', supplierId: form.direction === 'PAYABLE' ? form.supplierId : null,
remark: form.remark?.trim() || null }
}
@@ -144,8 +151,14 @@ async function finishAction(message: string) {
async function save() {
if (!await formRef.value?.validate()) return
if (!canViewFeeItems.value) { actionError.value = { message: '当前账号缺少费用项目查看权限,无法保存费用' }; return }
if (form.direction === 'PAYABLE' && !canViewPartners.value) { actionError.value = { message: '当前账号缺少往来单位查看权限,无法维护应付费用' }; return }
if (form.direction === 'PAYABLE' && !form.partnerId) { actionError.value = { message: '应付费用必须选择往来单位' }; return }
const selectedItem = feeItems.value.find((item) => String(item.id) === String(form.feeItemId))
if (form.direction === 'PAYABLE' && !canViewSuppliers.value) { actionError.value = { message: '当前账号缺少供应商查看权限,无法维护应付费用' }; return }
if (form.direction === 'PAYABLE' && !selectedItem?.supplierCategory) { actionError.value = { message: '该应付费用项目未配置供应商类别,已阻断保存' }; return }
if (form.direction === 'PAYABLE' && suppliersError.value) { actionError.value = { message: '供应商列表加载失败,无法校验应付费用供应商' }; return }
if (form.direction === 'PAYABLE' && loadedSupplierCategory.value !== selectedItem?.supplierCategory) { actionError.value = { message: '供应商候选尚未按费用项目类别加载完成,无法保存' }; return }
if (form.direction === 'PAYABLE' && !form.supplierId) { actionError.value = { message: '应付费用必须选择供应商' }; return }
const selectedSupplier = suppliers.value.find((supplier) => supplier.id === String(form.supplierId))
if (form.direction === 'PAYABLE' && (!selectedSupplier || selectedSupplier.category !== selectedItem?.supplierCategory)) { actionError.value = { message: '所选供应商与费用项目要求的供应商类别不匹配' }; return }
saving.value = true; actionError.value = null
try {
const payload = normalizePayload()
@@ -188,9 +201,10 @@ async function voidRow(row: InboundFeeDetail) {
watch(() => [props.accountId, normalizedInboundId.value], () => void load())
watch(() => form.direction, (direction) => {
if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
else void loadPartners()
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; suppliersLoading.value = false }
else void loadSuppliers()
})
watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() })
onMounted(() => void load())
</script>
@@ -228,12 +242,11 @@ onMounted(() => void load())
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
<el-table-column label="应收/应付" width="110"><template #default="{ row }">{{ directionLabel(row.direction) }}</template></el-table-column>
<el-table-column prop="feeTypeNameSnapshot" label="费用类型" min-width="150" />
<el-table-column label="往来单位/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.partnerName : row.customerName || '—' }}</template></el-table-column>
<el-table-column label="供应商/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName : row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="130" />
<el-table-column prop="currency" label="货币" width="90" />
<el-table-column label="金额" min-width="120"><template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template></el-table-column>
<el-table-column label="单价" min-width="110"><template #default="{ row }">{{ Number(row.unitPrice).toFixed(4) }}</template></el-table-column>
<el-table-column label="参与结算" width="110"><template #default="{ row }">{{ row.settlementIncluded ? '是' : '否' }}</template></el-table-column>
<el-table-column prop="sourceFeeVersion" label="版本" width="85" />
<el-table-column prop="publicationStatus" label="发布状态" min-width="110" />
<el-table-column prop="reviewReason" label="驳回原因" min-width="180" show-overflow-tooltip />
@@ -245,19 +258,18 @@ onMounted(() => void load())
<AppDialog v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewPartners" title="当前账号缺少往来单位查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewSuppliers" title="当前账号缺少供应商查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
<el-alert v-if="partnersError" type="error" :closable="true" @close="partnersError = null"><template #title>{{ partnersError.message }}</template><ApiErrorMeta :details="partnersError" /></el-alert>
<el-alert v-if="suppliersError" type="error" :closable="true" @close="suppliersError = null"><template #title>{{ suppliersError.message }}</template><ApiErrorMeta :details="suppliersError" /></el-alert>
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form">
<el-form-item label="应收/应付" prop="direction"><el-segmented v-model="form.direction" :options="directionOptions" /></el-form-item>
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems"><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="供应商" required><el-select v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem?.supplierCategory || !!suppliersError" placeholder="请先选择已配置供应商类别的费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="业务发生日" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="货币" prop="currency"><el-input model-value="CNY" readonly aria-label="货币 CNY" /></el-form-item>
<el-form-item label="单价" prop="unitPrice"><el-input-number v-model="form.unitPrice" :min="0" :precision="4" /></el-form-item>
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" /></el-form-item>
<el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
</AppDialog>