From 18a78e55086831484e38c5dde35d86175d9b00ac Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 26 Oct 2026 13:43:08 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B9=E7=94=A8=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/navigation/adminNavigation.ts | 1 + src/modules/finance/api/finance.test.ts | 25 +- src/modules/finance/api/finance.ts | 7 +- .../pages/FinanceAccountDialogs.test.ts | 106 +++++- .../pages/FinanceCustomerAccountsView.test.ts | 2 +- .../pages/FinanceCustomerAccountsView.vue | 78 ++++- .../api/overseasFees.test.ts | 75 ++++ .../overseas-business/api/overseasFees.ts | 99 ++++++ .../api/platformOverseasBusiness.ts | 4 +- .../api/procurementCommands.ts | 1 + .../overseas-business/api/storageRent.test.ts | 33 ++ .../overseas-business/api/storageRent.ts | 39 +++ .../components/BusinessDocumentFees.test.ts | 59 ++++ .../components/BusinessDocumentFees.vue | 63 ++++ .../components/BusinessOrderFees.test.ts | 328 ++++++++++++++++++ .../components/BusinessOrderFees.vue | 217 ++++++++++++ .../components/OverseasFeeEditor.vue | 150 ++++++++ .../OverseasFeeRetryHistory.test.ts | 65 ++++ .../components/OverseasFeeRetryHistory.vue | 55 +++ .../components/ProcurementControlFields.vue | 4 +- .../components/ProcurementFinanceFields.vue | 8 +- .../components/ProcurementNodeActions.test.ts | 34 +- .../components/ProcurementNodeActions.vue | 35 +- .../components/ProcurementPhysicalFields.vue | 12 +- .../components/ProcurementTransportFields.vue | 10 +- .../components/StorageRentFeeActions.vue | 61 ++++ .../components/StorageRentFeeDetails.vue | 27 ++ .../components/StorageRentPanel.test.ts | 103 ++++++ .../components/StorageRentPanel.vue | 96 +++++ src/modules/overseas-business/index.ts | 5 + .../overseas-business/overseasFeeDetails.css | 8 + .../overseasFeePresentation.test.ts | 80 +++++ .../overseasFeePresentation.ts | 107 ++++++ .../overseasFeeRetryPresentation.test.ts | 41 +++ .../overseasFeeRetryPresentation.ts | 34 ++ .../pages/LooseboardOrderDetailView.test.ts | 76 ++++ .../pages/LooseboardOrderDetailView.vue | 87 +++++ .../pages/OverseasFeesView.test.ts | 215 ++++++++++++ .../pages/OverseasFeesView.vue | 186 ++++++++++ .../pages/PurchaseOrderDetailView.vue | 15 +- .../pages/PurchaseOrdersView.vue | 8 +- .../pages/TransportOrdersView.test.ts | 68 ++++ .../pages/TransportOrdersView.vue | 20 +- .../pages/TruckOrderDetailView.test.ts | 10 +- .../pages/TruckOrderDetailView.vue | 2 + .../overseas-business/procurementDirection.ts | 9 + .../overseas-business/procurementPending.ts | 3 +- .../procurementPresentation.ts | 3 +- .../overseas-business/procurementTypes.ts | 1 + src/modules/overseas-business/routes.test.ts | 6 +- src/modules/overseas-business/routes.ts | 12 + .../storageRentPresentation.test.ts | 49 +++ .../storageRentPresentation.ts | 41 +++ src/modules/overseas-business/types.ts | 1 + .../useFeeStatusRefresh.test.ts | 72 ++++ .../overseas-business/useFeeStatusRefresh.ts | 28 ++ .../overseas-warehouse/api/lastMileOrders.ts | 4 +- .../pages/CargoDiscardDialogs.test.ts | 12 + .../pages/CargoDiscardsView.vue | 5 + .../pages/DeliveryOrderDetailView.test.ts | 8 + .../pages/DeliveryOrderDetailView.vue | 8 +- .../pages/InboundOrderDetailView.test.ts | 43 +++ .../pages/InboundOrderDetailView.vue | 7 +- .../pages/LastMileOrderDetailView.test.ts | 112 +++++- .../pages/LastMileOrderDetailView.vue | 131 +++++-- .../pages/LastMileOrdersView.behavior.test.ts | 27 ++ .../pages/LastMileOrdersView.vue | 11 +- .../pages/RelabelOrderDetailView.test.ts | 33 +- .../pages/RelabelOrderDetailView.vue | 10 +- .../UnifiedOrdersView.fulfillment.test.ts | 15 +- .../pages/UnifiedOrdersView.vue | 3 +- src/shared/components/DataTable.test.ts | 11 +- src/shared/components/DataTable.vue | 26 +- src/styles/adminDensity.test.ts | 5 + 74 files changed, 3339 insertions(+), 116 deletions(-) create mode 100644 src/modules/overseas-business/api/overseasFees.test.ts create mode 100644 src/modules/overseas-business/api/overseasFees.ts create mode 100644 src/modules/overseas-business/api/storageRent.test.ts create mode 100644 src/modules/overseas-business/api/storageRent.ts create mode 100644 src/modules/overseas-business/components/BusinessDocumentFees.test.ts create mode 100644 src/modules/overseas-business/components/BusinessDocumentFees.vue create mode 100644 src/modules/overseas-business/components/BusinessOrderFees.test.ts create mode 100644 src/modules/overseas-business/components/BusinessOrderFees.vue create mode 100644 src/modules/overseas-business/components/OverseasFeeEditor.vue create mode 100644 src/modules/overseas-business/components/OverseasFeeRetryHistory.test.ts create mode 100644 src/modules/overseas-business/components/OverseasFeeRetryHistory.vue create mode 100644 src/modules/overseas-business/components/StorageRentFeeActions.vue create mode 100644 src/modules/overseas-business/components/StorageRentFeeDetails.vue create mode 100644 src/modules/overseas-business/components/StorageRentPanel.test.ts create mode 100644 src/modules/overseas-business/components/StorageRentPanel.vue create mode 100644 src/modules/overseas-business/overseasFeeDetails.css create mode 100644 src/modules/overseas-business/overseasFeePresentation.test.ts create mode 100644 src/modules/overseas-business/overseasFeePresentation.ts create mode 100644 src/modules/overseas-business/overseasFeeRetryPresentation.test.ts create mode 100644 src/modules/overseas-business/overseasFeeRetryPresentation.ts create mode 100644 src/modules/overseas-business/pages/LooseboardOrderDetailView.test.ts create mode 100644 src/modules/overseas-business/pages/LooseboardOrderDetailView.vue create mode 100644 src/modules/overseas-business/pages/OverseasFeesView.test.ts create mode 100644 src/modules/overseas-business/pages/OverseasFeesView.vue create mode 100644 src/modules/overseas-business/pages/TransportOrdersView.test.ts create mode 100644 src/modules/overseas-business/procurementDirection.ts create mode 100644 src/modules/overseas-business/storageRentPresentation.test.ts create mode 100644 src/modules/overseas-business/storageRentPresentation.ts create mode 100644 src/modules/overseas-business/useFeeStatusRefresh.test.ts create mode 100644 src/modules/overseas-business/useFeeStatusRefresh.ts create mode 100644 src/modules/overseas-warehouse/pages/InboundOrderDetailView.test.ts diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 9ffd0e08..9fc1a023 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -146,6 +146,7 @@ export const adminNavigation: AdminNavigationNode[] = [ icon: PrototypeOverseasBusinessIcon, children: [ { key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' }, + { key: 'overseas-fees', title: '海外费用管理', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' }, { key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' }, { key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' }, { key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] }, diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index 6fe6106f..27f36b7d 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' +import { refundFinanceConsumption } from './finance' import { actionReconciliationStatement, adjustFinanceAccount, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, downloadFinanceRechargeVoucher, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceRechargeRequests, listFinanceStatements, reviewFinanceFeeSource, reviewFinanceRechargeRequest, updateFinanceBill, updateFinanceCreditLimit, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance' describe('finance api', () => { @@ -88,7 +89,7 @@ describe('finance api', () => { }) it('uses the formal G1 account ledger path and fixed page DTO', async () => { - const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } }) + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', walletBusinessNo: 'AC202608110001', changeType: 'RECHARGE', walletAmount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } }) const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' }) @@ -96,6 +97,22 @@ describe('finance api', () => { expect(result.records[0]).toMatchObject({ ledgerId: '81', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceNo: 'RC-91' }) }) + it('preserves storage rent freeze and settlement amounts from the account ledger DTO', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [ + { ledgerId: '61', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230003', changeType: 'CONSUMPTION', walletAmount: -10.01, actualBalanceAfter: 9905.22, frozenBalanceAfter: 35.47, availableBalanceAfter: 9969.73 }, + { ledgerId: '60', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230002', changeType: 'FREEZE', walletAmount: -10.01, actualBalanceAfter: 9915.23, frozenBalanceAfter: 45.48, availableBalanceAfter: 9969.73 }, + { ledgerId: '59', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230001', changeType: 'FREEZE', walletAmount: 0 }, + { ledgerId: '58', documentType: 'SETTLEMENT' }, + ], page: { total: 4 } }) + + const { records } = await listFinanceAccountLedger('10001') + + expect(records[0]).toMatchObject({ businessNo: 'AC202610230003', amount: '-10.01', actualBalanceAfter: '9905.22', frozenBalanceAfter: '35.47', availableBalanceAfter: '9969.73' }) + expect(records[1]).toMatchObject({ businessNo: 'AC202610230002', amount: '-10.01', actualBalanceAfter: '9915.23', frozenBalanceAfter: '45.48', availableBalanceAfter: '9969.73' }) + expect(records[2].amount).toBe('0') + expect(records[3].amount).toBeNull() + }) + it('uses frozen recharge review and account adjustment contracts', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ requestId: 91, requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: 81, voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', createdAt: '2026-09-04T10:00:00' }], page: { page: 1, pageSize: 200, total: 1 } }) const post = vi.spyOn(http, 'post') @@ -311,3 +328,9 @@ describe('finance api', () => { }) }) + +it('submits linked consumption refund with the caller retry key', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { changeType: 'REFUND', referenceNo: 'AC-61' } }) + await expect(refundFinanceConsumption('9007199254740993', '61', { amount: '12.30', reason: '多扣' }, 'stable-key')).resolves.toMatchObject({ referenceNo: 'AC-61' }) + expect(post).toHaveBeenCalledWith('/platform/finance/v1/accounts/9007199254740993/ledger/61/refunds', { amount: '12.30', reason: '多扣' }, { headers: { 'Idempotency-Key': 'stable-key' } }) +}) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 037a29b8..97f4830d 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -155,7 +155,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo), receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange), outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark), - businessNo: nullableStringValue(item.businessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.amount), + businessNo: nullableStringValue(item.walletBusinessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.walletAmount), actualBalanceAfter: nullableStringValue(item.actualBalanceAfter), frozenBalanceAfter: nullableStringValue(item.frozenBalanceAfter), availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType), creditLimitAfter: nullableStringValue(item.creditLimitAfter), creditUsedAfter: nullableStringValue(item.creditUsedAfter), @@ -208,6 +208,11 @@ export async function updateFinanceCreditLimit(customerId: string, payload: { cr return unwrapApiResponse(response) } +export async function refundFinanceConsumption(customerId: string, ledgerId: string, payload: { amount: string; reason: string }, key: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/accounts/${encodeURIComponent(customerId)}/ledger/${encodeURIComponent(ledgerId)}/refunds`, payload, { headers: { 'Idempotency-Key': key } }) + return unwrapApiResponse(response) +} + export async function listFinanceFeeSources(params: { page?: number size?: number diff --git a/src/modules/finance/pages/FinanceAccountDialogs.test.ts b/src/modules/finance/pages/FinanceAccountDialogs.test.ts index 843fe859..879549c9 100644 --- a/src/modules/finance/pages/FinanceAccountDialogs.test.ts +++ b/src/modules/finance/pages/FinanceAccountDialogs.test.ts @@ -6,7 +6,7 @@ import { ElMessage } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import { listPlatformTenants } from '@/modules/iam' import * as api from '../api/finance' -import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types' +import type { FinanceAccountSummary, FinanceLedgerEntry, FinanceRechargeRequest } from '../types' import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue' const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus') @@ -17,7 +17,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) vi.mock('../api/finance', () => ({ listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(), - reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(), + reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(), updateFinanceCreditLimit: vi.fn(), downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(), })) const account: FinanceAccountSummary = { @@ -287,3 +287,105 @@ describe('customer wallet native dialogs', () => { expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true) }) }) + +const consumption: FinanceLedgerEntry = { + ledgerId: '61', businessNo: 'AC-CONSUME-61', changeType: 'CONSUMPTION', amount: '-80.00', + sourceNo: 'RELABEL-1', occurredAt: '', businessType: 'ADJUSTMENT', documentType: '', documentId: '', documentNo: '', + receivableChange: 0, payableChange: 0, outstandingReceivableAfter: 0, operatorName: '', remark: '', +} +async function openRefundDialog() { + vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption], total: 1, pages: 1 }) + const wrapper = await mountPage() + await click(wrapper, '退款', '.ledger-table button') + return { wrapper, dialog: activeDialog(wrapper) } +} +describe('linked original consumption refunds', () => { + it.each(['', '0', '-1', '80.01', '1.001', 'NaN'])('rejects invalid refund %s', async amount => { + const { dialog } = await openRefundDialog() + await dialog.get('input[placeholder="请输入退款金额"]').setValue(amount) + await dialog.get('textarea').setValue('测试退款') + await click(dialog, '确认退款') + expect(api.refundFinanceConsumption).not.toHaveBeenCalled() + }) + it('requires a reason and keeps the original consumption visible', async () => { + const { dialog } = await openRefundDialog() + expect(dialog.text()).toContain('AC-CONSUME-61') + expect(dialog.text()).toContain('RELABEL-1') + await dialog.get('input[placeholder="请输入退款金额"]').setValue('10') + await click(dialog, '确认退款') + expect(api.refundFinanceConsumption).not.toHaveBeenCalled() + }) + it('locks a pending refund, preserves failures, and retries the identical payload with the same key', async () => { + const pending = deferred>() + vi.mocked(api.refundFinanceConsumption).mockReturnValueOnce(pending.promise).mockResolvedValueOnce({}) + const { wrapper, dialog } = await openRefundDialog() + await dialog.get('input[placeholder="请输入退款金额"]').setValue('12.3') + await dialog.get('textarea').setValue(' 原单多扣 ') + await click(dialog, '确认退款') + expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false }) + expect(dialog.get('textarea').attributes('disabled')).toBeDefined() + await click(dialog, '取消') + expect(dialog.props('modelValue')).toBe(true) + pending.reject(failure()) + await flushPromises() + expect(dialog.text()).toContain('测试操作未成功') + const first = vi.mocked(api.refundFinanceConsumption).mock.calls[0]! + expect(first).toEqual([account.customerId, '61', { amount: '12.30', reason: '原单多扣' }, expect.stringMatching(/^finance-refund-/)]) + await click(dialog, '确认退款') + expect(vi.mocked(api.refundFinanceConsumption).mock.calls[1]).toEqual(first) + expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('账户流水') + }) + it('creates a new key for changed amount after a rejected request', async () => { + vi.mocked(api.refundFinanceConsumption).mockRejectedValue(failure()) + const { dialog } = await openRefundDialog() + await dialog.get('input[placeholder="请输入退款金额"]').setValue('10') + await dialog.get('textarea').setValue('退款') + await click(dialog, '确认退款') + await dialog.get('input[placeholder="请输入退款金额"]').setValue('11') + await click(dialog, '确认退款') + const calls = vi.mocked(api.refundFinanceConsumption).mock.calls + expect(calls[0]![3]).not.toBe(calls[1]![3]) + }) + it('hides refunds without adjustment permission and labels refund original references', async () => { + can.mockImplementation(permission => permission !== 'platform:finance:customer-account:adjust') + vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption, { ...consumption, ledgerId: '62', changeType: 'REFUND', amount: '10.00', referenceNo: 'AC-CONSUME-61' }], total: 2, pages: 1 }) + const wrapper = await mountPage() + expect(wrapper.findAll('.ledger-table button').some(button => button.text() === '退款')).toBe(false) + expect(wrapper.text()).toContain('消费退款') + expect(wrapper.text()).toContain('原消费:') + }) +}) + +describe('customer wallet detail error recovery', () => { + it.each(['ACCOUNT', 'LEDGER'] as const)('reloads the current detail after %s fails without same-route navigation', async failurePoint => { + if (failurePoint === 'ACCOUNT') vi.mocked(api.listFinanceAccounts).mockRejectedValueOnce(failure()) + else vi.mocked(api.listFinanceAccountLedger).mockRejectedValueOnce(failure()) + const wrapper = await mountPage() + expect(wrapper.get('.load-error-state').text()).toContain('测试操作未成功') + const pending = deferred>>() + vi.mocked(api.listFinanceAccounts).mockReturnValueOnce(pending.promise) + await click(wrapper, '重新加载', '.load-error-state button') + expect(api.listFinanceAccounts).toHaveBeenCalledTimes(2) + pending.resolve({ records: [{ ...account, actualBalance: '90.00' }], total: 1, pages: 1 }) + await flushPromises() + expect(wrapper.find('.load-error-state').exists()).toBe(false) + expect(wrapper.get('[aria-label="客户资金账户余额"]').text()).toContain('¥90.00') + expect(wrapper.find('.ledger-table').exists()).toBe(true) + expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2) + expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2) + }) +}) + +it('enforces the refund contract reason limit before submitting', async () => { + const { wrapper, dialog } = await openRefundDialog() + expect(dialog.get('textarea').attributes('maxlength')).toBe('500') + await dialog.get('input[placeholder="请输入退款金额"]').setValue('10') + const vm = wrapper.vm as unknown as { refundForm: { reason: string } } + vm.refundForm.reason = '退'.repeat(501) + await click(dialog, '确认退款') + expect(api.refundFinanceConsumption).not.toHaveBeenCalled() + vm.refundForm.reason = '退'.repeat(500) + await click(dialog, '确认退款') + expect(api.refundFinanceConsumption).toHaveBeenCalledWith(account.customerId, consumption.ledgerId, { amount: '10.00', reason: '退'.repeat(500) }, expect.any(String)) +}) diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts index c81dc448..daa356e5 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts @@ -18,7 +18,7 @@ vi.mock('../api/finance', () => ({ listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(), reviewFinanceRechargeRequest: vi.fn(), - adjustFinanceAccount: vi.fn(), + adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(), updateFinanceCreditLimit: vi.fn(), downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index 7906fb21..6033a7ee 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -22,7 +22,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' -import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance' +import { refundFinanceConsumption, adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance' import { financeBusinessTypeLabel } from '../status' import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types' @@ -45,6 +45,7 @@ const saving = ref(false) const selected = ref(null) const ledger = ref([]) const ledgerLoading = ref(false) +const detailLoading = ref(false) const ledgerError = ref(null) const selectedTerms = ref(null) const creditDays = ref(30) @@ -57,6 +58,12 @@ const rechargeLoading = ref(false) const rechargeError = ref(null) const accountExportBusy = ref(false) const ledgerExportBusy = ref(false) +const refundOpen = ref(false) +const refundSaving = ref(false) +const refundSource = ref<{ customerId: string; entry: FinanceLedgerEntry } | null>(null) +const refundForm = reactive({ amount: '', reason: '' }) +const refundError = ref(null) +let refundAttempt = { payload: '', key: '' } const adjustmentOpen = ref(false) const adjustmentSaving = ref(false) const adjustmentAccount = ref(null) @@ -114,6 +121,7 @@ const ledgerQueryFields: QueryField[] = [ { key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 }, ] const ledgerColumns: DataTableColumn[] = [ + { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' }, { key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' }, { key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' }, @@ -204,7 +212,15 @@ async function refreshWalletDetail() { if (!selected.value) return await Promise.all([loadRechargeRequests(), loadLedger()]) } -async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record, editing.value) } +async function retryDrawer() { + if (!canView.value || detailLoading.value) return + if (editing.value) { + if (selected.value) await openAccount(selected.value as unknown as Record, true) + return + } + const customerId = String(route.params.customerId || selected.value?.customerId || '') + if (customerId) await restoreAccountDetail(customerId) +} async function saveTerms() { if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return } saving.value = true @@ -237,7 +253,41 @@ function paymentMethodLabel(value: unknown) { return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record)[String(value)] || '其他' } function walletBusinessType(value: unknown) { - return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', CREDIT_LIMIT: '授信调整' } as Record)[String(value)] || ledgerBusinessType(value) + return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', REFUND: '消费退款', CREDIT_LIMIT: '授信调整' } as Record)[String(value)] || ledgerBusinessType(value) +} +function canRefund(entry: FinanceLedgerEntry) { + return canAdjust.value && entry.changeType === 'CONSUMPTION' && !!entry.ledgerId && Number(entry.amount) < 0 +} +function openRefund(entry: FinanceLedgerEntry) { + if (!selected.value || !canRefund(entry) || refundSaving.value) return + refundSource.value = { customerId: selected.value.customerId, entry: { ...entry } } + Object.assign(refundForm, { amount: '', reason: '' }) + refundError.value = null + refundAttempt = { payload: '', key: '' } + refundOpen.value = true +} +async function submitRefund() { + const source = refundSource.value + if (refundSaving.value || !source || !canRefund(source.entry) || selected.value?.customerId !== source.customerId) return + const raw = refundForm.amount.trim() + const amount = Number(raw) + if (!/^\d+(?:\.\d{1,2})?$/.test(raw) || !Number.isFinite(amount) || amount <= 0 || amount > Math.abs(Number(source.entry.amount))) { + ElMessage.warning('退款金额必须大于 0、最多两位小数,且不能超过原消费金额'); return + } + const reason = refundForm.reason.trim() + if (!reason || reason.length > 500) { ElMessage.warning('请填写退款说明(最多 500 字)'); return } + const payload = { amount: amount.toFixed(2), reason } + const signature = JSON.stringify([source.customerId, source.entry.ledgerId, payload]) + if (refundAttempt.payload !== signature) refundAttempt = { payload: signature, key: 'finance-refund-' + crypto.randomUUID() } + refundSaving.value = true + refundError.value = null + try { + await refundFinanceConsumption(source.customerId, source.entry.ledgerId, payload, refundAttempt.key) + refundOpen.value = false + ElMessage.success('消费退款已入账') + if (selected.value?.customerId === source.customerId) await refreshWalletDetail() + } catch (error) { refundError.value = getApiErrorDetails(error, '退款失败,请重试') } + finally { refundSaving.value = false } } function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') { adjustmentAccount.value = account @@ -425,12 +475,15 @@ async function closeDetail() { if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' }) } async function restoreAccountDetail(customerId: string) { + if (!canView.value || detailLoading.value) return + detailLoading.value = true detailOpen.value = true; editing.value = false; ledgerError.value = null try { selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return } await Promise.all([loadLedger(), loadRechargeRequests()]) } catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') } + finally { detailLoading.value = false } } onMounted(() => { const customerId = String(route.params.customerId || '') @@ -468,7 +521,7 @@ onBeforeUnmount(clearVoucherPreview) + +
+

原消费(CNY)

+ + + +
原消费流水{{ refundSource?.entry.businessNo || refundSource?.entry.ledgerId }}
关联业务单据{{ refundSource?.entry.sourceNo || refundSource?.entry.documentNo || '—' }}
原消费金额{{ money(refundSource?.entry.amount) }}
+ +
+
+ + +
+
+
diff --git a/src/modules/overseas-business/api/overseasFees.test.ts b/src/modules/overseas-business/api/overseasFees.test.ts new file mode 100644 index 00000000..7ed812d5 --- /dev/null +++ b/src/modules/overseas-business/api/overseasFees.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })) +vi.mock('@/shared/api/http', async original => ({ ...await original(), http: client })) +import { actOnOverseasFee, createOverseasFee, getOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, listOverseasFeeItems, listOverseasFees, listOverseasFeeRetries, type SaveOverseasFee } from './overseasFees' +const base = '/platform/v1/last-leg/finance-fee-sources' +const envelope = (data: T) => ({ code: '200', message: 'success', data }) +const row = { id: '9007199254740993', sourceFeeId: '9007199254740993', customerId: '1', businessId: '9007199254740995', direction: 'RECEIVABLE', allowedActions: ['APPROVE'] } +describe('海外费用真实接口', () => { + beforeEach(() => vi.clearAllMocks()) + it('分页不退回旧非分页接口,长整型身份保持字符串', async () => { + client.get.mockResolvedValue(envelope({ records: [row], total: '1', pages: '1', page: 1, size: 20 })) + const result = await listOverseasFees({ page: 1, size: 20, feeItemCode: 'F1' }) + expect(client.get).toHaveBeenCalledWith(`${base}/page`, { params: { page: 1, size: 20, feeItemCode: 'F1' } }) + expect(result.records[0]?.businessId).toBe('9007199254740995') + expect(result.total).toBe(1) + client.get.mockResolvedValue(envelope(row)) + expect((await getOverseasFee(row.sourceFeeId)).sourceFeeId).toBe(row.sourceFeeId) + }) + it('创建传正式业务订单及幂等键,审核和锁款重试各用正式命令', async () => { + client.post.mockResolvedValue(envelope(row)) + const payload = { businessNo: 'WT1', businessType: 'WAREHOUSE_TRANSFER', amount: 10 } as SaveOverseasFee + await createOverseasFee(payload, 'create-1') + expect(client.post).toHaveBeenCalledWith(base, payload, { headers: { 'Idempotency-Key': 'create-1' } }) + await actOnOverseasFee(row.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' }) + expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/review`, { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' }) + await actOnOverseasFee(row.sourceFeeId, 'RETRY_FREEZE', { idempotencyKey: 'retry-1' }) + expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-funding`, { idempotencyKey: 'retry-1' }) + await actOnOverseasFee(row.sourceFeeId, 'RETRY_PUBLISH', { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' }) + expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-delivery`, { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' }) + }) + it('目录和业务订单选项读取真实数据,不套用人民币默认价', async () => { + client.get.mockResolvedValueOnce(envelope([{ code: 'F1', name: '操作费', defaultPrice: 10 }])) + .mockResolvedValueOnce(envelope([{ businessId: '9007199254740995', customerId: '1', orderId: null, warehouseId: '2' }])) + expect(await listOverseasFeeItems()).toEqual([{ feeCode: 'F1', feeTypeName: '操作费', defaultPrice: 10 }]) + expect(client.get).toHaveBeenLastCalledWith(`${base}/fee-items`, { params: { direction: 'RECEIVABLE' } }) + expect((await listBusinessFeeOptions({ businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' }))[0]?.businessId).toBe('9007199254740995') + expect(client.get).toHaveBeenLastCalledWith(`${base}/business-options`, { params: { businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' } }) + }) + it('应付项目明确传递方向', async () => { + client.get.mockResolvedValue(envelope([])) + await listOverseasFeeItems('PAYABLE') + expect(client.get).toHaveBeenCalledWith(`${base}/fee-items`, { params: { direction: 'PAYABLE' } }) + }) + it('按稳定费用身份查询完整重试记录', async () => { + client.get.mockResolvedValue(envelope([])) + expect(await listOverseasFeeRetries(row.sourceFeeId)).toEqual([]) + expect(client.get).toHaveBeenCalledWith(`${base}/${row.sourceFeeId}/retries`) + }) + it('单据费用先严格匹配客户、类型及ID,合并去重后分页', async () => { + const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '测试客户', orderId: null, warehouseId: null } + const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' } + const first = { ...row, businessType: business.businessType, createdAt: '2026-10-22T10:00:00' } + const second = { ...first, sourceFeeId: '9007199254740994', businessId: related.businessId, businessType: related.businessType } + client.get.mockResolvedValue(envelope([first, second, first, + { ...first, sourceFeeId: 'wrong-id', businessId: 'another' }, + { ...first, sourceFeeId: 'wrong-type', businessType: 'RETURN_ORDER' }, + { ...first, sourceFeeId: 'wrong-customer', customerId: '2' }, + ])) + const result = await listBusinessOverseasFees([business, related], { page: 2, size: 1 }) + expect(client.get).toHaveBeenCalledWith(base, { params: { customerId: '1', businessType: 'LASTLEG_ORDER', businessNo: 'TB1' } }) + expect(result).toMatchObject({ total: 2, pages: 2, page: 2, size: 1 }) + expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId]) + }) + it.each(['RECEIVABLE', 'PAYABLE'] as const)('混合返回时先按 %s 分开,再统计分页,不遗漏第二页数据', async direction => { + const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '客户', orderId: null, warehouseId: null } + const first = { ...row, businessType: 'LASTLEG_ORDER', direction, createdAt: '2026-10-23T10:00:00' } + const second = { ...first, sourceFeeId: 'second', createdAt: '2026-10-23T11:00:00' } + const opposite = { ...first, sourceFeeId: 'opposite', direction: direction === 'RECEIVABLE' ? 'PAYABLE' : 'RECEIVABLE' } + client.get.mockResolvedValue(envelope([first, opposite, second, { ...first, sourceFeeId: 'unknown', direction: null }])) + const result = await listBusinessOverseasFees(business, { page: 2, size: 1 }, direction) + expect(result).toMatchObject({ total: 2, pages: 2 }) + expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId]) + expect(result.records.every(fee => fee.direction === direction)).toBe(true) + }) +}) diff --git a/src/modules/overseas-business/api/overseasFees.ts b/src/modules/overseas-business/api/overseasFees.ts new file mode 100644 index 00000000..962001d0 --- /dev/null +++ b/src/modules/overseas-business/api/overseasFees.ts @@ -0,0 +1,99 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export interface FeePage { records: T[]; page: number; size: number; total: number; pages: number } +export type OverseasFeeDirection = 'RECEIVABLE' | 'PAYABLE' +export interface OverseasFee { + storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null; + lines: Array<{ lotId: string; inboundAt: string; quantity: number; days: number; calculatedAmount: number; + sourceBusinessType?: string | null; sourceBusinessNo?: string | null; batchNo?: string | null }> } | null + id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string + sourceType: string; businessType: string; businessId: string | null; businessNo: string + displayBusinessType?: string + direction: OverseasFeeDirection + supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null + settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null + paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null + orderId: string | null; warehouseId: string | null; feeItemCode: string; feeItemName: string + feeRemark?: string | null + businessDate: string; currency: string; amount: number; originalCurrency: string; originalAmount: number + exchangeRate?: number | null; exchangeRateMonth?: string | null + chargeableQuantity: number | null; quantityUnit: string | null; unitPrice: number | null + periodStart: string | null; periodEnd: string | null; reviewStatus: string; sourceStatus: string + publicationStatus: string; collectionMode: 'BILL' | 'WALLET'; walletStatus: string | null + deliveryStatus?: 'PENDING' | 'PROCESSING' | 'PUBLISHED' | 'DEAD' | null + deliveryKind?: 'FEE' | 'FUNDING' | null; deliveryEventId?: string | null; deliveryErrorMessage?: string | null + walletErrorMessage: string | null; reviewReason: string | null; reviewerId?: string | null; reviewerName?: string | null; reviewedAt: string | null + createdAt: string; updatedAt: string; allowedActions: string[] +} +export interface BusinessFeeOption { + businessId: string; businessType: string; businessNo: string; customerId: string; customerName: string + warehouseId: string | null; orderId: string | null; orderType?: string +} +export interface SaveOverseasFee { + customerId: string; customerName: string; sourceType: string; businessType: string + businessId: string; businessNo: string; orderId: string | null; warehouseId: string | null + feeItemCode: string; feeItemName: string; direction: OverseasFeeDirection; businessDate: string + feeRemark?: string | null + supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null + settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null + paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null + currency: 'CNY' | 'USD'; amount: number + chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number +} +const base = '/platform/v1/last-leg/finance-fee-sources' +export interface OverseasFeeRetryRecord { + requestId: string; requestedAt: string; operatorName: string; action: string + result: 'SUCCESS' | 'FAILED' | 'SUBMITTED'; errorMessage: string | null +} +export async function listOverseasFeeRetries(id: string): Promise { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/${encodeURIComponent(id)}/retries`)) +} +export async function listOverseasFeeItems(direction: 'RECEIVABLE' | 'PAYABLE' = 'RECEIVABLE') { + const response = await http.get>, ApiEnvelope>>(`${base}/fee-items`, { params: { direction } }) + return unwrapApiResponse(response).map(item => ({ feeCode: item.code, feeTypeName: item.name, defaultPrice: item.defaultPrice })) +} +export async function listOverseasFeeCustomers() { + const response = await http.get>, ApiEnvelope>>(`${base}/customer-options`) + return unwrapApiResponse(response).map(row => ({ ...row, customerId: String(row.customerId) })) +} +function normalizeFee(value: OverseasFee): OverseasFee { + return { ...value, id: String(value.id), sourceFeeId: String(value.sourceFeeId), customerId: String(value.customerId), + businessId: value.businessId == null ? null : String(value.businessId), orderId: value.orderId == null ? null : String(value.orderId), + warehouseId: value.warehouseId == null ? null : String(value.warehouseId), allowedActions: value.allowedActions || [] } +} +export async function listOverseasFees(query: Record): Promise> { + const response = await http.get>, ApiEnvelope>>(`${base}/page`, { params: query }) + const data = unwrapApiResponse(response) + return { ...data, records: data.records.map(normalizeFee), total: Number(data.total), pages: Number(data.pages) } +} +export async function listBusinessOverseasFees(business: BusinessFeeOption | BusinessFeeOption[], pagination: { page: number; size: number }, direction: OverseasFeeDirection = 'RECEIVABLE'): Promise> { + const { page, size } = pagination + const scopes = Array.isArray(business) ? business : [business] + if (!scopes.length || scopes.some(scope => !scope.businessId || !scope.customerId || !scope.businessType)) throw new Error('业务单据信息不完整') + // 非分页接口返回两种方向;先按客户、业务身份及收付方向匹配,再合并分页。 + const groups = await Promise.all(scopes.map(async scope => { + const response = await http.get, ApiEnvelope>(base, { + params: { customerId: scope.customerId, businessType: scope.businessType, businessNo: scope.businessNo }, + }) + return unwrapApiResponse(response).map(normalizeFee).filter(row => row.customerId === scope.customerId && row.businessType === scope.businessType && row.businessId === scope.businessId && row.direction === direction) + })) + const records = [...new Map(groups.flat().map(row => [row.sourceFeeId, row])).values()] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.sourceFeeId.localeCompare(left.sourceFeeId)) + return { records: records.slice((page - 1) * size, page * size), total: records.length, pages: Math.ceil(records.length / size), page, size } +} +export async function getOverseasFee(id: string) { + return normalizeFee(unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/${encodeURIComponent(id)}`))) +} +export async function listBusinessFeeOptions(params: { businessType: string; keyword?: string; customerId?: string }) { + const response = await http.get, ApiEnvelope>(`${base}/business-options`, { params }) + return unwrapApiResponse(response).map(row => ({ ...row, businessId: String(row.businessId), customerId: String(row.customerId), orderId: row.orderId == null ? null : String(row.orderId), warehouseId: row.warehouseId == null ? null : String(row.warehouseId) })) +} +export async function createOverseasFee(payload: SaveOverseasFee, key: string) { + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(base, payload, { headers: { 'Idempotency-Key': key } }))) +} +export async function actOnOverseasFee(id: string, action: string, body: Record) { + const endpoint = action === 'APPROVE' || action === 'REJECT' ? 'review' + : ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? 'retry-delivery' + : action.startsWith('RETRY_') ? 'retry-funding' : action.toLowerCase() + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/${encodeURIComponent(id)}/${endpoint}`, body))) +} diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.ts b/src/modules/overseas-business/api/platformOverseasBusiness.ts index 807c5bde..0ecb7ac3 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.ts @@ -57,8 +57,8 @@ export async function listOverseasPurchaseCustomerOptions(keyword = '', limit = const response = await http.get, ApiEnvelope>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } }) return unwrapApiResponse(response) } -export async function listOverseasPurchaseWarehouseOptions() { - const response = await http.get, ApiEnvelope>(`${procurementBasePath}/warehouse-options`) +export async function listOverseasPurchaseWarehouseOptions(procurementDirection?: import('../procurementDirection').ProcurementDirection) { + const response = await http.get, ApiEnvelope>(`${procurementBasePath}/warehouse-options`, { params: { procurementDirection } }) return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) })) } export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) { diff --git a/src/modules/overseas-business/api/procurementCommands.ts b/src/modules/overseas-business/api/procurementCommands.ts index f955ea58..14a051a7 100644 --- a/src/modules/overseas-business/api/procurementCommands.ts +++ b/src/modules/overseas-business/api/procurementCommands.ts @@ -3,6 +3,7 @@ import type { ProcurementFile, ProcurementFileCategory, ProcurementRemainingKind export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close' export interface ProcurementCommandBody { + procurementDirection?: import('../procurementDirection').ProcurementDirection name?: string targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string diff --git a/src/modules/overseas-business/api/storageRent.test.ts b/src/modules/overseas-business/api/storageRent.test.ts new file mode 100644 index 00000000..9a27a55e --- /dev/null +++ b/src/modules/overseas-business/api/storageRent.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +const client = vi.hoisted(() => ({ get: vi.fn() })) +vi.mock('@/shared/api/http', async original => ({ ...await original(), http: client })) +import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses } from './storageRent' + +const base = '/platform/v1/last-leg/storage-rent' +const envelope = (data: T) => ({ code: '200', message: 'success', data }) +const lot = { id: '9223372036854775001', customerId: '9223372036854775002', warehouseId: '9223372036854775003', businessNo: 'WT1' } + +describe('仓租查询接口', () => { + beforeEach(() => vi.clearAllMocks()) + it('uses the paginated root endpoint with server filters and preserves long IDs', async () => { + client.get.mockResolvedValue(envelope({ records: [lot], total: '1', page: 1, size: 20, pages: 1 })) + const params = { page: 1, size: 20, businessType: 'WAREHOUSE_TRANSFER', warehouseId: lot.warehouseId, status: 'IN_STOCK' } + const result = await listStorageRentLots(params) + expect(client.get).toHaveBeenCalledWith(base, { params }) + expect(result.total).toBe(1) + expect(result.records[0]).toMatchObject(lot) + }) + it('reads a lot with its actual charges and warehouse options from dedicated endpoints', async () => { + const charge = { id: '9223372036854775004', outboundNo: 'OUT1', quantity: 2, originalAmount: 23, currency: 'CNY', settlementAmount: 23 } + const currencyLot = { ...lot, generatedAmount: null, generatedCurrency: null, generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }] } + client.get.mockResolvedValueOnce(envelope({ lot: currencyLot, charges: [charge] })) + .mockResolvedValueOnce(envelope([{ id: lot.warehouseId, warehouseName: '测试仓' }])) + const detail = await getStorageRentLot(lot.id) + expect(client.get).toHaveBeenNthCalledWith(1, `${base}/${lot.id}`) + expect(detail.lot.id).toBe(lot.id) + expect(detail.lot).toMatchObject(currencyLot) + expect(detail.charges).toEqual([charge]) + expect(await listStorageRentWarehouses()).toEqual([{ id: lot.warehouseId, warehouseName: '测试仓' }]) + expect(client.get).toHaveBeenNthCalledWith(2, `${base}/warehouse-options`) + }) +}) diff --git a/src/modules/overseas-business/api/storageRent.ts b/src/modules/overseas-business/api/storageRent.ts new file mode 100644 index 00000000..71c80c4e --- /dev/null +++ b/src/modules/overseas-business/api/storageRent.ts @@ -0,0 +1,39 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { FeePage } from './overseasFees' +import type { OverseasFee } from './overseasFees' + +export async function adjustStorageRentFee(id: string, body: { expectedVersion: number; amount: number; reason: string; idempotencyKey: string }) { + return unwrapApiResponse(await http.post, ApiEnvelope>(`/platform/v1/last-leg/storage-rent/fees/${encodeURIComponent(id)}/adjust`, body)) +} + +export interface StorageRentLot { + id: string; customerId: string; customerName: string; businessType: string; businessNo: string + warehouseId: string; warehouseName: string; batchNo: string; inboundAt: string; storageDays: number + originalQuantity: number; remainingQuantity: number; quantityUnit: string; billingQuantity: number + billingUnit: string; ruleCode: string; status: string; generatedAmount: number | null + generatedCurrency: string | null; generatedAmounts: Array<{ currency: string; amount: number }> + settledAmount?: number; chargeCount?: number; settledChargeCount?: number +} +export interface StorageRentCharge { + id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number + originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string + createdAt: string; outboundAt: string +} +export interface StorageRentDetail { lot: StorageRentLot; charges: StorageRentCharge[] } +const base = '/platform/v1/last-leg/storage-rent' +function normalizeLot(row: StorageRentLot): StorageRentLot { + return { ...row, id: String(row.id), customerId: String(row.customerId), warehouseId: String(row.warehouseId) } +} +export async function listStorageRentLots(params: Record) { + const response = await http.get>, ApiEnvelope>>(base, { params }) + const data = unwrapApiResponse(response) + return { ...data, total: Number(data.total), records: data.records.map(normalizeLot) } +} +export async function getStorageRentLot(id: string) { + const data = unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/${encodeURIComponent(id)}`)) + return { lot: normalizeLot(data.lot), charges: data.charges.map(row => ({ ...row, id: String(row.id) })) } +} +export async function listStorageRentWarehouses() { + const data = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base}/warehouse-options`)) + return data.map(row => ({ ...row, id: String(row.id) })) +} diff --git a/src/modules/overseas-business/components/BusinessDocumentFees.test.ts b/src/modules/overseas-business/components/BusinessDocumentFees.test.ts new file mode 100644 index 00000000..884417f0 --- /dev/null +++ b/src/modules/overseas-business/components/BusinessDocumentFees.test.ts @@ -0,0 +1,59 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import BusinessDocumentFees from './BusinessDocumentFees.vue' +import { listBusinessFeeOptions } from '../api/overseasFees' + +const can = vi.hoisted(() => vi.fn(() => true)) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('../api/overseasFees', () => ({ listBusinessFeeOptions: vi.fn() })) +vi.mock('./BusinessOrderFees.vue', () => ({ default: { name: 'BusinessOrderFees', props: ['business', 'relatedBusinesses', 'canCreate', 'readonly', 'direction'], template: '
' } })) +const business = { businessType: 'LASTLEG_ORDER', businessId: '9223372036854775001', businessNo: 'TB-01', customerId: 'C1', customerName: '客户', orderId: '9223372036854775001', warehouseId: null } +function page(props = {}) { + return mount(BusinessDocumentFees, { props: { ...business, ...props }, global: { stubs: { + LoadErrorState: { props: ['message'], template: '
{{ message }}
' }, ApiErrorMeta: true, ElSkeleton: true, ElAlert: true, + } } }) +} +beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true); vi.mocked(listBusinessFeeOptions).mockResolvedValue([business]) }) +describe('单据费用归属', () => { + it('passes the payable direction to the review panel', async () => { + const wrapper = page({ direction: 'PAYABLE', canCreate: false }); await flushPromises() + expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props()).toMatchObject({ direction: 'PAYABLE', canCreate: false }) + }) + it('requires exact customer, type, number and long identity', async () => { + vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessNo: 'TB-010' }, { ...business, customerId: 'C2' }, business]) + const wrapper = page(); await flushPromises() + expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business) + expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('canCreate')).toBe(true) + expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('readonly')).toBe(false) + expect(listBusinessFeeOptions).toHaveBeenCalledWith({ businessType: 'LASTLEG_ORDER', customerId: 'C1', keyword: 'TB-01' }) + }) + it('does not permit a missing or ambiguous match to create charges', async () => { + vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessId: 'another' }]) + const wrapper = page(); await flushPromises() + expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false) + expect(wrapper.text()).toContain('未找到唯一关联单据') + }) + it('resolves source order charges alongside tail charges', async () => { + const source = { ...business, businessType: 'RETURN_ORDER', businessId: 'return-id', businessNo: 'TH-01', orderId: null } + vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([source]) + const wrapper = page({ relatedDocuments: [source] }); await flushPromises() + expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([source]) + }) + it('can resolve a unique relabel id without inventing a customer', async () => { + const wrapper = page({ customerId: undefined }); await flushPromises() + expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business').customerId).toBe('C1') + }) + it('keeps the real tail order usable when its source number is only an external reference', async () => { + vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([]) + const wrapper = page({ relatedDocuments: [{ businessType: 'RELABEL_ORDER', businessNo: 'external-reference', customerId: 'C1' }] }) + await flushPromises() + expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business) + expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([]) + }) + it('does not query fees without read permission', async () => { + can.mockReturnValue(false) + const wrapper = page(); await flushPromises() + expect(listBusinessFeeOptions).not.toHaveBeenCalled() + expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false) + }) +}) diff --git a/src/modules/overseas-business/components/BusinessDocumentFees.vue b/src/modules/overseas-business/components/BusinessDocumentFees.vue new file mode 100644 index 00000000..2d56afcb --- /dev/null +++ b/src/modules/overseas-business/components/BusinessDocumentFees.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/modules/overseas-business/components/BusinessOrderFees.test.ts b/src/modules/overseas-business/components/BusinessOrderFees.test.ts new file mode 100644 index 00000000..02bcffb8 --- /dev/null +++ b/src/modules/overseas-business/components/BusinessOrderFees.test.ts @@ -0,0 +1,328 @@ +import ElementPlus, { ElDialog } from 'element-plus' +import { createPinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import OverseasFeeEditor from './OverseasFeeEditor.vue' +import AppDialog from '@/shared/components/AppDialog.vue' +import * as api from '../api/overseasFees' +import type { OverseasFee } from '../api/overseasFees' +import BusinessOrderFees from './BusinessOrderFees.vue' +import { adjustStorageRentFee } from '../api/storageRent' +vi.mock('../api/storageRent', () => ({ adjustStorageRentFee: vi.fn() })) + + +const can = vi.hoisted(() => vi.fn((_permission: string) => true)) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('../api/overseasFees', () => ({ + actOnOverseasFee: vi.fn(), getOverseasFee: vi.fn(), listOverseasFeeCustomers: vi.fn(), listOverseasFeeItems: vi.fn(), + listOverseasFeeRetries: vi.fn(), + listOverseasFees: vi.fn(), listBusinessOverseasFees: vi.fn(), listBusinessFeeOptions: vi.fn(), createOverseasFee: vi.fn(), +})) +const fee: OverseasFee = { + direction: 'RECEIVABLE', + id: '9223372036854775001', sourceFeeId: '9223372036854775001', sourceVersion: 3, + customerId: '9223372036854775002', customerName: '测试客户', sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER', + businessId: '9223372036854775003', businessNo: 'WT202610220001', orderId: null, warehouseId: '9223372036854775004', + feeItemCode: 'HANDLING', feeItemName: '操作费', businessDate: '2026-10-22', currency: 'CNY', amount: 70, + originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null, unitPrice: null, + periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'UNPUBLISHED', + collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewerId: null, reviewedAt: null, + createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'], +} +const business = { businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: fee.orderId } +function deferred() { + let resolve!: (value: T) => void, reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} +async function mountPage() { + const wrapper = mount(BusinessOrderFees, { props: { business }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) + await flushPromises() + return wrapper +} +async function click(wrapper: VueWrapper, label: string, selector = 'button') { + const button = wrapper.findAll(selector).find(node => node.text().trim() === label) + expect(button, label).toBeDefined() + await button!.trigger('click'); await flushPromises() +} +function activeDialog(wrapper: VueWrapper) { + const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('modelValue')) + expect(dialog).toBeDefined() + return dialog!.getComponent({ name: 'ElDialogContent' }) +} +enableAutoUnmount(afterEach) +beforeEach(() => { + vi.resetAllMocks(); can.mockReturnValue(true) + vi.mocked(api.listOverseasFeeRetries).mockResolvedValue([]) + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + vi.mocked(api.listOverseasFeeCustomers).mockResolvedValue([{ customerId: fee.customerId, customerName: fee.customerName }]) + vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }]) + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [fee], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue(fee) + vi.mocked(api.actOnOverseasFee).mockResolvedValue(fee) + vi.mocked(api.createOverseasFee).mockResolvedValue(fee) + vi.mocked(api.listBusinessFeeOptions).mockResolvedValue([{ businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo, + customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: null }]) +}) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) + +describe('单据费用操作', () => { + it.each(['RECEIVABLE', 'PAYABLE'] as const)('hides an empty %s block and reveals it when fees are added', async direction => { + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) + const wrapper = mount(BusinessOrderFees, { props: { business, direction, hideWhenEmpty: true, canCreate: false, heading: direction === 'RECEIVABLE' ? '应收明细' : '应付明细' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) + await flushPromises() + expect(wrapper.isVisible()).toBe(false) + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, direction }], total: 1, page: 1, size: 20, pages: 1 }) + await wrapper.vm.refresh(); await flushPromises() + expect(wrapper.isVisible()).toBe(true) + expect(wrapper.get('h3').text()).toBe(direction === 'RECEIVABLE' ? '应收明细' : '应付明细') + expect(wrapper.text()).toContain('操作费') + expect(wrapper.findAll('button').some(button => button.text().trim() === '新增费用')).toBe(false) + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) + await wrapper.vm.refresh(); await flushPromises() + expect(wrapper.isVisible()).toBe(false) + }) + it('queries the exact document and creates without a second order selector or catalog default amount', async () => { + const wrapper = await mountPage() + expect(api.listBusinessOverseasFees).toHaveBeenCalledWith([business], { page: 1, size: 20 }, 'RECEIVABLE') + await click(wrapper, '新增费用') + expect(api.listBusinessFeeOptions).not.toHaveBeenCalled() + const dialog = activeDialog(wrapper) + expect(dialog.text()).not.toContain('业务类型') + expect(dialog.text()).not.toContain('业务订单') + expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false) + const selects = dialog.findAllComponents({ name: 'ElSelect' }) + selects[1]!.vm.$emit('update:modelValue', 'CNY') + dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.34) + selects[0]!.vm.$emit('update:modelValue', 'HANDLING') + selects[0]!.vm.$emit('change', 'HANDLING') + await flushPromises() + expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(12.34) + expect(dialog.text()).toContain('USD 10.00 · 待审核') + await click(dialog, '保存') + expect(api.createOverseasFee).not.toHaveBeenCalled() + dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '补收本次额外操作费用') + await flushPromises() + await click(dialog, '保存') + expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ ...business, feeRemark: '补收本次额外操作费用', amount: 12.34, currency: 'CNY', businessDate: new Date().toLocaleDateString('sv-SE') }), expect.any(String)) + expect(wrapper.emitted('changed')).toHaveLength(1) + }) + it('checks refreshed eligibility and rejects a fee belonging to another order', async () => { + const wrapper = await mountPage() + vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, allowedActions: [] }) + await click(wrapper, '审核', '.el-table__body-wrapper button') + expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false) + vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, businessId: 'different-order' }) + await click(wrapper, '审核', '.el-table__body-wrapper button') + expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false) + expect(api.actOnOverseasFee).not.toHaveBeenCalled() + }) + it('keeps supplier payables separate and reviews them for settlement without customer billing hints', async () => { + const payable: OverseasFee = { ...fee, businessType: 'LASTLEG_ORDER', direction: 'PAYABLE', supplierId: 'supplier-1', supplierName: '测试供应商', settlementMode: 'ON_ACCOUNT', collectionMode: 'BILL', walletStatus: 'NOT_REQUIRED' } + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [payable], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue(payable) + const wrapper = mount(BusinessOrderFees, { props: { business: { ...business, businessType: 'LASTLEG_ORDER' }, direction: 'PAYABLE' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) + await flushPromises() + expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([expect.objectContaining({ businessType: 'LASTLEG_ORDER' })], { page: 1, size: 20 }, 'PAYABLE') + expect(wrapper.text()).toContain('测试供应商'); expect(wrapper.text()).toContain('推送结算') + expect(wrapper.findAll('button').map(button => button.text())).not.toContain('新增费用') + await click(wrapper, '审核', '.el-table__body-wrapper button') + const dialog = activeDialog(wrapper) + expect(dialog.text()).not.toContain('请核对原报价') + await click(dialog, '审核通过') + expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' })) + }) + it('cannot review the opposite direction returned by a refreshed fee', async () => { + const wrapper = await mountPage() + vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, direction: 'PAYABLE' }) + await click(wrapper, '审核', '.el-table__body-wrapper button') + expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('modelValue'))).toBe(false) + expect(api.actOnOverseasFee).not.toHaveBeenCalled() + }) + it('keeps read-only documents read-only and closes an editor when the order changes', async () => { + const wrapper = await mountPage() + await click(wrapper, '新增费用') + await wrapper.setProps({ business: { ...business, businessId: 'other' } }) + await flushPromises() + expect(wrapper.getComponent(OverseasFeeEditor).props('modelValue')).toBe(false) + await wrapper.setProps({ readonly: true }) + const visibleButtons = wrapper.findAll('button').filter(button => button.isVisible()).map(button => button.text()) + expect(visibleButtons).not.toContain('新增费用') + expect(visibleButtons).not.toContain('审核') + expect(visibleButtons).not.toContain('驳回') + expect(visibleButtons).not.toContain('取消费用') + }) + it('does not create against an order or customer changed after the editor was opened', async () => { + const wrapper = mount(OverseasFeeEditor, { props: { modelValue: false, fee: null, business, items: [{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }] }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) + await wrapper.setProps({ modelValue: true }); await flushPromises() + const dialog = activeDialog(wrapper) + dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'HANDLING') + dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10) + await wrapper.setProps({ business: { ...business, customerId: 'different-customer' } }) + await flushPromises(); await click(dialog, '保存') + expect(api.createOverseasFee).not.toHaveBeenCalled() + }) + it('blocks automatic storage rent from manual addition', async () => { + vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }]) + const wrapper = await mountPage() + await click(wrapper, '新增费用') + const dialog = activeDialog(wrapper) + const select = dialog.findAllComponents({ name: 'ElSelect' })[0]! + expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(0) + select.vm.$emit('update:modelValue', 'FEE_TYPE_077') + dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10) + await flushPromises(); await click(dialog, '保存') + expect(api.createOverseasFee).not.toHaveBeenCalled() + }) + it('includes verified related orders but rejects another customer and keeps adjustment on the original order', async () => { + const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' } + const relatedFee = { ...fee, ...related, allowedActions: ['ADJUST'] } + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [relatedFee], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue(relatedFee) + const wrapper = await mountPage() + await wrapper.setProps({ relatedBusinesses: [related, { ...business, customerId: 'other-customer' }] }) + await flushPromises() + expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([business, related], { page: 1, size: 20 }, 'RECEIVABLE') + await click(wrapper, '调整', '.el-table__body-wrapper button') + expect(wrapper.getComponent(OverseasFeeEditor).props('business')).toEqual(related) + const dialog = activeDialog(wrapper) + dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15) + await flushPromises(); await click(dialog, '保存') + expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'ADJUST', expect.objectContaining({ + expectedVersion: fee.sourceVersion, fee: expect.objectContaining({ businessId: related.businessId, businessType: 'RETURN_ORDER', amount: 15 }), + })) + }) + it('shows a voided freeze-failure fixture as closed instead of pending review', async () => { + const closed = { ...fee, sourceStatus: 'VOIDED', reviewStatus: 'PENDING', walletStatus: 'FREEZE_FAILED', walletErrorMessage: '可用余额和授信额度不足', allowedActions: [] } + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [closed], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue(closed) + const wrapper = await mountPage() + expect(wrapper.text()).toContain('已作废') + expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).toEqual(['详情']) + await click(wrapper, '详情', '.el-table__body-wrapper button') + expect(activeDialog(wrapper).text()).toContain('冻结失败') + expect(activeDialog(wrapper).text()).toContain('此费用已作废,无需继续审核') + expect(activeDialog(wrapper).text()).not.toContain('待审核') + }) + it('retries a failed debit only when the refreshed server action is allowed', async () => { + const failed = { ...fee, walletStatus: 'SETTLE_FAILED', allowedActions: ['RETRY_SETTLE'] } + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue(failed) + const wrapper = await mountPage() + await click(wrapper, '重试扣费', '.el-table__body-wrapper button') + await click(activeDialog(wrapper), '重试扣费') + expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_SETTLE', { idempotencyKey: expect.any(String) }) + expect(wrapper.emitted('changed')).toHaveLength(1) + }) + it('requires a rejection reason and submits the review contract', async () => { + const wrapper = await mountPage() + vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, sourceVersion: 5 }) + await click(wrapper, '审核', '.el-table__body-wrapper button') + const dialog = activeDialog(wrapper) + const reject = dialog.findAll('button').find(button => button.text().trim() === '驳回') + expect(reject?.attributes('disabled')).toBeDefined() + expect(api.actOnOverseasFee).not.toHaveBeenCalled() + await dialog.get('textarea').setValue(' 金额不符 ') + await click(dialog, '驳回') + expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '金额不符', expectedVersion: 5, idempotencyKey: expect.any(String) }) + }) + it('keeps rejected fees read-only while allowing failed frozen funds to be released', async () => { + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] }], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] }) + const wrapper = await mountPage() + const visibleButtons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim()) + expect(visibleButtons).toContain('详情') + expect(visibleButtons).not.toContain('审核') + expect(visibleButtons).not.toContain('驳回') + expect(visibleButtons).not.toContain('提交审核') + expect(visibleButtons).toContain('重试释放') + await click(wrapper, '重试释放', '.el-table__body-wrapper button') + await click(activeDialog(wrapper), '重试释放') + expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_RELEASE', expect.objectContaining({ idempotencyKey: expect.any(String) })) + await click(wrapper, '详情', '.el-table__body-wrapper button') + const dialog = activeDialog(wrapper) + expect(dialog.text()).toContain('审核状态') + expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false) + }) + it('voids with the freshly loaded version and a required reason', async () => { + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] }) + const wrapper = await mountPage() + await click(wrapper, '作废', '.el-table__body-wrapper button') + const dialog = activeDialog(wrapper) + await click(dialog, '作废') + expect(api.actOnOverseasFee).not.toHaveBeenCalled() + await dialog.get('textarea').setValue(' 重复费用 ') + await click(dialog, '作废') + expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'VOID', { expectedVersion: 5, reason: '重复费用', idempotencyKey: expect.any(String) }) + }) + it('locks in-flight approvals and preserves diagnostics and idempotency when retried', async () => { + const pending = deferred() + vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(pending.promise) + const wrapper = await mountPage() + await click(wrapper, '审核', '.el-table__body-wrapper button') + const dialog = activeDialog(wrapper) + await click(dialog, '审核通过') + expect(wrapper.findAllComponents(ElDialog).find(component => component.props('modelValue'))!.props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) + expect(dialog.get('textarea').attributes('disabled')).toBeDefined() + pending.reject(new ApiResponseError('余额服务暂不可用', 'FUNDING_ERROR', 409, null, 'test-request-1')) + await flushPromises() + expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(true) + expect(dialog.text()).toContain('余额服务暂不可用') + expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('') + const firstKey = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey + await click(dialog, '审核通过') + expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(firstKey) + }) +}) + + +describe('仓租费用审核与调价', () => { + const rent: OverseasFee = { ...fee, feeItemCode: 'FEE_TYPE_077', feeItemName: '仓储费', allowedActions: ['APPROVE', 'REJECT', 'ADJUST_RENT'], + storageRent: { outboundNo: 'OUT-1', outboundAt: '2026-10-22T10:00:00Z', calculatedAmount: 10, chargedAmount: null, + lines: [ + { lotId: '1', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'LASTLEG_ORDER', sourceBusinessNo: 'WC-SOURCE-1', batchNo: 'B-SOURCE-1' }, + { lotId: '2', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'RETURN_ORDER', sourceBusinessNo: 'TH-SOURCE-2', batchNo: 'B-SOURCE-2' }, + ] } } + async function setupRent() { + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue(rent) + return mountPage() + } + it('审核显示实际出入库与原计算额,批准以前不请求扣款', async () => { + const wrapper = await setupRent() + await click(wrapper, '审核', '.el-table__body-wrapper button') + const dialog = activeDialog(wrapper) + expect(dialog.text()).toContain('入库时间'); expect(dialog.text()).toContain('出库时间') + expect(dialog.text()).toContain('原计算金额'); expect(dialog.text()).toContain('实扣金额') + expect(dialog.text()).toContain('来源单号'); expect(dialog.text()).toContain('头程尾程订单') + expect(dialog.text()).toContain('WC-SOURCE-1'); expect(dialog.text()).toContain('TH-SOURCE-2') + expect(dialog.text()).toContain('退货单'); expect(dialog.text()).toContain('B-SOURCE-2') + expect(dialog.text()).toContain('2026-10-01'); expect(api.actOnOverseasFee).not.toHaveBeenCalled() + await click(dialog, '审核通过') + expect(api.actOnOverseasFee).toHaveBeenCalledWith(rent.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' })) + }) + it('调价说明必填,失败保留金额说明且同参数重试复用请求编号', async () => { + const wrapper = await setupRent() + vi.mocked(adjustStorageRentFee).mockRejectedValueOnce(new Error('暂时不可用')).mockResolvedValue(rent) + await click(wrapper, '调整仓租', '.el-table__body-wrapper button') + const dialog = activeDialog(wrapper) + expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(10) + expect(dialog.findAll('button').find(button => button.text() === '保存调整')!.attributes('disabled')).toBeDefined() + dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12) + dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '人工核对调整') + await flushPromises(); await click(dialog, '保存调整') + expect(dialog.text()).toContain('仓租操作失败') + await click(dialog, '保存调整') + const calls = vi.mocked(adjustStorageRentFee).mock.calls + expect(calls).toHaveLength(2); expect(calls[0]).toEqual(calls[1]) + expect(calls[0]).toEqual([rent.sourceFeeId, expect.objectContaining({ amount: 12, reason: '人工核对调整', expectedVersion: 3 })]) + }) + it('只读单据在仓租弹框已打开时同样禁止修改', async () => { + const wrapper = await setupRent(); await click(wrapper, '调整仓租', '.el-table__body-wrapper button') + await wrapper.setProps({ readonly: true }); await flushPromises() + expect(activeDialog(wrapper).findAll('button').some(button => button.text() === '保存调整')).toBe(false) + expect(adjustStorageRentFee).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/overseas-business/components/BusinessOrderFees.vue b/src/modules/overseas-business/components/BusinessOrderFees.vue new file mode 100644 index 00000000..da72ff30 --- /dev/null +++ b/src/modules/overseas-business/components/BusinessOrderFees.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/src/modules/overseas-business/components/OverseasFeeEditor.vue b/src/modules/overseas-business/components/OverseasFeeEditor.vue new file mode 100644 index 00000000..89dcd4b1 --- /dev/null +++ b/src/modules/overseas-business/components/OverseasFeeEditor.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/src/modules/overseas-business/components/OverseasFeeRetryHistory.test.ts b/src/modules/overseas-business/components/OverseasFeeRetryHistory.test.ts new file mode 100644 index 00000000..00fe0725 --- /dev/null +++ b/src/modules/overseas-business/components/OverseasFeeRetryHistory.test.ts @@ -0,0 +1,65 @@ +import ElementPlus from 'element-plus' +import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import { listOverseasFeeRetries } from '../api/overseasFees' +import OverseasFeeRetryHistory from './OverseasFeeRetryHistory.vue' + +vi.mock('../api/overseasFees', () => ({ listOverseasFeeRetries: vi.fn() })) +enableAutoUnmount(afterEach) +beforeEach(() => { + vi.mocked(listOverseasFeeRetries).mockReset().mockResolvedValue([]) + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) +}) +afterEach(() => vi.unstubAllGlobals()) +describe('费用重试记录', () => { + it('shows every retry with its actual operator, timestamp, result and failure reason', async () => { + vi.mocked(listOverseasFeeRetries).mockResolvedValue([ + { requestId: 'retry-2', requestedAt: '2026-10-23T11:22:33', operatorName: '李四 [2]', action: '重试推送', result: 'SUCCESS', errorMessage: null }, + { requestId: 'retry-1', requestedAt: '2026-10-23T10:20:30', operatorName: '张三 [1]', action: '重试扣费', result: 'FAILED', errorMessage: '可用额度不足' }, + ]) + const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: '9007199254740993' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(listOverseasFeeRetries).toHaveBeenCalledWith('9007199254740993') + const text = wrapper.text() + for (const value of ['李四 [2]', '张三 [1]', '2026-10-23 11:22:33', '2026-10-23 10:20:30', '重试成功', '重试后仍失败', '可用额度不足']) expect(text).toContain(value) + }) + it('keeps request failures visible and retries without losing the fee identity', async () => { + vi.mocked(listOverseasFeeRetries).mockRejectedValueOnce(new ApiResponseError('查询暂不可用', 'UNAVAILABLE', 503)) + const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1' }, global: { plugins: [ElementPlus] } }) + await flushPromises(); expect(wrapper.text()).toContain('查询暂不可用') + await wrapper.get('button').trigger('click'); await flushPromises() + expect(listOverseasFeeRetries).toHaveBeenLastCalledWith('fee-1'); expect(wrapper.text()).toContain('暂无重试记录') + }) + it('explains the failed settlement step in Chinese and keeps full technical details collapsed', async () => { + const raw = 'EventDeliveryFailure: Consumer rejected event: code=S0500, message=Internal server error, requestId=original-request, traceId=original-trace' + vi.mocked(listOverseasFeeRetries).mockResolvedValue([ + { requestId: 'r2', requestedAt: '', operatorName: '李四', action: '重试推送', result: 'SUCCESS', errorMessage: null }, + { requestId: 'r1', requestedAt: '', operatorName: '张三', action: '重试推送', result: 'FAILED', errorMessage: raw }, + ]) + const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.text()).toContain('推送结算失败:财务系统处理异常,请联系管理员排查后重试。') + expect(wrapper.findAll('details')).toHaveLength(1) + expect(wrapper.get('details').attributes('open')).toBeUndefined() + expect(wrapper.get('summary').text()).toBe('查看技术详情') + expect(wrapper.get('pre').text()).toBe(raw) + }) + it('ignores a late response from another fee', async () => { + let resolve!: (rows: Awaited>) => void + vi.mocked(listOverseasFeeRetries).mockReturnValueOnce(new Promise(yes => { resolve = yes })) + const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'old-fee' }, global: { plugins: [ElementPlus] } }) + await wrapper.setProps({ feeId: 'new-fee' }); await flushPromises() + resolve([{ requestId: 'old', requestedAt: '', operatorName: '旧费用操作人', action: '重试', result: 'SUCCESS', errorMessage: null }]); await flushPromises() + expect(wrapper.text()).not.toContain('旧费用操作人') + }) + it('refreshes the open history when the actual delivery result changes', async () => { + const record = { requestId: 'r1', requestedAt: '', operatorName: '张三', action: '重试推送', result: 'SUBMITTED', errorMessage: null } as const + vi.mocked(listOverseasFeeRetries).mockResolvedValueOnce([record]).mockResolvedValueOnce([{ ...record, result: 'SUCCESS' }]) + const wrapper = mount(OverseasFeeRetryHistory, { props: { feeId: 'fee-1', refreshKey: 'PENDING' }, global: { plugins: [ElementPlus] } }) + await flushPromises(); expect(wrapper.text()).toContain('已提交重试') + await wrapper.setProps({ refreshKey: 'PUBLISHED' }); await flushPromises() + expect(wrapper.text()).toContain('重试成功'); expect(wrapper.text()).not.toContain('已提交重试') + expect(listOverseasFeeRetries).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/modules/overseas-business/components/OverseasFeeRetryHistory.vue b/src/modules/overseas-business/components/OverseasFeeRetryHistory.vue new file mode 100644 index 00000000..cacb6c4f --- /dev/null +++ b/src/modules/overseas-business/components/OverseasFeeRetryHistory.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/src/modules/overseas-business/components/ProcurementControlFields.vue b/src/modules/overseas-business/components/ProcurementControlFields.vue index 90bfb983..95aaf128 100644 --- a/src/modules/overseas-business/components/ProcurementControlFields.vue +++ b/src/modules/overseas-business/components/ProcurementControlFields.vue @@ -4,6 +4,8 @@ import { computed } from 'vue' import type { ProcurementCommand } from '../api/procurementCommands' import type { PlatformProcurement, ProcurementFile } from '../procurementTypes' import { allowedDecisions, decisionLabels, remainingLabels, type ControlForm } from '../procurementControlForm' +import { regionText } from '../procurementDirection' +const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection) const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>() const form = defineModel({ required: true }) const proofs = computed(() => props.files.filter(file => file.category === 'EXCEPTION')) @@ -14,7 +16,7 @@ const blockers = computed(() => props.detail.controls.notes.filter(note => note.