diff --git a/src/modules/loading-lists/api/containerFees.test.ts b/src/modules/loading-lists/api/containerFees.test.ts index 6e23331d..08d8d8ea 100644 --- a/src/modules/loading-lists/api/containerFees.test.ts +++ b/src/modules/loading-lists/api/containerFees.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees' +import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee } from './containerFees' import { listSuppliers } from '@/modules/suppliers' vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() })) @@ -9,8 +9,8 @@ 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', 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 } + 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', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-7', paymentVoucherFileIds: [9001], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1 } }) + const values = { feeItemId: '7', direction: 'PAYABLE' as const, supplierId: '41', settlementMode: 'DIRECT_PAID' as const, paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER' as const, paymentReferenceNo: 'BANK-7', paymentVoucherFileIds: ['9001'], 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) @@ -18,7 +18,15 @@ 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', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING' }) + expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paymentVoucherFileIds: ['9001'] }) + }) + + it('uploads a payment voucher in the current customer scope and rejects a mismatched echo', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValueOnce({ code: '200', data: { fileId: 9001, tenantId: 11, fileName: '付款凭证.png' } }) + await expect(uploadContainerFeePaymentVoucher('11', new File(['x'], '付款凭证.png'))).resolves.toEqual({ fileId: '9001', fileName: '付款凭证.png' }) + expect(post).toHaveBeenCalledWith('/platform/finance/v1/vouchers', expect.any(FormData), { params: { customerId: '11' }, headers: { 'Content-Type': 'multipart/form-data' } }) + post.mockResolvedValueOnce({ code: '200', data: { fileId: 9002, tenantId: 12, fileName: '错客户.png' } }) + await expect(uploadContainerFeePaymentVoucher('11', new File(['x'], '错客户.png'))).rejects.toThrow('付款凭证与当前客户不匹配') }) it('loads only ACTIVE suppliers in the requested category', async () => { diff --git a/src/modules/loading-lists/api/containerFees.ts b/src/modules/loading-lists/api/containerFees.ts index 2c867ba9..a0673c84 100644 --- a/src/modules/loading-lists/api/containerFees.ts +++ b/src/modules/loading-lists/api/containerFees.ts @@ -3,6 +3,8 @@ import { listSuppliers, type SupplierCategory } from '@/modules/suppliers' export type ContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE' export type ContainerFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' +export type ContainerFeeSettlementMode = 'ON_ACCOUNT' | 'DIRECT_PAID' +export type ContainerFeePaymentMethod = 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER' export interface ContainerFeeResource { id: string @@ -15,6 +17,11 @@ export interface ContainerFeeResource { supplierId: string | null supplierName: string | null supplierCategory: SupplierCategory | null + settlementMode?: ContainerFeeSettlementMode | null + paidAt?: string | null + paymentMethod?: ContainerFeePaymentMethod | null + paymentReferenceNo?: string | null + paymentVoucherFileIds?: string[] | null businessDate: string amount: number currency: 'CNY' @@ -31,6 +38,11 @@ export interface ContainerFeeSaveRequest { feeItemId: string direction: ContainerFeeDirection supplierId: string | null + settlementMode: ContainerFeeSettlementMode | null + paidAt: string | null + paymentMethod: ContainerFeePaymentMethod | null + paymentReferenceNo: string | null + paymentVoucherFileIds: string[] | null businessDate: string amount: number currency: 'CNY' @@ -98,6 +110,10 @@ function normalizeFee(raw: Record): ContainerFeeResource { feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection, supplierId: raw.supplierId == null ? null : id(raw.supplierId), supplierName: raw.supplierName == null ? null : String(raw.supplierName), supplierCategory: supplierCategory(raw.supplierCategory), + settlementMode: raw.settlementMode == null ? null : raw.settlementMode as ContainerFeeSettlementMode, + paidAt: raw.paidAt == null ? null : String(raw.paidAt), paymentMethod: raw.paymentMethod == null ? null : raw.paymentMethod as ContainerFeePaymentMethod, + paymentReferenceNo: raw.paymentReferenceNo == null ? null : String(raw.paymentReferenceNo), + paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(id) : null, businessDate: String(raw.businessDate || ''), amount: numberValue(raw.amount), currency: 'CNY', reviewStatus: raw.reviewStatus as ContainerFeeReviewStatus, sourceStatus: raw.sourceStatus as ContainerFeeResource['sourceStatus'], sourceVersion: numberValue(raw.sourceVersion), @@ -143,6 +159,17 @@ export async function listContainerFeeSupplierOptions(category: SupplierCategory return page.records.filter((supplier) => supplier.status === 'ACTIVE' && (!category || supplier.category === category)).map((supplier) => ({ id: supplier.id, supplierName: supplier.name, category: supplier.category })) } +export async function uploadContainerFeePaymentVoucher(customerId: string, file: File): Promise<{ fileId: string; fileName: string }> { + const body = new FormData(); body.append('file', file) + const raw = unwrapApiResponse(await http.post>, ApiEnvelope>>('/platform/finance/v1/vouchers', body, { + params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' }, + })) + const fileId = raw.fileId == null ? '' : id(raw.fileId) + const echoedCustomerId = raw.tenantId == null ? id(raw.customerId ?? customerId) : id(raw.tenantId) + if (!fileId || echoedCustomerId !== customerId) throw new Error('付款凭证与当前客户不匹配') + return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) } +} + export async function listContainerCostDetails(customerId: string, containerId: string): Promise { const response = await http.get[]>, ApiEnvelope[]>>('/platform/finance/v1/cost-entries', { params: { customerId } }) const costs = unwrapApiResponse(response).filter((raw) => id(raw.customerId) === customerId && id(raw.containerId) === containerId) diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index 7b6ac834..456bb437 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -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, listContainerFeeSupplierOptions, listContainerFees } from '../api/containerFees' +import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees' import ContainerFeesPanel from './ContainerFeesPanel.vue' vi.mock('../api/containerFees', async (importOriginal) => { const actual = await importOriginal() - 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() } + return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: 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') @@ -33,6 +33,7 @@ beforeEach(() => { vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([]) vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset() vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never) + vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' }) document.body.innerHTML = '' }) @@ -167,11 +168,11 @@ describe('ContainerFeesPanel contract', () => { 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; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState + const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: 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 + state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100 await state.save() - expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' })) + expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })) expect(createContainerFee).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ partnerId: expect.anything() })) }) @@ -179,12 +180,12 @@ describe('ContainerFeesPanel contract', () => { vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, 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; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState + const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: 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 + state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100 await state.save(); await flushPromises() expect(listContainerFeeSupplierOptions).toHaveBeenCalledWith(null) - expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' })) + expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })) }) it('fails closed when a supplier candidate does not match the fee-item category', async () => { @@ -207,4 +208,28 @@ describe('ContainerFeesPanel contract', () => { expect(wrapper.text()).toContain('当前账号缺少费用项目查看权限') expect(listApplicableFeeItems).not.toHaveBeenCalled() }) + + it('clears paid inputs for RECEIVABLE and ON_ACCOUNT and shows backend settlement semantics', async () => { + vi.mocked(listContainerFees).mockResolvedValue([{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '已付拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }]) + vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', 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', 'platform:supplier:list']) + expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-1') + const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState + state.openCreate(); await flushPromises(); 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.settlementMode).toBeNull() + }) + + it('fails closed without DIRECT_PAID evidence and submits a scoped uploaded voucher', 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: '承运商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() + 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() + expect(uploadContainerFeePaymentVoucher).toHaveBeenCalledWith('11', expect.objectContaining({ name: '付款凭证.pdf' })) + expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ settlementMode: 'DIRECT_PAID', paymentMethod: 'ONLINE', paymentVoucherFileIds: ['9001'] })) + }) }) diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index e2e4b0e3..757b278c 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -1,6 +1,6 @@ @@ -215,6 +261,8 @@ onMounted(() => void load()) + + @@ -229,6 +277,8 @@ onMounted(() => void load()) {{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }} {{ target.feeItemName }} {{ target.supplierName || '—' }} + {{ settlementLabel(target) }} + {{ paidSummary(target) }} {{ target.currency }} {{ target.remark || '—' }} @@ -243,6 +293,13 @@ onMounted(() => void load()) + 账期应付已付成本 + @@ -266,6 +323,7 @@ onMounted(() => void load()) .fees-panel :deep(.data-table__toolbar) { min-height: 48px; padding: 6px 12px; } .fees-panel :deep(.el-table th.el-table__cell) { padding: 7px 0; }.fees-panel :deep(.el-table td.el-table__cell) { padding: 6px 0; } .row-actions { display: flex; flex-wrap: wrap; }.allocation-section { padding-top: 16px; border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 14px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.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; } .adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; } @media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } } 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()) + + @@ -265,6 +316,17 @@ onMounted(() => void load()) + 账期应付已付成本 + @@ -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); } }