From c93eea89c8c916fbfa1013862fec1754f9171577 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 20 Aug 2026 16:13:55 +0800
Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../pages/FinanceCustomerArapView.test.ts | 20 +--
.../finance/pages/FinanceCustomerArapView.vue | 54 +++---
.../FinanceCustomerStatementsView.test.ts | 43 ++++-
.../pages/FinanceCustomerStatementsView.vue | 155 +++++++++++++++++-
4 files changed, 226 insertions(+), 46 deletions(-)
diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts
index 1c188656..e41b5bf1 100644
--- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts
+++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts
@@ -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 { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
+import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import type { FinanceFeeSourceDetail, FinanceFeeSourceSummary } from '../types'
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
@@ -23,7 +23,6 @@ vi.mock('../api/finance', () => ({
getFinanceFeeSource: vi.fn(),
reviewFinanceFeeSource: vi.fn(),
editFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(),
- createReconciliationStatement: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
@@ -62,6 +61,7 @@ async function mountView(permissions: string[], initialPath = '/finance/customer
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
+ { path: '/finance/customer-statements', name: 'finance-customer-statements', component: { template: '
客户对账单
' } },
] })
await router.push(initialPath); await router.isReady()
const wrapper = mount(FinanceCustomerArapView, {
@@ -96,7 +96,6 @@ describe('FinanceCustomerArapView G2 operations', () => {
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)
})
@@ -254,18 +253,19 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.text()).not.toContain(customerName)
})
- it('creates a reconciliation draft from selected eligible receivables', async () => {
+ it('continues selected eligible receivables in the customer statement generation center', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:reconciliation:create'])
await loadRows(wrapper)
const vm = wrapper.vm as unknown as {
selectedRows: Array>
- generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string }
- submitGeneration(): Promise
+ continueSelectedGeneration(): Promise
}
- 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: '八月费用' })
+ vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', status: 'AVAILABLE', statementGenerated: false, amount: 100, businessDate: '2026-08-11' }]
+ await vm.continueSelectedGeneration()
+ expect(wrapper.router.currentRoute.value).toMatchObject({
+ name: 'finance-customer-statements',
+ query: { createMode: 'SELECTED', customerId: '10001', feeSourceIds: '42', periodStart: '2026-08-11', periodEnd: '2026-08-11' },
+ })
})
it('creates a fee-source export task with the complete active filters and typed booleans', async () => {
diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue
index bcd32203..d3d7b75c 100644
--- a/src/modules/finance/pages/FinanceCustomerArapView.vue
+++ b/src/modules/finance/pages/FinanceCustomerArapView.vue
@@ -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 { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
+import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
@@ -72,10 +72,6 @@ const mutationError = ref(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(null)
-const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; cutoffDate: string; remark: string }>({ mode: 'SELECTED', customerId: '', period: [], cutoffDate: '', remark: '' })
const selectedRows = ref([])
const page = ref(1)
const pageSize = ref(20)
@@ -101,7 +97,7 @@ const queryFields = computed(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
])
const columns: DataTableColumn[] = [
- { key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: (row: ArapRow) => row.direction === 'RECEIVABLE' && Boolean(row.feeSourceId) }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
+ { key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ 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' },
@@ -247,22 +243,31 @@ async function submitMutation() {
function statusLabel(status: string) { return financeStatusLabel(status) }
function money(value: unknown) { return Number(value || 0).toFixed(2) }
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
-function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') {
- generationForm.mode = mode
- generationForm.customerId = ''
- generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
- generationForm.cutoffDate = generationForm.period[1] || ''
- generationForm.remark = ''
- generationError.value = null
- generationOpen.value = true
+function isSelectableReceivable(row: ArapRow) {
+ return Boolean(row.feeSourceId)
+ && row.direction === 'RECEIVABLE'
+ && row.reviewStatus === 'APPROVED'
+ && row.status === 'AVAILABLE'
+ && row.amount > 0
+ && row.statementGenerated === false
}
-async function submitGeneration() {
- 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 }
- 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 }
+async function continueSelectedGeneration() {
+ if (!selectedRows.value.length) return ElMessage.warning('请先勾选需要生成对账单的应收费用')
+ if (selectedRows.value.some((row) => !isSelectableReceivable(row))) return ElMessage.warning('所选费用中存在不可生成对账单的记录,请刷新后重新选择')
+ const customerIds = [...new Set(selectedRows.value.map((row) => row.customerId))]
+ if (customerIds.length !== 1) return ElMessage.warning('一次只能选择同一客户的费用生成对账单')
+ const businessDates = selectedRows.value.map((row) => row.businessDate).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value)).sort()
+ if (businessDates.length !== selectedRows.value.length) return ElMessage.warning('所选费用缺少有效业务日期,暂不能生成对账单')
+ await router.push({
+ name: 'finance-customer-statements',
+ query: {
+ createMode: 'SELECTED',
+ customerId: customerIds[0],
+ feeSourceIds: selectedRows.value.map((row) => row.feeSourceId as string).join(','),
+ periodStart: businessDates[0],
+ periodEnd: businessDates.at(-1),
+ },
+ })
}
async function exportFeeSources() {
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
@@ -315,14 +320,14 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
-
+
- 导出Excel按截止日期生成按勾选生成
+ 导出Excel生成所选对账单
{{ customerName(row.customerId, row.customerName) }}{{ financeDirectionLabel(row.direction) }}
{{ businessReference(row as unknown as ArapRow) }}{{ sourceTypeDisplay(row.sourceType) }}{{ supplierDisplay(row as unknown as ArapRow) }}{{ sourceStatusDisplay(row.status) }}
@@ -347,9 +352,6 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
-
- {{ selectedRows.length }} 笔,合计 {{ money(selectedAmount) }} CNY
-
diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts
index 16bf1994..cba0f508 100644
--- a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts
+++ b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts
@@ -10,8 +10,8 @@ import { useAuthStore } from '@/core/auth/auth'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
-import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
-import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
+import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
+import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
@@ -19,7 +19,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
- getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
+ getReconciliationStatement: vi.fn(), listFinanceFeeSources: vi.fn(), listFinanceStatements: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -44,6 +44,13 @@ const detail: FinanceReconciliationDetail = {
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: ['9001'], createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }],
}
+const eligibleFeeSource: FinanceFeeSourceSummary = {
+ poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IN-21',
+ sourceId: '81', sourceVersion: 2, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null,
+ supplierName: '', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: null, containerNo: '', businessDate: '2026-08-15',
+ feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 320, currency: 'CNY', reviewStatus: 'APPROVED',
+ sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15',
+}
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') {
const pinia = createPinia(); setActivePinia(pinia)
@@ -73,6 +80,8 @@ describe('FinanceCustomerStatementsView', () => {
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
+ vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [eligibleFeeSource], total: 1, pages: 1 })
+ vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({ ...detail, id: '502', billNo: 'REC-502', status: 'DRAFT' })
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
@@ -255,6 +264,34 @@ describe('FinanceCustomerStatementsView', () => {
expect(listPlatformTenants).not.toHaveBeenCalled()
})
+ it('accepts selected fee sources from customer arap and creates them in the statement page', async () => {
+ const permissions = [
+ 'platform:finance:customer-statement:list',
+ 'platform:finance:customer-arap:list',
+ 'platform:finance:reconciliation:create',
+ 'platform:customer:list',
+ ]
+ const { wrapper, router } = await mountPage(permissions, '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15')
+ const vm = wrapper.vm as unknown as {
+ createOpen: boolean
+ createForm: { generationMode: string; customerId: string; feeSourceIds: string[]; periodStart: string; periodEnd: string }
+ createStatement(): Promise
+ }
+ expect(vm.createOpen).toBe(true)
+ expect(vm.createForm).toMatchObject({ generationMode: 'SELECTED', customerId: '10001', feeSourceIds: ['42'], periodStart: '2026-08-15', periodEnd: '2026-08-15' })
+ expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({
+ customerId: '10001', direction: 'RECEIVABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false,
+ businessDateFrom: '2026-08-15', businessDateTo: '2026-08-15',
+ }))
+
+ await vm.createStatement()
+ expect(createReconciliationStatement).toHaveBeenCalledWith({
+ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'],
+ periodStart: '2026-08-15', periodEnd: '2026-08-15', remark: undefined,
+ })
+ expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
+ })
+
it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => {
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(statement)
diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue
index 6cc8fff2..709829df 100644
--- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue
+++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue
@@ -17,9 +17,9 @@ 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 { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
+import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
-import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
+import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
@@ -61,14 +61,20 @@ const activeDetailTab = ref<'lines' | 'payments'>('lines')
const detailCache = new Map()
const createOpen = ref(false)
const createBusy = ref(false)
+const createSourcesLoading = ref(false)
+const createSourcesError = ref(null)
+const createSourceOptions = ref([])
const exportBusy = ref(false)
const actionBusy = ref(false)
const createForm = ref({
+ generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
customerId: '',
periodStart: new Date().toISOString().slice(0, 8) + '01',
periodEnd: new Date().toISOString().slice(0, 10),
+ feeSourceIds: [] as string[],
remark: '',
})
+let createSourceLoadSequence = 0
const adjustOpen = ref(false)
const adjustBusy = ref(false)
const adjustStatement = ref(null)
@@ -78,6 +84,7 @@ const adjustLines = ref([])
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canViewFeeSource = computed(() => access.can('platform:finance:customer-statement:list'))
+const canSelectSources = computed(() => access.can('platform:finance:customer-arap:list'))
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
@@ -86,6 +93,8 @@ const canAdjust = computed(() => access.can('platform:finance:reconciliation:adj
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
+const selectedCreateSources = computed(() => createSourceOptions.value.filter((item) => createForm.value.feeSourceIds.includes(item.feeSourceId)))
+const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((total, item) => total + Number(item.amount || 0), 0))
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
const fields = computed(() => [
@@ -366,22 +375,120 @@ async function exportStatements() {
}
}
+function defaultCreateForm() {
+ return {
+ generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
+ customerId: '',
+ periodStart: new Date().toISOString().slice(0, 8) + '01',
+ periodEnd: new Date().toISOString().slice(0, 10),
+ feeSourceIds: [] as string[],
+ remark: '',
+ }
+}
+
function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
- createForm.value = { ...createForm.value, customerId: '' }
+ createForm.value = defaultCreateForm()
+ createSourceOptions.value = []
+ createSourcesError.value = null
createOpen.value = true
}
+async function loadEligibleCreateSources(preferredIds: string[] = createForm.value.feeSourceIds) {
+ const sequence = ++createSourceLoadSequence
+ createSourcesError.value = null
+ createSourceOptions.value = []
+ if (createForm.value.generationMode !== 'SELECTED' || !createForm.value.customerId
+ || !createForm.value.periodStart || !createForm.value.periodEnd || !canSelectSources.value) {
+ createForm.value.feeSourceIds = []
+ return
+ }
+ createSourcesLoading.value = true
+ try {
+ const records: FinanceFeeSourceSummary[] = []
+ let currentPage = 1
+ let pages = 1
+ do {
+ const result = await listFinanceFeeSources({
+ page: currentPage,
+ size: 200,
+ customerId: createForm.value.customerId,
+ direction: 'RECEIVABLE',
+ status: 'AVAILABLE',
+ reviewStatus: 'APPROVED',
+ statementGenerated: false,
+ businessDateFrom: createForm.value.periodStart,
+ businessDateTo: createForm.value.periodEnd,
+ })
+ records.push(...result.records)
+ pages = Math.max(1, result.pages)
+ currentPage += 1
+ } while (currentPage <= pages)
+ if (sequence !== createSourceLoadSequence) return
+ createSourceOptions.value = records.filter((item) => item.direction === 'RECEIVABLE'
+ && item.reviewStatus === 'APPROVED' && item.status === 'AVAILABLE'
+ && !item.statementGenerated && item.amount > 0)
+ const availableIds = new Set(createSourceOptions.value.map((item) => item.feeSourceId))
+ createForm.value.feeSourceIds = preferredIds.filter((id) => availableIds.has(id))
+ if (preferredIds.length && createForm.value.feeSourceIds.length !== preferredIds.length) {
+ ElMessage.warning('部分带入费用已被占用或不再符合生成条件,请重新核对')
+ }
+ } catch (cause) {
+ if (sequence !== createSourceLoadSequence) return
+ createSourcesError.value = getApiErrorDetails(cause, '可生成费用加载失败')
+ createForm.value.feeSourceIds = []
+ } finally {
+ if (sequence === createSourceLoadSequence) createSourcesLoading.value = false
+ }
+}
+
+function queryText(value: unknown) {
+ return Array.isArray(value) ? String(value[0] || '') : String(value || '')
+}
+
+async function openSelectedCreateFromRoute() {
+ if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
+ if (!canGenerate.value || !canSelectSources.value) {
+ ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
+ return
+ }
+ const feeSourceIds = queryText(route.query.feeSourceIds).split(',').map((value) => value.trim()).filter((value) => /^[1-9]\d*$/.test(value))
+ const customerId = queryText(route.query.customerId)
+ const periodStart = queryText(route.query.periodStart)
+ const periodEnd = queryText(route.query.periodEnd)
+ if (!customerId || !feeSourceIds.length || !/^\d{4}-\d{2}-\d{2}$/.test(periodStart) || !/^\d{4}-\d{2}-\d{2}$/.test(periodEnd)) {
+ ElMessage.warning('带入的对账单生成参数不完整,请重新选择费用')
+ return
+ }
+ if (!activeCustomers.value.some((item) => String(item.id) === customerId)) {
+ ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
+ return
+ }
+ createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
+ await loadEligibleCreateSources(feeSourceIds)
+ createOpen.value = true
+ await router.replace({ name: 'finance-customer-statements' })
+}
+
async function createStatement() {
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
+ if (createForm.value.generationMode === 'SELECTED' && !createForm.value.feeSourceIds.length) return ElMessage.warning('请选择至少一笔应收费用')
createBusy.value = true
try {
- await createReconciliationStatement({ customerId: String(createForm.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
+ const created = await createReconciliationStatement({
+ customerId: String(createForm.value.customerId),
+ currency: 'CNY',
+ generationMode: createForm.value.generationMode,
+ feeSourceIds: createForm.value.generationMode === 'SELECTED' ? createForm.value.feeSourceIds : undefined,
+ periodStart: createForm.value.periodStart,
+ periodEnd: createForm.value.periodEnd,
+ remark: createForm.value.remark || undefined,
+ })
createOpen.value = false
ElMessage.success('对账单已生成')
- await load(filters.value)
+ await router.push({ name: 'finance-customer-statement-detail', params: { statementId: created.id } })
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') }
finally { createBusy.value = false }
}
@@ -431,7 +538,17 @@ async function submitAdjustment() {
finally { adjustBusy.value = false }
}
-onMounted(() => { if (canListCustomers.value) void loadCustomers() })
+onMounted(async () => {
+ if (canListCustomers.value) await loadCustomers()
+ await openSelectedCreateFromRoute()
+})
+watch(
+ () => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
+ ([open, mode]) => {
+ if (!open || mode !== 'SELECTED') return
+ void loadEligibleCreateSources()
+ },
+)
watch(detailRouteId, (statementId) => {
if (statementId) void loadDetail(statementId)
else {
@@ -568,7 +685,29 @@ watch(detailRouteId, (statementId) => {
-
+
+
+
+
+ 按账期自动汇总
+ 指定费用生成
+
+
+
+
+
+
+ 可生成费用加载失败{{ createSourcesError.message }}
重试
+
+
+
+
+ 已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY
+
+
+
+
+
@@ -580,6 +719,8 @@ watch(detailRouteId, (statementId) => {
diff --git a/src/modules/unified-orders/api/inboundFeeDetails.test.ts b/src/modules/unified-orders/api/inboundFeeDetails.test.ts
index 5ae03bd6..5bed6536 100644
--- a/src/modules/unified-orders/api/inboundFeeDetails.test.ts
+++ b/src/modules/unified-orders/api/inboundFeeDetails.test.ts
@@ -3,7 +3,7 @@ import { http } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails,
listInboundFeeItemOptions, listInboundFeeSupplierOptions, reviewInboundFeeDetail,
- updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
+ updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
} from './inboundFeeDetails'
vi.mock('@/shared/api/http', async () => {
@@ -19,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, supplierId: '41', remark: '验收' }
+ const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-31', paymentVoucherFileIds: ['9001'], 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 })
@@ -34,6 +34,14 @@ describe('inbound fee-detail API', () => {
expect(http.delete).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11', { params: { accountId: '100' } })
})
+ it('reuses the scoped FINANCE_VOUCHER upload and fails closed on customer mismatch', async () => {
+ vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { fileId: 9001, tenantId: 100, fileName: '付款凭证.pdf' } })
+ await expect(uploadInboundFeePaymentVoucher('100', new File(['x'], '付款凭证.pdf'))).resolves.toEqual({ fileId: '9001', fileName: '付款凭证.pdf' })
+ expect(http.post).toHaveBeenCalledWith('/platform/finance/v1/vouchers', expect.any(FormData), { params: { customerId: '100' }, headers: { 'Content-Type': 'multipart/form-data' } })
+ vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { fileId: 9002, tenantId: 101, fileName: '错客户.pdf' } })
+ await expect(uploadInboundFeePaymentVoucher('100', new File(['x'], '错客户.pdf'))).rejects.toThrow('付款凭证与当前客户不匹配')
+ })
+
it('keeps an empty supplier category as null and rejects an unknown non-empty category', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [
{ id: 31, feeTypeName: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehousePayable: true },
diff --git a/src/modules/unified-orders/api/inboundFeeDetails.ts b/src/modules/unified-orders/api/inboundFeeDetails.ts
index 1a7a4793..f8d77e4f 100644
--- a/src/modules/unified-orders/api/inboundFeeDetails.ts
+++ b/src/modules/unified-orders/api/inboundFeeDetails.ts
@@ -3,6 +3,8 @@ import { listSuppliers, type SupplierCategory } from '@/modules/suppliers'
export type InboundFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export type InboundFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
+export type FeeSettlementMode = 'ON_ACCOUNT' | 'DIRECT_PAID'
+export type FeePaymentMethod = 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'
export interface InboundFeeDetail {
id: string | number
@@ -15,6 +17,11 @@ export interface InboundFeeDetail {
supplierId: string | number | null
supplierName: string | null
supplierCategory: SupplierCategory | null
+ settlementMode?: FeeSettlementMode | null
+ paidAt?: string | null
+ paymentMethod?: FeePaymentMethod | null
+ paymentReferenceNo?: string | null
+ paymentVoucherFileIds?: Array | null
businessDate: string
customerName: string | null
feeTypeNameSnapshot: string
@@ -37,6 +44,11 @@ export interface SaveInboundFeeDetailRequest {
currency: string
amount: number
supplierId: string | number | null
+ settlementMode: FeeSettlementMode | null
+ paidAt: string | null
+ paymentMethod: FeePaymentMethod | null
+ paymentReferenceNo: string | null
+ paymentVoucherFileIds: Array | null
remark: string | null
}
@@ -50,6 +62,7 @@ export interface InboundFeeItemOption {
}
export interface InboundFeeSupplierOption { id: string; supplierName: string; category: SupplierCategory }
+export interface FeePaymentVoucher { fileId: string; fileName: string }
const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
@@ -123,3 +136,14 @@ export async function listInboundFeeSupplierOptions(category: SupplierCategory |
id: supplier.id, supplierName: supplier.name, category: supplier.category,
}))
}
+
+export async function uploadInboundFeePaymentVoucher(customerId: string, file: File): Promise {
+ const body = new FormData(); body.append('file', file)
+ const raw = unwrapApiResponse(await http.post>>('/platform/finance/v1/vouchers', body, {
+ params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' },
+ }))
+ const fileId = raw.fileId == null ? '' : String(raw.fileId)
+ const echoedCustomerId = raw.tenantId == null ? String(raw.customerId ?? customerId) : String(raw.tenantId)
+ if (!fileId || echoedCustomerId !== customerId) throw new Error('付款凭证与当前客户不匹配')
+ return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) }
+}
diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
index 7066a41a..c84d13ac 100644
--- a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
+++ b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
@@ -10,7 +10,7 @@ import { ApiResponseError } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
- reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
+ reviewInboundFeeDetail, updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import InboundFeeDetailManager from './InboundFeeDetailManager.vue'
import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails'
@@ -18,12 +18,13 @@ import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDe
vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({
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(),
+ reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), uploadInboundFeePaymentVoucher: vi.fn(), voidInboundFeeDetail: vi.fn(),
}))
const pending: InboundFeeDetail = {
id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1,
inboundCode: 'RK-88', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null,
+ settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: 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',
@@ -49,6 +50,7 @@ describe('InboundFeeDetailManager', () => {
vi.mocked(createInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' })
+ vi.mocked(uploadInboundFeePaymentVoucher).mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
vi.mocked(reviewInboundFeeDetail).mockResolvedValue(null)
vi.mocked(deleteInboundFeeDetail).mockResolvedValue(null)
vi.mocked(voidInboundFeeDetail).mockResolvedValue(null)
@@ -81,6 +83,13 @@ describe('InboundFeeDetailManager', () => {
for (const action of ['编辑', '审核', '驳回', '调整', '作废', '删除']) expect(buttons).not.toContain(action)
})
+ it('shows payable settlement meaning and a DIRECT_PAID summary without changing fee direction', async () => {
+ vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-11', paymentVoucherFileIds: ['9001'] }])
+ const wrapper = mountManager(['firstleg:inbound:fee-detail:view'])
+ await flushPromises()
+ expect(wrapper.text()).toContain('应付'); expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-11')
+ })
+
it('does not request or expose fee rows without view permission', async () => {
mountManager([]); await flushPromises()
expect(listInboundFeeDetails).not.toHaveBeenCalled()
@@ -163,7 +172,7 @@ describe('InboundFeeDetailManager', () => {
})
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(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', supplierCategory: 'TRUCKING', settlementMode: 'ON_ACCOUNT' }])
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',
@@ -191,11 +200,45 @@ describe('InboundFeeDetailManager', () => {
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; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
- state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100
+ const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
+ state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100
await state.save(); await flushPromises()
expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith(null)
- expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' }))
+ expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }))
+ })
+
+ it('clears settlement and paid fields when switching to RECEIVABLE or ON_ACCOUNT', async () => {
+ 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: { form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState
+ Object.assign(state.form, { direction: 'PAYABLE', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'CASH', paymentReferenceNo: 'CASH-1', paymentVoucherFileIds: ['9001'] }); await flushPromises()
+ state.form.settlementMode = 'ON_ACCOUNT'; await flushPromises()
+ expect(state.form).toMatchObject({ settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })
+ state.form.direction = 'RECEIVABLE'; await flushPromises()
+ expect(state.form).toMatchObject({ direction: 'RECEIVABLE', settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })
+ })
+
+ it('requires complete DIRECT_PAID evidence and submits uploaded voucher IDs only after scoped upload succeeds', async () => {
+ 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; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState
+ Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: null, amount: 100 }); await flushPromises()
+ await state.save(); expect(createInboundFeeDetail).not.toHaveBeenCalled()
+ const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '付款凭证.pdf')] })
+ await state.selectVoucher({ target: input } as unknown as Event); await state.save()
+ expect(uploadInboundFeePaymentVoucher).toHaveBeenCalledWith('100', expect.objectContaining({ name: '付款凭证.pdf' }))
+ expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ settlementMode: 'DIRECT_PAID', paymentMethod: 'BANK_TRANSFER', paymentVoucherFileIds: ['9001'] }))
+ })
+
+ it('keeps DIRECT_PAID save closed and exposes feedback when voucher upload fails', async () => {
+ vi.mocked(uploadInboundFeePaymentVoucher).mockRejectedValue(new ApiResponseError('File服务不可用', 'A0503', 503, null, 'request-file', 'trace-file'))
+ 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; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState
+ Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: 'ONLINE-1', amount: 100 }); await flushPromises()
+ const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '失败凭证.png')] })
+ await state.selectVoucher({ target: input } as unknown as Event); await state.save(); await flushPromises()
+ expect(createInboundFeeDetail).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('request-file'); expect(document.body.textContent).toContain('trace-file')
})
it('refreshes fee rows and the order detail after every successful write', () => {
diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.vue b/src/modules/unified-orders/components/InboundFeeDetailManager.vue
index a1f64154..5dfcde6d 100644
--- a/src/modules/unified-orders/components/InboundFeeDetailManager.vue
+++ b/src/modules/unified-orders/components/InboundFeeDetailManager.vue
@@ -1,7 +1,7 @@
@@ -240,6 +289,8 @@ onMounted(() => void load())
{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}
{{ directionLabel(row.direction) }}
+ {{ settlementLabel(row as InboundFeeDetail) }}
+ {{ paidSummary(row as InboundFeeDetail) }}
{{ row.direction === 'PAYABLE' ? row.supplierName : row.customerName || '—' }}
@@ -265,6 +316,17 @@ onMounted(() => void load())
+ 账期应付已付成本
+
+
+
+
+
+ FINANCE_VOUCHER · 最多 20 个
+ {{ voucherError.message }}
+ {{ file.fileName }}
+
+
@@ -288,6 +350,7 @@ onMounted(() => void load())
.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }
.fee-form :deep(.el-select), .fee-form :deep(.el-date-editor), .fee-form :deep(.el-input-number) { width: 100%; }
.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
+.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
From c91c87784e1cb2088c8bb88d44c58f0151fb4ee1 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 20 Aug 2026 18:05:27 +0800
Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../loading-lists/components/ContainerFeesPanel.test.ts | 3 ++-
src/modules/loading-lists/components/ContainerFeesPanel.vue | 6 +++++-
.../components/InboundFeeDetailManager.test.ts | 6 ++++--
.../unified-orders/components/InboundFeeDetailManager.vue | 6 +++++-
4 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts
index 456bb437..a6f2e4f2 100644
--- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts
+++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts
@@ -225,7 +225,8 @@ describe('ContainerFeesPanel contract', () => {
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; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState
- state.openCreate(); await flushPromises(); Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '7', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: null, amount: 100 }); await flushPromises()
+ state.openCreate(); await flushPromises(); Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '7' }); await flushPromises()
+ Object.assign(state.form, { supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: null, amount: 100 }); await flushPromises()
await state.save(); expect(createContainerFee).not.toHaveBeenCalled()
const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '付款凭证.pdf')] })
await state.selectVoucher({ target: input } as unknown as Event); await state.save()
diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue
index 757b278c..90c315f6 100644
--- a/src/modules/loading-lists/components/ContainerFeesPanel.vue
+++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue
@@ -13,6 +13,10 @@ import { adjustContainerFee, approveContainerFee, createContainerFee, listApplic
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
const access = useAccessPreviewStore()
+const settlementModeModel = computed({
+ get: () => form.settlementMode ?? '',
+ set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
+})
const accountId = ref('')
const fees = ref([])
const feeItems = ref([])
@@ -293,7 +297,7 @@ onMounted(() => void load())
- 账期应付已付成本
+ 账期应付已付成本
diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
index c84d13ac..99f4c638 100644
--- a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
+++ b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
@@ -222,7 +222,8 @@ describe('InboundFeeDetailManager', () => {
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; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState
- Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: null, amount: 100 }); await flushPromises()
+ Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31' }); await flushPromises()
+ Object.assign(state.form, { supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: null, amount: 100 }); await flushPromises()
await state.save(); expect(createInboundFeeDetail).not.toHaveBeenCalled()
const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '付款凭证.pdf')] })
await state.selectVoucher({ target: input } as unknown as Event); await state.save()
@@ -235,7 +236,8 @@ describe('InboundFeeDetailManager', () => {
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; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState
- Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: 'ONLINE-1', amount: 100 }); await flushPromises()
+ Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31' }); await flushPromises()
+ Object.assign(state.form, { supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: 'ONLINE-1', amount: 100 }); await flushPromises()
const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '失败凭证.png')] })
await state.selectVoucher({ target: input } as unknown as Event); await state.save(); await flushPromises()
expect(createInboundFeeDetail).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('request-file'); expect(document.body.textContent).toContain('trace-file')
diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.vue b/src/modules/unified-orders/components/InboundFeeDetailManager.vue
index 5dfcde6d..42726b8a 100644
--- a/src/modules/unified-orders/components/InboundFeeDetailManager.vue
+++ b/src/modules/unified-orders/components/InboundFeeDetailManager.vue
@@ -316,7 +316,7 @@ onMounted(() => void load())
- 账期应付已付成本
+ 账期应付已付成本
@@ -356,3 +356,7 @@ onMounted(() => void load())
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
+const settlementModeModel = computed({
+ get: () => form.settlementMode ?? '',
+ set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
+})
From 659ddf0614cb6d16f94430a865501ba9303ed4f7 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 20 Aug 2026 18:17:36 +0800
Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/modules/finance/api/finance.test.ts | 6 +++---
src/modules/finance/api/finance.ts | 5 ++++-
src/modules/finance/financeDelivery.test.ts | 2 +-
.../pages/FinanceCustomerAccountsView.test.ts | 4 ++--
.../finance/pages/FinanceCustomerAccountsView.vue | 12 ++++++++----
src/modules/finance/types.ts | 2 ++
.../loading-lists/components/ContainerFeesPanel.vue | 8 ++++----
.../components/InboundFeeDetailManager.vue | 8 ++++----
8 files changed, 28 insertions(+), 19 deletions(-)
diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts
index ba891da5..3e53ae0a 100644
--- a/src/modules/finance/api/finance.test.ts
+++ b/src/modules/finance/api/finance.test.ts
@@ -21,7 +21,7 @@ describe('finance api', () => {
})
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 } })
+ 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', totalCost: '300.000000', paidCost: '200.000000', outstandingPayable: '100.000000', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null } })
const result = await getFinanceCustomerAccountSummary('9007199254740993')
@@ -57,12 +57,12 @@ describe('finance api', () => {
})
it('uses the formal G1 account list path and fixed page DTO', async () => {
- const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
+ const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
const result = await listFinanceAccounts({ page: 2, size: 20, customerId: '10001', status: 'ACTIVE' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts', { params: { page: 2, pageSize: 20, customerId: '10001', status: 'ACTIVE' } })
- expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], total: 21, pages: 2 })
+ expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], total: 21, pages: 2 })
})
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts
index 6ce1c52f..368c4bf9 100644
--- a/src/modules/finance/api/finance.ts
+++ b/src/modules/finance/api/finance.ts
@@ -9,6 +9,8 @@ export interface FinanceCustomerAccountSummaryResource {
receivedAmount: number | string
outstandingReceivable: number | string
pendingBilling: number | string
+ totalCost: number | string
+ paidCost: number | string
outstandingPayable: number | string
asOf: string
lastChangedAt: string | null
@@ -103,6 +105,7 @@ export async function getFinanceCustomerAccountSummary(customerId: string): Prom
customerId: stringValue(item.customerId), currency: 'CNY',
totalReceivable: stringValue(item.totalReceivable), receivedAmount: stringValue(item.receivedAmount),
outstandingReceivable: stringValue(item.outstandingReceivable), pendingBilling: stringValue(item.pendingBilling),
+ totalCost: stringValue(item.totalCost), paidCost: stringValue(item.paidCost),
outstandingPayable: stringValue(item.outstandingPayable), asOf: stringValue(item.asOf),
lastChangedAt: item.lastChangedAt == null ? null : stringValue(item.lastChangedAt),
}
@@ -121,7 +124,7 @@ export async function listFinanceAccounts(params: { page?: number; size?: number
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined, status: params.status || undefined }
const response = await http.get & { page?: RawResource }, ApiEnvelope & { page?: RawResource }>(`${basePath}/accounts`, { params: query })
const data = unwrapApiResponse(response)
- const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
+ const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), totalCost: numberValue(item.totalCost), paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
diff --git a/src/modules/finance/financeDelivery.test.ts b/src/modules/finance/financeDelivery.test.ts
index ad52aa23..0cbe8bac 100644
--- a/src/modules/finance/financeDelivery.test.ts
+++ b/src/modules/finance/financeDelivery.test.ts
@@ -82,7 +82,7 @@ describe('finance six-page delivery contract', () => {
const columns = page.slice(page.indexOf('const columns:'))
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'direction'"))
expect(page).toContain('')
+ expect(page).toContain('')
expect(page).not.toContain(' {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.stubGlobal('sessionStorage', memoryStorage())
- vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 })
+ vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, totalCost: 90, paidCost: 50, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 })
vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [{ ledgerId: '1', occurredAt: '2026-08-13T10:00:00', businessType: 'REC', documentType: 'RECEIPT', documentId: '2', documentNo: 'REC-2', receivableChange: -10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '收款' }], total: 1, pages: 1 })
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-1', taskNo: 'EXP-1', taskName: '客户账户', status: 'PENDING' } as never)
@@ -155,7 +155,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
})
it('shows explicit master-data empty states without exposing the customer id', async () => {
- vi.mocked(listFinanceAccounts).mockResolvedValue({ records: [{ customerId: '10001', customerCode: '', customerName: '客户A', contactName: '', contactPhone: '', outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, outstandingPayable: 0, status: 'ACTIVE', lastChangedAt: '' }], total: 1, pages: 1 })
+ vi.mocked(listFinanceAccounts).mockResolvedValue({ records: [{ customerId: '10001', customerCode: '', customerName: '客户A', contactName: '', contactPhone: '', outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0, status: 'ACTIVE', lastChangedAt: '' }], total: 1, pages: 1 })
const wrapper = await mountAccountsView(['platform:finance:customer-account:list'])
expect(wrapper.text()).toContain('客户编号')
expect(wrapper.text()).toContain('联系人')
diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue
index 1d20e88c..40460837 100644
--- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue
+++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue
@@ -68,6 +68,8 @@ const columns: DataTableColumn[] = [
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
+ { key: 'totalCost', label: '总成本(CNY)', width: 140 },
+ { key: 'paidCost', label: '已付成本(CNY)', width: 150 },
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
@@ -89,8 +91,10 @@ const summary = computed(() => rows.value.reduce((result, row) => ({
receivable: result.receivable + row.outstandingReceivable,
pending: result.pending + row.pendingBilling,
received: result.received + row.receivedInPeriod,
+ totalCost: result.totalCost + row.totalCost,
+ paidCost: result.paidCost + row.paidCost,
payable: result.payable + row.outstandingPayable,
-}), { receivable: 0, pending: 0, received: 0, payable: 0 }))
+}), { receivable: 0, pending: 0, received: 0, totalCost: 0, paidCost: 0, payable: 0 }))
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
@@ -263,7 +267,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
{{ customerField(row.customerCode) }}{{ customerField(row.contactName) }}{{ customerField(row.contactPhone) }}
{{ formatLastChangedAt(row.lastChangedAt) }}
{{ row.status === 'ACTIVE' ? '正常' : '停用' }}
- 汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY
+ 汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 总成本 {{ money(summary.totalCost) }} · 已付成本 {{ money(summary.paidCost) }} · 待付 {{ money(summary.payable) }} CNY
@@ -271,7 +275,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
{{ editing ? '维护客户账期' : '客户账户详情' }}
{{ customerName(selected.customerId, selected.customerName) }} · 客户财务往来总览
返回客户账户
- 待收金额{{ money(selected.outstandingReceivable) }}CNY
待开账金额{{ money(selected.pendingBilling) }}CNY
本期已收{{ money(selected.receivedInPeriod) }}CNY
待付金额{{ money(selected.outstandingPayable) }}CNY
+ 待收金额{{ money(selected.outstandingReceivable) }}CNY
待开账金额{{ money(selected.pendingBilling) }}CNY
本期已收{{ money(selected.receivedInPeriod) }}CNY
总成本{{ money(selected.totalCost) }}CNY
已付成本{{ money(selected.paidCost) }}CNY
待付金额{{ money(selected.outstandingPayable) }}CNY
{{ customerField(selected.customerCode) }}{{ customerField(selected.contactName) }}{{ customerField(selected.contactPhone) }}
@@ -377,7 +381,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
.account-metrics {
display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
padding: 0 0 12px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
diff --git a/src/modules/finance/types.ts b/src/modules/finance/types.ts
index 562ec893..9e96493b 100644
--- a/src/modules/finance/types.ts
+++ b/src/modules/finance/types.ts
@@ -183,6 +183,8 @@ export interface FinanceAccountSummary {
outstandingReceivable: number
pendingBilling: number
receivedInPeriod: number
+ totalCost: number
+ paidCost: number
outstandingPayable: number
status: string
lastChangedAt: string
diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue
index 90c315f6..5a2cb0ad 100644
--- a/src/modules/loading-lists/components/ContainerFeesPanel.vue
+++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue
@@ -13,10 +13,6 @@ import { adjustContainerFee, approveContainerFee, createContainerFee, listApplic
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
const access = useAccessPreviewStore()
-const settlementModeModel = computed({
- get: () => form.settlementMode ?? '',
- set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
-})
const accountId = ref('')
const fees = ref([])
const feeItems = ref([])
@@ -36,6 +32,10 @@ const saving = ref(false)
const target = ref(null)
const formRef = ref()
const form = reactive({ feeItemId: '', direction: 'RECEIVABLE', supplierId: null, settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', remark: null })
+const settlementModeModel = computed({
+ get: () => form.settlementMode ?? '',
+ set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
+})
const voucherFiles = ref>([])
const voucherUploading = ref(false)
const voucherError = ref(null)
diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.vue b/src/modules/unified-orders/components/InboundFeeDetailManager.vue
index 42726b8a..043706ad 100644
--- a/src/modules/unified-orders/components/InboundFeeDetailManager.vue
+++ b/src/modules/unified-orders/components/InboundFeeDetailManager.vue
@@ -52,6 +52,10 @@ const emptyForm = (): SaveInboundFeeDetailRequest => ({
paymentReferenceNo: null, paymentVoucherFileIds: null, remark: null,
})
const form = reactive(emptyForm())
+const settlementModeModel = computed({
+ get: () => form.settlementMode ?? '',
+ set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
+})
const rules: FormRules = {
feeItemId: [{ required: true, message: '请选择费用项目', trigger: 'change' }],
businessDate: [{ required: true, message: '请选择业务发生日', trigger: 'change' }],
@@ -356,7 +360,3 @@ onMounted(() => void load())
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
-const settlementModeModel = computed({
- get: () => form.settlementMode ?? '',
- set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null },
-})