Compare commits

...
80 changed files with 3490 additions and 130 deletions
+1
View File
@@ -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'] },
+24 -1
View File
@@ -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' } })
})
+6 -1
View File
@@ -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<RawResource>, ApiEnvelope<RawResource>>(`${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
@@ -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<Record<string, unknown>>()
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<Awaited<ReturnType<typeof api.listFinanceAccounts>>>()
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))
})
@@ -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(),
@@ -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<FinanceAccountSummary | null>(null)
const ledger = ref<FinanceLedgerEntry[]>([])
const ledgerLoading = ref(false)
const detailLoading = ref(false)
const ledgerError = ref<ApiErrorDetails | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30)
@@ -57,6 +58,12 @@ const rechargeLoading = ref(false)
const rechargeError = ref<ApiErrorDetails | null>(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<ApiErrorDetails | null>(null)
let refundAttempt = { payload: '', key: '' }
const adjustmentOpen = ref(false)
const adjustmentSaving = ref(false)
const adjustmentAccount = ref<FinanceAccountSummary | null>(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<string, unknown>, 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<string, unknown>, 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, string>)[String(value)] || '其他'
}
function walletBusinessType(value: unknown) {
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', REFUND: '消费退款', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[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)
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
@@ -483,7 +536,7 @@ onBeforeUnmount(clearVoucherPreview)
</FormSection>
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template>
@@ -491,6 +544,21 @@ onBeforeUnmount(clearVoucherPreview)
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="refundOpen" title="原消费退款" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退款" :confirm-loading="refundSaving" @confirm="submitRefund">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="source" aria-label="原消费资料"><h3 class="density-dialog-section__title">原消费(CNY)</h3><table class="density-table wallet-summary"><tbody>
<tr><th scope="row">原消费流水</th><td>{{ refundSource?.entry.businessNo || refundSource?.entry.ledgerId }}</td></tr>
<tr><th scope="row">关联业务单据</th><td>{{ refundSource?.entry.sourceNo || refundSource?.entry.documentNo || '—' }}</td></tr>
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前账户资料">
@@ -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<typeof import('@/shared/api/http')>(), 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 = <T>(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)
})
})
@@ -0,0 +1,99 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface FeePage<T> { 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<OverseasFeeRetryRecord[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<OverseasFeeRetryRecord[]>, ApiEnvelope<OverseasFeeRetryRecord[]>>(`${base}/${encodeURIComponent(id)}/retries`))
}
export async function listOverseasFeeItems(direction: 'RECEIVABLE' | 'PAYABLE' = 'RECEIVABLE') {
const response = await http.get<ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>, ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>>(`${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<Array<{ customerId: string; customerName: string }>>, ApiEnvelope<Array<{ customerId: string; customerName: string }>>>(`${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<string, string | number | undefined>): Promise<FeePage<OverseasFee>> {
const response = await http.get<ApiEnvelope<FeePage<OverseasFee>>, ApiEnvelope<FeePage<OverseasFee>>>(`${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<FeePage<OverseasFee>> {
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<OverseasFee[]>, ApiEnvelope<OverseasFee[]>>(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<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}`)))
}
export async function listBusinessFeeOptions(params: { businessType: string; keyword?: string; customerId?: string }) {
const response = await http.get<ApiEnvelope<BusinessFeeOption[]>, ApiEnvelope<BusinessFeeOption[]>>(`${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<OverseasFee>, ApiEnvelope<OverseasFee>>(base, payload, { headers: { 'Idempotency-Key': key } })))
}
export async function actOnOverseasFee(id: string, action: string, body: Record<string, unknown>) {
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<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}/${endpoint}`, body)))
}
@@ -57,8 +57,8 @@ export async function listOverseasPurchaseCustomerOptions(keyword = '', limit =
const response = await http.get<ApiEnvelope<OverseasPurchaseCustomerOption[]>, ApiEnvelope<OverseasPurchaseCustomerOption[]>>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } })
return unwrapApiResponse(response)
}
export async function listOverseasPurchaseWarehouseOptions() {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`)
export async function listOverseasPurchaseWarehouseOptions(procurementDirection?: import('../procurementDirection').ProcurementDirection) {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`, { params: { procurementDirection } })
return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) }))
}
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
@@ -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
@@ -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<typeof import('@/shared/api/http')>(), http: client }))
import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses } from './storageRent'
const base = '/platform/v1/last-leg/storage-rent'
const envelope = <T>(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`)
})
})
@@ -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<OverseasFee>, ApiEnvelope<OverseasFee>>(`/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<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<FeePage<StorageRentLot>>, ApiEnvelope<FeePage<StorageRentLot>>>(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<StorageRentDetail>, ApiEnvelope<StorageRentDetail>>(`${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<Array<{ id: string; warehouseName: string }>>, ApiEnvelope<Array<{ id: string; warehouseName: string }>>>(`${base}/warehouse-options`))
return data.map(row => ({ ...row, id: String(row.id) }))
}
@@ -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: '<div><slot name="toolbar-actions" /></div>' } }))
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: '<div>{{ message }}</div>' }, 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)
})
})
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import BusinessOrderFees from './BusinessOrderFees.vue'
import { listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFeeDirection } from '../api/overseasFees'
import { feePermissions } from '../overseasFeePresentation'
interface DocumentIdentity { businessType: string; businessNo: string; customerId?: string; businessId?: string }
class DocumentIdentityError extends Error {}
const props = withDefaults(defineProps<DocumentIdentity & { relatedDocuments?: DocumentIdentity[]; canCreate?: boolean; readonly?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { canCreate: true, readonly: false, direction: 'RECEIVABLE', hideWhenEmpty: false })
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canRead = computed(() => access.can(feePermissions.list) || access.can('platform:finance:customer-arap:list'))
const business = ref<BusinessFeeOption | null>(null), related = ref<BusinessFeeOption[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const panel = ref<InstanceType<typeof BusinessOrderFees>>()
let generation = 0
async function load() {
const request = ++generation
business.value = null; related.value = []; error.value = null; loading.value = false
if (!canRead.value) return
loading.value = true
try {
const identities: DocumentIdentity[] = [props, ...(props.relatedDocuments || [])]
const resolved = await Promise.all(identities.map(async (identity, index) => {
if ((!identity.customerId && !identity.businessId) || !identity.businessNo) throw new DocumentIdentityError('单据信息不完整,无法读取费用')
const options = await listBusinessFeeOptions({ businessType: identity.businessType, customerId: identity.customerId, keyword: identity.businessNo })
// 候选搜索是模糊查询,只有当前客户和完整单号一致时才能承载收费。
const matches = options.filter(option => (!identity.customerId || option.customerId === identity.customerId) && option.businessType === identity.businessType
&& option.businessNo === identity.businessNo && (!identity.businessId || option.businessId === identity.businessId))
// 尾程来源号可能只是外部参考号;没有内部业务实体时不能据此造出费用归属。
if (index > 0 && matches.length === 0) return null
if (matches.length !== 1) throw new DocumentIdentityError(`未找到唯一关联单据:${identity.businessNo}`)
return matches[0]!
}))
if (request === generation) { business.value = resolved[0]!; related.value = resolved.slice(1).filter((value): value is BusinessFeeOption => value !== null) }
} catch (failure) { if (request === generation) error.value = failure instanceof DocumentIdentityError ? { message: failure.message } : getApiErrorDetails(failure, '单据费用加载失败') }
finally { if (request === generation) loading.value = false }
}
watch(() => [props.businessType, props.businessNo, props.customerId, props.businessId, props.relatedDocuments, canRead.value], load, { immediate: true, deep: true })
async function refresh() { if (business.value) await panel.value?.refresh(); else await load() }
/** 新增前读取完整单据范围,重复项目提示不受当前表格分页影响。 */
async function getFees() {
if (!business.value) await load()
if (!business.value || error.value) throw new Error(error.value?.message || '单据费用尚未加载完成')
return (await listBusinessOverseasFees([business.value, ...related.value], { page: 1, size: Number.MAX_SAFE_INTEGER }, props.direction)).records
}
defineExpose({ refresh, getFees })
</script>
<template>
<section aria-label="单据费用">
<el-alert v-if="!canRead" title="暂无费用查看权限" type="info" :closable="false" />
<LoadErrorState v-else-if="error" title="单据费用加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="3" animated />
<BusinessOrderFees v-else-if="business" ref="panel" :business="business" :related-businesses="related" :direction="direction" :can-create="canCreate" :readonly="readonly" :hide-when-empty="hideWhenEmpty" :heading="heading" @changed="emit('changed')">
<template v-if="$slots['toolbar-actions']" #toolbar-actions><slot name="toolbar-actions" /></template>
</BusinessOrderFees>
</section>
</template>
@@ -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<T>() {
let resolve!: (value: T) => void, reject!: (reason: unknown) => void
const promise = new Promise<T>((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<OverseasFee>()
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()
})
})
@@ -0,0 +1,217 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../overseasFeeDetails.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions from '@/shared/components/RowActions.vue'
import OverseasFeeEditor, { type FeeItemOption } from './OverseasFeeEditor.vue'
import OverseasFeeRetryHistory from './OverseasFeeRetryHistory.vue'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
import StorageRentFeeActions from './StorageRentFeeActions.vue'
import { useFeeStatusRefresh } from '../useFeeStatusRefresh'
import { actOnOverseasFee, getOverseasFee, listBusinessOverseasFees, listOverseasFeeItems, type BusinessFeeOption, type OverseasFee, type OverseasFeeDirection } from '../api/overseasFees'
import { actionLabels, feeActions, feeMoney, feePermissions, feeProcessingLabel, feeTime, feeReviewLabel, feeFundingErrorLabel } from '../overseasFeePresentation'
const props = withDefaults(defineProps<{ business: BusinessFeeOption; relatedBusinesses?: BusinessFeeOption[]; readonly?: boolean; canCreate?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { readonly: false, canCreate: true, direction: 'RECEIVABLE', hideWhenEmpty: false })
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canEdit = computed(() => !props.readonly && access.can(feePermissions.edit))
const loading = ref(false), error = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), itemsLoading = ref(false)
const page = ref(1), pageSize = ref(20), total = ref(0), rows = ref<OverseasFee[]>([]), items = ref<FeeItemOption[]>([])
const detailLoading = ref(''), detail = ref<OverseasFee | null>(null), detailOpen = ref(false)
const editorOpen = ref(false), editFee = ref<OverseasFee | null>(null)
const actionOpen = ref(false), saving = ref(false), actionError = ref<ApiErrorDetails | null>(null)
const actionForm = reactive({ action: '', reason: '', requestKey: '', requestFingerprint: '' })
const actionFee = ref<OverseasFee | null>(null)
const rentFee = ref<OverseasFee | null>(null), rentAction = ref(''), rentOpen = ref(false)
const actionTitle = computed(() => actionForm.action === 'REVIEW' ? '审核' : actionLabels[actionForm.action] || '确认操作')
const actionConfirmText = computed(() => actionLabels[actionForm.action] || '确认操作')
const actionConfirmType = computed(() => ['REJECT', 'VOID'].includes(actionForm.action) ? 'danger' : 'primary')
const businesses = computed(() => [props.business, ...(props.relatedBusinesses || [])].filter(scope => scope.customerId === props.business.customerId))
const businessKey = computed(() => props.direction + '|' + businesses.value.map(scope => [scope.businessType, scope.businessId, scope.customerId].join(':')).join('|'))
const feeBusiness = (fee: OverseasFee) => businesses.value.find(scope => fee.businessId === scope.businessId && fee.businessType === scope.businessType && fee.customerId === scope.customerId)
const belongsToBusiness = (fee: OverseasFee) => fee.direction === props.direction && !!feeBusiness(fee)
const editorBusiness = computed(() => editFee.value ? feeBusiness(editFee.value) || props.business : props.business)
function rowActions(fee: OverseasFee) {
return feeActions(fee, permission => !props.readonly && access.can(permission), !!detailLoading.value || saving.value)
}
const collectionLabel = (fee: OverseasFee) => fee.direction === 'PAYABLE' ? '推送结算' : fee.collectionMode === 'BILL' ? '账单结算' : '余额/授信'
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
{ prop: 'feeItemName', label: '费用项目', minWidth: 150 },
...(props.direction === 'PAYABLE' ? [{ prop: 'supplierName', label: '供应商', minWidth: 180 }] : []),
{ key: 'amount', label: '金额', width: 150, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.originalAmount, row.originalCurrency) },
{ key: 'settlementAmount', label: '结算金额(CNY)', width: 145, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.amount) },
{ prop: 'collectionMode', label: '结算方式', width: 110, formatter: collectionLabel },
{ prop: 'reviewStatus', label: '审核状态', width: 100, slotName: 'reviewStatus' },
{ prop: 'reviewerName', label: '审核人', width: 120, formatter: (row: OverseasFee) => row.reviewerName || row.reviewerId || '—' },
{ prop: 'reviewedAt', label: '审核时间', width: 160, formatter: (row: OverseasFee) => feeTime(row.reviewedAt) },
{ key: 'chargeStatus', label: '费用状态', width: 120, formatter: feeProcessingLabel },
{ prop: 'businessDate', label: '费用日期', width: 110 },
])
let loadSequence = 0, detailSequence = 0, itemsSequence = 0
const statusRefresh = useFeeStatusRefresh(rows, () => load(true), computed(() => !error.value))
async function load(background = false) {
if (!background) statusRefresh.reset()
const current = ++loadSequence; if (!background) loading.value = true; error.value = null
try {
const data = await listBusinessOverseasFees(businesses.value, { page: page.value, size: pageSize.value }, props.direction)
if (current !== loadSequence) return
if (page.value > 1 && page.value > data.pages) { page.value = Math.max(1, data.pages); return }
rows.value = data.records.filter(belongsToBusiness); total.value = data.total
const latestDetail = rows.value.find(row => row.sourceFeeId === detail.value?.sourceFeeId)
if (latestDetail) detail.value = latestDetail
} catch (failure) { if (current === loadSequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '费用列表加载失败') } }
finally { if (current === loadSequence) loading.value = false }
}
async function loadItems() {
const current = ++itemsSequence; itemsLoading.value = true; itemsError.value = null
try {
const result = await listOverseasFeeItems(props.direction)
if (current !== itemsSequence) return false
items.value = result
return true
} catch (failure) { if (current === itemsSequence) itemsError.value = getApiErrorDetails(failure, '费用项目加载失败'); return false }
finally { if (current === itemsSequence) itemsLoading.value = false }
}
async function saved() { emit('changed'); await load() }
async function freshFee(row: OverseasFee) {
const current = ++detailSequence; detailLoading.value = row.sourceFeeId
try { const data = await getOverseasFee(row.sourceFeeId); return current === detailSequence ? data : null }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '费用详情加载失败').message); return null }
finally { if (current === detailSequence) detailLoading.value = '' }
}
async function create() { if (!canEdit.value || !props.canCreate || props.direction === 'PAYABLE') return; if (!await loadItems() || !canEdit.value || !props.canCreate) return; editFee.value = null; editorOpen.value = true }
async function handleAction(row: OverseasFee, action: string) {
if (detailLoading.value || saving.value) return
const current = await freshFee(row)
if (!current) return
if (!belongsToBusiness(current)) { ElMessage.warning('费用所属单据已变化,请重新查询'); await load(); return }
if (action === 'DETAIL') { detail.value = current; detailOpen.value = true; return }
if (!rowActions(current).some(item => item.key === action)) {
ElMessage.warning('费用状态已更新,请重新查询'); await load(); return
}
detailOpen.value = false
if (current.storageRent && ['REVIEW', 'ADJUST_RENT'].includes(action)) { rentFee.value = current; rentAction.value = action; rentOpen.value = true; return }
if (action === 'ADJUST') { if (!await loadItems() || !canEdit.value || !belongsToBusiness(current)) return; editFee.value = current; editorOpen.value = true; return }
actionFee.value = current; actionForm.action = action
actionForm.reason = ''; actionForm.requestKey = ''; actionForm.requestFingerprint = ''; actionError.value = null; actionOpen.value = true
}
async function confirmAction() {
const fee = actionFee.value, action = actionForm.action
if (!fee || saving.value || !belongsToBusiness(fee)) return
if (action === 'REVIEW' || !rowActions(fee).some(item => item.key === action)) return
if (action === 'VOID' && !actionForm.reason.trim()) { ElMessage.warning('请填写原因'); return }
saving.value = true; actionError.value = null
try {
const body = action === 'VOID' ? { expectedVersion: fee.sourceVersion, reason: actionForm.reason.trim() }
: ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? { expectedVersion: fee.sourceVersion, eventId: fee.deliveryEventId } : {}
const fingerprint = JSON.stringify(body)
if (actionForm.requestFingerprint !== fingerprint || !actionForm.requestKey) { actionForm.requestFingerprint = fingerprint; actionForm.requestKey = crypto.randomUUID() }
const result = await actOnOverseasFee(fee.sourceFeeId, action, { ...body, idempotencyKey: actionForm.requestKey })
if (detail.value?.sourceFeeId === result.sourceFeeId) detail.value = result
actionOpen.value = false; ElMessage.success('操作已提交'); emit('changed'); await load()
} catch (failure) { actionError.value = getApiErrorDetails(failure, '费用操作失败') }
finally { saving.value = false }
}
async function submitReview(decision: 'APPROVE' | 'REJECT') {
const fee = actionFee.value
if (!fee || saving.value || !belongsToBusiness(fee)) return
if (!rowActions(fee).some(item => item.key === 'REVIEW') || !fee.allowedActions.includes(decision)) return
if (decision === 'REJECT' && !actionForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
const body = { decision, reason: actionForm.reason.trim() || undefined, expectedVersion: fee.sourceVersion }
const fingerprint = JSON.stringify(body)
if (actionForm.requestFingerprint !== fingerprint || !actionForm.requestKey) {
actionForm.requestFingerprint = fingerprint; actionForm.requestKey = crypto.randomUUID()
}
saving.value = true; actionError.value = null
try {
const result = await actOnOverseasFee(fee.sourceFeeId, decision, { ...body, idempotencyKey: actionForm.requestKey })
actionFee.value = result
actionOpen.value = false
ElMessage.success(decision === 'APPROVE' ? '费用审核通过' : '费用已驳回')
emit('changed'); await load()
} catch (failure) { actionError.value = getApiErrorDetails(failure, '费用审核失败') }
finally { saving.value = false }
}
watch([page, pageSize], () => void load())
watch(businessKey, () => {
loadSequence++; detailSequence++; itemsSequence++; detailLoading.value = ''; itemsLoading.value = false
rows.value = []; total.value = 0; detail.value = null; actionFee.value = null; rentOpen.value = false; rentFee.value = null
editorOpen.value = false; detailOpen.value = false; actionOpen.value = false; itemsError.value = null
if (page.value === 1) void load(); else page.value = 1
}, { immediate: true })
watch(canEdit, allowed => { if (!allowed) editorOpen.value = false })
watch(() => actionFee.value && rowActions(actionFee.value).some(item => item.key === actionForm.action), allowed => { if (!allowed && !saving.value) actionOpen.value = false })
defineExpose({ refresh: load })
</script>
<template>
<section v-show="!hideWhenEmpty || !!error || total > 0" class="business-order-fees admin-density admin-workbench" :aria-label="heading">
<h3 v-if="heading" class="business-fee-heading">{{ heading }}</h3>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" :preference-key="`business-order-fees-${direction.toLowerCase()}`" row-key="sourceFeeId" empty-text="暂无费用明细">
<template #toolbar-actions>
<slot name="toolbar-actions">
<el-button v-if="canEdit && canCreate && direction === 'RECEIVABLE'" type="primary" :loading="itemsLoading" @click="create"><Plus :size="16" />新增费用</el-button>
</slot>
</template>
<template #notices>
<LoadErrorState v-if="error" title="费用列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="itemsError" title="费用项目加载失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as OverseasFee)" @action="handleAction(row as OverseasFee, $event)" /></template>
<template #reviewStatus="{ row }"><el-tag :type="(row as OverseasFee).reviewStatus === 'APPROVED' ? 'success' : (row as OverseasFee).reviewStatus === 'REJECTED' ? 'danger' : 'info'">{{ feeReviewLabel(row as OverseasFee) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条费用</template>
</DataTable>
<StorageRentFeeActions v-model="rentOpen" :fee="rentFee" :action="rentAction" :readonly="readonly" @saved="saved" />
<OverseasFeeEditor v-model="editorOpen" :fee="editFee" :items="items" :business="editorBusiness" @saved="saved" />
<AppDialog append-to-body v-model="detailOpen" title="费用详情" width="860px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item>
<el-descriptions-item v-if="detail.feeRemark" label="费用说明" :span="2">{{ detail.feeRemark }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(detail.originalAmount, detail.originalCurrency) }}</el-descriptions-item><el-descriptions-item label="结算方式">{{ collectionLabel(detail) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算金额">{{ feeMoney(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算汇率">{{ detail.exchangeRate || '—' }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item>
<el-descriptions-item label="审核状态">{{ feeReviewLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ feeProcessingLabel(detail) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewerName || detail.reviewerId" label="审核人">{{ detail.reviewerName || detail.reviewerId }}</el-descriptions-item><el-descriptions-item v-if="detail.reviewedAt" label="审核时间">{{ feeTime(detail.reviewedAt) }}</el-descriptions-item>
<el-descriptions-item label="费用日期">{{ detail.businessDate }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ feeTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.periodStart || detail.periodEnd" label="计费期间" :span="2">{{ detail.periodStart }} — {{ detail.periodEnd }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewReason" label="审核意见" :span="2">{{ detail.reviewReason }}</el-descriptions-item>
<el-descriptions-item v-if="detail.walletErrorMessage" :label="feeFundingErrorLabel(detail)" :span="2">{{ detail.walletErrorMessage }}<span v-if="detail.sourceStatus === 'VOIDED'">(此费用已作废,无需继续审核)</span></el-descriptions-item>
</el-descriptions>
<StorageRentFeeDetails :fee="detail" />
<OverseasFeeRetryHistory v-if="detailOpen" :fee-id="detail.sourceFeeId" :refresh-key="[detail.deliveryStatus, detail.deliveryErrorMessage, detail.walletStatus].join('|')" />
</template>
</AppDialog>
<AppDialog append-to-body v-model="actionOpen" :title="actionTitle" width="520px" class="admin-density-dialog admin-workbench is-dialog" :confirm-text="actionConfirmText" :confirm-type="actionConfirmType" :confirm-loading="saving" @confirm="confirmAction">
<el-descriptions v-if="actionFee" :column="1" border class="density-descriptions"><el-descriptions-item label="业务单号">{{ actionFee.businessNo }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ actionFee.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ feeMoney(actionFee.originalAmount, actionFee.originalCurrency) }}</el-descriptions-item><el-descriptions-item v-if="actionFee.feeRemark" label="费用说明">{{ actionFee.feeRemark }}</el-descriptions-item></el-descriptions>
<el-form v-if="actionForm.action === 'REVIEW' || actionForm.action === 'VOID'" label-position="top" class="fee-action-reason density-form" :disabled="saving"><el-form-item :label="actionForm.action === 'REVIEW' ? '驳回原因(驳回时必填)' : '原因'" :required="actionForm.action === 'VOID'"><el-input v-model="actionForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="actionForm.action === 'REVIEW' ? '审核通过可不填,驳回时请填写原因' : ''" /></el-form-item></el-form>
<el-alert v-if="actionForm.action === 'REVIEW' && actionFee?.direction === 'RECEIVABLE' && actionFee.businessType === 'LASTLEG_ORDER' && actionFee.collectionMode === 'BILL'" title="请核对原报价,已含费用请勿重复收取。" type="warning" :closable="false" />
<el-alert v-if="actionError" :title="actionError.message" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert>
<template #footer="{ close, submitting }">
<div v-if="actionForm.action === 'REVIEW'" class="app-dialog__footer">
<el-button v-if="actionFee?.allowedActions.includes('REJECT')" type="danger" :disabled="submitting || !actionForm.reason.trim()" @click="submitReview('REJECT')">驳回</el-button>
<el-button v-if="actionFee?.allowedActions.includes('APPROVE')" type="primary" :loading="submitting" :disabled="submitting" @click="submitReview('APPROVE')">审核通过</el-button>
</div>
<div v-else class="app-dialog__footer">
<el-button :disabled="submitting" @click="close">取消</el-button>
<el-button :type="actionConfirmType" :loading="submitting" @click="confirmAction">{{ actionConfirmText }}</el-button>
</div>
</template>
</AppDialog>
</section>
</template>
<style scoped>
.fee-action-reason { margin-top: 16px; }
.business-fee-heading { margin: var(--admin-space-3) 0; font-size: var(--admin-font-section); }
</style>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { actOnOverseasFee, createOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFee, type SaveOverseasFee } from '../api/overseasFees'
import { businessLabels, feePermissions, sameProjectFees, sameProjectFeeSummary } from '../overseasFeePresentation'
export interface FeeItemOption { feeCode: string; feeTypeName: string; defaultPrice: number | null }
const props = defineProps<{ fee: OverseasFee | null; items: FeeItemOption[]; business?: BusinessFeeOption }>()
const access = useAccessPreviewStore()
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const saving = ref(false), optionsLoading = ref(false), options = ref<BusinessFeeOption[]>([]), formRef = ref<FormInstance>()
const error = ref<ApiErrorDetails | null>(null)
const today = () => new Date().toLocaleDateString('sv-SE')
const form = reactive({ businessType: 'WAREHOUSE_TRANSFER', businessId: '', feeItemCode: '', feeRemark: '', settlementMode: '' as '' | 'BILL' | 'WALLET', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: today() })
const existingFees = ref<OverseasFee[]>([]), existingLoading = ref(false), existingError = ref('')
const duplicates = computed(() => sameProjectFees(existingFees.value, form.feeItemCode, props.fee?.direction || 'RECEIVABLE', props.fee?.sourceFeeId))
const title = computed(() => props.fee ? '调整费用' : '新增费用')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-fee-editor', label: title, isOpen: open, model: form, requiredPermission: computed(() => feePermissions.edit) })
const rules: FormRules<typeof form> = {
businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
businessId: [{ required: true, message: '请选择业务订单', trigger: 'change' }],
feeItemCode: [{ required: true, message: '请选择费用项目', trigger: 'change' }],
amount: [{ validator: (_rule, value: number, callback) => callback(Number.isFinite(value) && value > 0 ? undefined : new Error('金额须大于0')), trigger: 'blur' }],
}
let optionSequence = 0
let savedFingerprint = '', requestKey = ''
let openedBusinessKey = ''
const businessKey = () => props.business ? `${props.business.businessType}:${props.business.businessId}:${props.business.customerId}` : ''
const automaticRentCode = 'FEE_TYPE_077'
const manualItems = computed(() => props.items.filter(item => item.feeCode !== automaticRentCode))
const hasExistingItem = computed(() => !!props.fee && manualItems.value.some(item => item.feeCode === props.fee?.feeItemCode))
async function loadOptions(keyword = '') {
if (props.business) return
const sequence = ++optionSequence
optionsLoading.value = true
try {
const result = await listBusinessFeeOptions({ businessType: form.businessType, keyword })
if (sequence === optionSequence) {
const current = selected.value
options.value = current && !result.some(row => row.businessId === current.businessId) ? [current, ...result] : result
}
} catch (failure) { if (sequence === optionSequence) error.value = getApiErrorDetails(failure, '业务订单加载失败') }
finally { if (sequence === optionSequence) optionsLoading.value = false }
}
function changeBusinessType() { form.businessId = ''; options.value = []; void loadOptions() }
const selected = computed(() => props.business || options.value.find(row => row.businessId === form.businessId))
const selectedItem = computed(() => manualItems.value.find(item => item.feeCode === form.feeItemCode))
let existingSequence = 0
async function loadExistingFees() {
const sequence = ++existingSequence
existingFees.value = []; existingError.value = ''; existingLoading.value = false
if (!open.value || !selected.value) return
existingLoading.value = true
try {
const result = await listBusinessOverseasFees(selected.value, { page: 1, size: Number.MAX_SAFE_INTEGER }, props.fee?.direction || 'RECEIVABLE')
if (sequence === existingSequence) existingFees.value = result.records
} catch (failure) { if (sequence === existingSequence) existingError.value = getApiErrorDetails(failure, '已有费用加载失败').message }
finally { if (sequence === existingSequence) existingLoading.value = false }
}
watch([open, selected], loadExistingFees)
watch(open, async value => {
if (!value) { optionSequence++; optionsLoading.value = false; return }
savedFingerprint = ''; requestKey = ''
openedBusinessKey = businessKey()
error.value = null
const fee = props.fee
Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() })
options.value = fee ? [{ businessId: fee.businessId || '', businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, orderId: fee.orderId, warehouseId: fee.warehouseId }] : []
captureBaseline()
formRef.value?.clearValidate()
if (!fee && !props.business) await loadOptions()
})
const sourceTypes: Record<string, string> = {
LASTLEG_ORDER: 'OVERSEAS_ORDER_FEE', WAREHOUSE_TRANSFER: 'OVERSEAS_WAREHOUSE_FEE', RETURN_ORDER: 'OVERSEAS_WAREHOUSE_FEE',
DISCARD_ORDER: 'OVERSEAS_WAREHOUSE_FEE', RELABEL_ORDER: 'OVERSEAS_RELABEL_FEE', US_TRUCK: 'OVERSEAS_TRUCK_FEE',
LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE',
OVERSEAS_INBOUND: 'OVERSEAS_INBOUND_FEE', DELIVERY_ORDER: 'OVERSEAS_DELIVERY_FEE',
}
const selectableTypes = Object.keys(sourceTypes)
async function save() {
if (!access.can(feePermissions.edit) || saving.value || optionsLoading.value || !await formRef.value?.validate().catch(() => false)) return
if (form.feeItemCode === automaticRentCode) { ElMessage.error('仓租自动计算,请勿手动添加'); return }
const order = selected.value
if (!order) { ElMessage.error('请重新选择业务订单'); return }
if (existingLoading.value || existingError.value) { ElMessage.warning(existingError.value || '正在核对已有费用'); return }
if (duplicates.value.length && !form.feeRemark.trim()) { ElMessage.warning('此单已有相同费用项目,请填写费用说明后继续添加'); return }
const wrongFee = props.business && props.fee && (props.fee.businessId !== props.business.businessId || props.fee.businessType !== props.business.businessType || props.fee.customerId !== props.business.customerId)
if (openedBusinessKey !== businessKey() || wrongFee || (props.business && (form.businessId !== props.business.businessId || form.businessType !== props.business.businessType))) {
ElMessage.error('单据已变化,请关闭后重新添加费用'); return
}
if (!props.fee && !sourceTypes[order.businessType]) { ElMessage.error('请通过本业务的费用流程添加'); return }
const item = selectedItem.value
if (!item && !props.fee) { ElMessage.error('请重新选择费用项目'); return }
saving.value = true; error.value = null
try {
const payload: SaveOverseasFee = {
...order, sourceType: props.fee?.sourceType || sourceTypes[order.businessType]!,
feeItemCode: form.feeItemCode, feeItemName: item?.feeTypeName || props.fee!.feeItemName, direction: props.fee?.direction || 'RECEIVABLE',
businessDate: form.businessDate, currency: form.currency, amount: form.amount, feeRemark: form.feeRemark.trim() || undefined,
...(form.settlementMode ? { settlementMode: form.settlementMode } : {}),
...(props.fee?.direction === 'PAYABLE' ? {
supplierId: props.fee.supplierId, supplierName: props.fee.supplierName, supplierCategory: props.fee.supplierCategory,
settlementMode: props.fee.settlementMode, paidAt: props.fee.paidAt, paymentMethod: props.fee.paymentMethod,
paymentReferenceNo: props.fee.paymentReferenceNo, paymentVoucherFileIds: props.fee.paymentVoucherFileIds,
} : {}),
}
const fingerprint = JSON.stringify(payload)
if (fingerprint !== savedFingerprint || !requestKey) { savedFingerprint = fingerprint; requestKey = crypto.randomUUID() }
const idempotencyKey = requestKey
if (props.fee) await actOnOverseasFee(props.fee.sourceFeeId, 'ADJUST', { expectedVersion: props.fee.sourceVersion, fee: payload, idempotencyKey })
else await createOverseasFee(payload, idempotencyKey)
captureBaseline(); open.value = false; ElMessage.success(props.fee ? '费用已调整' : '费用已创建'); emit('saved')
} catch (failure) { error.value = getApiErrorDetails(failure, '费用保存失败') }
finally { saving.value = false }
}
</script>
<template>
<AppDialog append-to-body v-model="open" :title="title" width="680px" class="admin-density-dialog admin-workbench is-dialog" confirm-text="保存" :confirm-disabled="optionsLoading || !access.can(feePermissions.edit)" :confirm-loading="saving" fullscreenable @confirm="save">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="density-form" :disabled="saving">
<div class="fee-editor-grid">
<el-form-item v-if="!business" label="业务类型" prop="businessType"><el-select v-model="form.businessType" :disabled="!!fee" @change="changeBusinessType"><el-option v-for="type in selectableTypes" :key="type" :value="type" :label="businessLabels[type]" /></el-select></el-form-item>
<el-form-item v-if="!business" label="业务订单" prop="businessId"><el-select v-model="form.businessId" filterable remote :remote-method="loadOptions" :loading="optionsLoading" :disabled="!!fee" placeholder="搜索订单号"><el-option v-for="row in options" :key="row.businessId" :value="row.businessId" :label="`${row.businessNo} · ${row.customerName}`" /></el-select></el-form-item>
<el-form-item label="费用项目" prop="feeItemCode"><el-select v-model="form.feeItemCode" filterable><el-option v-if="fee && fee.feeItemCode !== automaticRentCode && !hasExistingItem" :value="fee.feeItemCode" :label="fee.feeItemName" /><el-option v-for="item in manualItems" :key="item.feeCode" :value="item.feeCode" :label="item.feeTypeName" /></el-select></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :max="999999999.99" :precision="2" :controls="false" /></el-form-item>
<el-form-item v-if="(props.fee?.direction || 'RECEIVABLE') === 'RECEIVABLE'" label="结算方式"><el-select v-model="form.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item>
<el-form-item label="费用说明" class="fee-remark" :required="duplicates.length > 0"><el-input v-model="form.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item>
</div>
<div v-if="duplicates.length" class="fee-existing" role="status"><span>此单已有相同费用,确认本次为新增作业后可继续添加。</span><p v-for="row in duplicates" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</p></div>
<div v-if="existingError" class="fee-existing-error">{{ existingError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></div>
</el-form>
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</AppDialog>
</template>
<style scoped>
.fee-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
.fee-editor-grid :deep(.el-select), .fee-editor-grid :deep(.el-date-editor), .fee-editor-grid :deep(.el-input-number) { width: 100%; }
.fee-remark { grid-column: 1 / -1; }
.fee-existing { color: var(--cc-text-secondary, #606266); font-size: 13px; margin-bottom: 12px; }
.fee-existing p { margin: 4px 0; }
.fee-existing-error { color: var(--cc-danger, #f56c6c); }
</style>
@@ -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<ReturnType<typeof listOverseasFeeRetries>>) => 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)
})
})
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listOverseasFeeRetries, type OverseasFeeRetryRecord } from '../api/overseasFees'
import { feeTime, retryResultLabels } from '../overseasFeePresentation'
import { retryFailureReason } from '../overseasFeeRetryPresentation'
const props = defineProps<{ feeId: string; refreshKey?: string }>()
const rows = ref<OverseasFeeRetryRecord[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const columns: DataTableColumn[] = [
{ prop: 'requestedAt', label: '重试时间', width: 160, formatter: (row: OverseasFeeRetryRecord) => feeTime(row.requestedAt) },
{ prop: 'operatorName', label: '操作人', width: 170 },
{ prop: 'action', label: '重试操作', width: 100 },
{ prop: 'result', label: '操作结果', width: 110, formatter: (row: OverseasFeeRetryRecord) => retryResultLabels[row.result] || '—' },
{ key: 'failureReason', label: '失败原因', minWidth: 230, slotName: 'failureReason', singleLine: false, showOverflowTooltip: false },
]
let sequence = 0
async function load(background = false) {
const current = ++sequence; error.value = null
if (!background) { rows.value = []; loading.value = true }
try { const result = await listOverseasFeeRetries(props.feeId); if (current === sequence) rows.value = result }
catch (failure) { if (current === sequence) error.value = getApiErrorDetails(failure, '重试记录加载失败') }
finally { if (current === sequence) loading.value = false }
}
watch(() => props.feeId, () => load(), { immediate: true })
// 列表取得新的投递结果时同步刷新记录,不要求关闭再打开详情。
watch(() => props.refreshKey, () => load(true))
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<section class="density-dialog-section" aria-label="重试记录">
<h3 class="density-dialog-section__title">重试记录</h3>
<LoadErrorState v-if="error" title="重试记录加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="requestId" column-settings-mode="hide" empty-text="暂无重试记录">
<template #failureReason="{ row }">
<div class="retry-failure-reason">{{ retryFailureReason(row as OverseasFeeRetryRecord) }}</div>
<details v-if="row.result === 'FAILED' && row.errorMessage" class="retry-failure-details">
<summary>查看技术详情</summary>
<pre>{{ row.errorMessage }}</pre>
</details>
</template>
</DataTable>
</section>
</template>
<style scoped>
.retry-failure-reason { white-space: normal; overflow-wrap: anywhere; }
.retry-failure-details { margin-top: 4px; }
.retry-failure-details summary { color: var(--el-color-primary); cursor: pointer; width: fit-content; }
.retry-failure-details pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; color: var(--el-text-color-secondary); }
</style>
@@ -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<ControlForm>({ 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.
<template v-if="command === 'control-notes'">
<el-alert title="追加说明保留原购买、仓库、实收及交寄事实。需要阻断时,正式装箱、预估核对、核费及交寄须先处理异常。" type="info" :closable="false" />
<el-form-item label="关联事实类型" required><el-select v-model="form.targetType" placeholder="选择需要留痕的事实" @change="form.targetId = ''"><el-option value="PURCHASE" label="真实购买" /><el-option value="WAREHOUSE" label="实际收货仓" /><el-option value="RECEIPT" label="实收记录" /><el-option value="SHIPMENT" label="实际交寄记录" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${t(row.leg === 'OVERSEAS' ? '海外' : '国内')}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'SHIPMENT'" label="关联实际交寄" required><el-select v-model="form.targetId" placeholder="选择本单实际交寄记录"><el-option v-for="row in detail.transport.shipments" :key="row.id" :value="row.id" :label="`${row.shipmentNo} · ${row.leg === 'OVERSEAS' ? '国际段' : '寄客户段'}`" /></el-select></el-form-item>
<dl v-if="form.targetType === 'WAREHOUSE'" class="density-fields"><div><dt>当前实际仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚无实际仓' }}</dd></div></dl>
<el-form-item label="是否阻断后续正式作业" required><el-radio-group v-model="form.blocking"><el-radio :value="true">需要阻断</el-radio><el-radio :value="false">仅追加说明</el-radio></el-radio-group></el-form-item>
@@ -7,6 +7,8 @@ import { chargeDraft, feeRetryPermission, refundAvailable, targetPreview, replac
import { money, decimal, feePurposes } from '../procurementPresentation'
import { commandKey } from '../procurementPending'
import ProcurementChargeFields from './ProcurementChargeFields.vue'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[]; can: (permission: string) => boolean }>()
const form = defineModel<FinanceForm>({ required: true })
const candidates = computed(() => props.detail.fees.filter(fee => {
@@ -34,8 +36,8 @@ function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = ch
<div class="admin-density">
<template v-if="command === 'charges/confirm'">
<el-alert v-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本单已完成过核费,本次只引用已有费用版本;新增费用或重新核定请使用财务操作。" type="info" :closable="false" />
<el-alert title="一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。" type="info" :closable="false" />
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
<el-alert :title="t('一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。')" type="info" :closable="false" />
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.purpose === 'DOMESTIC_FREIGHT' ? t('国内运费') : row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
<section class="charge-section"><h3>其他费用</h3><el-form-item label="其他费用适用情况" required><el-radio-group v-model="form.otherDetermination"><el-radio value="NOT_APPLICABLE" :disabled="detail.fees.some(row => row.active && row.purpose === 'OTHER')">不适用</el-radio><el-radio value="ITEMIZED">逐项核定</el-radio></el-radio-group></el-form-item>
<template v-if="form.otherDetermination === 'ITEMIZED'"><section v-for="(row, index) in form.others" :key="row.code" class="other-charge"><div class="charge-heading"><el-form-item label="费用名称" required><el-input v-model="row.name" :readonly="row.mode === 'EXISTING'" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item><el-button v-if="row.mode === 'NEW'" class="density-action" data-tone="danger" @click="form.others.splice(index, 1)">移除新费用</el-button></div><ProcurementChargeFields v-model="form.others[index]" :detail="detail" :files="files" /></section><el-button v-if="!detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" class="density-action" @click="addOther">增加其他费用</el-button></template>
</section>
@@ -55,7 +57,7 @@ function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = ch
<el-alert v-if="command === 'fees/terminate'" title="仅终止明确失败的费用版本;仍有有效冻结时先释放,释放成功后才可重新核定,不能将未决结果当作失败。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/release-estimate'" title="释放有效预估冻结不产生退款或实际收费,后续交寄须重新满足预估核对条件。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/retry'" title="仅重试已明确失败的原资金请求,金额与费用版本不改变;处理中请刷新结果。" type="info" :closable="false" />
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${feePurposes[fee.purpose] || fee.feeItemName} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${t(feePurposes[fee.purpose] || fee.feeItemName)} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
<dl v-if="selected" class="density-fields"><div><dt>原币金额</dt><dd>{{ money(selected.originalAmount, selected.originalCurrency) }}</dd></div><div><dt>原汇率及月份</dt><dd>{{ selected.exchangeRate }} / {{ selected.exchangeRateMonth }}</dd></div></dl>
<el-form-item v-if="['fees/correct-target', 'fees/replace-failed'].includes(command)" :label="`正确原币总额(${selected?.originalCurrency || '先选择费用'})`" required><el-input v-model="form.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item>
<el-form-item v-if="command === 'fees/refund'" label="本次退款金额(CNY)" required><el-input v-model="form.refundAmount" placeholder="填写本次正额退款金额" /></el-form-item>
@@ -40,6 +40,42 @@ beforeEach(() => {
vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
})
describe('procurement purchase node operations', () => {
it('switches to domestic warehouses, clears old selection and preserves editable direction after failure', async () => {
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(async direction => direction === 'DOMESTIC_TO_OVERSEAS'
? [{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }]
: [{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外采购仓', countryCode: 'US' }])
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!
field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
field('采购方向').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises()
expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith('DOMESTIC_TO_OVERSEAS')
expect(field('采购仓').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '30001')
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('connection lost'))
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ procurementDirection: 'DOMESTIC_TO_OVERSEAS', purchaseWarehouseId: '30001' }))
const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3]
expect(sessionStorage.getItem(storageKey)).toBeNull()
expect(field('采购方向').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('DOMESTIC_TO_OVERSEAS')
await confirm(wrapper)
const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3]
expect(next.procurementDirection).toBe('DOMESTIC_TO_OVERSEAS')
expect(next.purchaseWarehouseId).toBe('30001')
expect(next.idempotencyKey).not.toBe(original.idempotencyKey)
})
it('ignores late warehouse responses from a previous direction', async () => {
let finishImport!: (rows: Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>) => void
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(direction => direction === 'DOMESTIC_TO_OVERSEAS'
? Promise.resolve([{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }])
: new Promise(resolve => { finishImport = resolve }))
const wrapper = render(); const dialog = await open(wrapper)
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购方向')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises()
finishImport([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '过期海外候选', countryCode: 'US' }]); await flushPromises()
const warehouse = dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' })
expect(warehouse.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual(['30001'])
})
it('disables terminal physical changes and keeps the management evidence form open', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'SHIPPED_TO_CUSTOMER'
value.purchaseFacts = { overseas_shipped_at: '2020-01-02', customer_shipped_at: '2020-01-03', actual_warehouse_id: '20' }
@@ -228,8 +264,8 @@ describe('procurement purchase node operations', () => {
it('requires an authoritative warehouse on approval and sends the current order version', async () => {
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓')
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', procurementDirection: 'OVERSEAS_TO_DOMESTIC', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('rejects without a warehouse and never substitutes an invented candidate', async () => {
@@ -23,6 +23,7 @@ import ProcurementTransportFields from './ProcurementTransportFields.vue'
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
import { directionLabel, procurementDirections, regionText, type ProcurementDirection } from '../procurementDirection'
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
const emit = defineEmits<{ changed: [] }>()
const auth = useAuthStore(); const access = useAccessPreviewStore()
@@ -38,7 +39,9 @@ const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); c
const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('')
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehouseLoading = ref(false); const warehouseError = ref('')
const extraFiles = ref<ProcurementFile[]>([])
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
const form = reactive({ procurementDirection: (props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC') as ProcurementDirection, decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
const warehouseDirection = computed(() => command.value === 'review' ? form.procurementDirection : props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC')
const t = (text: string) => regionText(text, warehouseDirection.value)
const control = ref(controlForm(props.detail))
const finance = ref(financeForm(props.detail))
const financialCommand = ref<ProcurementCommand>('fees/correct-target')
@@ -50,7 +53,7 @@ const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : ''
const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)))
const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' }
const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, reason: managementReason(option.value, props.detail) })))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, label: t(option.label), reason: managementReason(option.value, props.detail) ? t(managementReason(option.value, props.detail)!) : null })))
const managementBlock = computed(() => {
if (active.value?.key !== 'MANAGE' || !command.value || pending.value) return null
const reason = managementReason(command.value, props.detail)
@@ -78,13 +81,16 @@ watch(key, () => {
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
}, { immediate: true })
let warehouseGeneration = 0
async function loadWarehouses() {
if (warehouseLoading.value) return
warehouseLoading.value = true; warehouseError.value = ''
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
catch { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] }
finally { warehouseLoading.value = false }
const current = ++warehouseGeneration
const direction = warehouseDirection.value
warehouseLoading.value = true; warehouseError.value = ''; warehouses.value = []
try { const result = await listOverseasPurchaseWarehouseOptions(direction); if (current === warehouseGeneration) warehouses.value = result }
catch { if (current === warehouseGeneration) { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] } }
finally { if (current === warehouseGeneration) warehouseLoading.value = false }
}
watch(warehouseDirection, () => { form.warehouseId = ''; warehouses.value = []; if (opened.value && needsWarehouse.value) void loadWarehouses() }, { flush: 'sync' })
function open(action: ProcurementAction) {
if (!action.allowed || pending.value || storageError.value || saving.value) return
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = manageOptions.value.find(option => !option.reason)?.value || 'control-notes'; physical.value = physicalForm(props.detail)
@@ -92,7 +98,7 @@ function open(action: ProcurementAction) {
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
if (action.key.includes('DOMESTIC')) physical.value.leg = 'DOMESTIC'
Object.assign(form, { decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
Object.assign(form, { procurementDirection: props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC', decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
opened.value = true; captureBaseline()
if (needsWarehouse.value) void loadWarehouses()
}
@@ -135,7 +141,7 @@ function build(): ProcurementCommandBody {
if (needsEvidence.value && !files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单异常处置凭证')
return buildPhysical(command.value, physical.value, props.detail, base, form.warehouseId, form.voucherFileId)
}
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, procurementDirection: form.procurementDirection, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'planned-warehouse') return { ...base, warehouseId: form.warehouseId }
if (needsAmount.value) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写非负金额,最多两位小数;免费样品请填 0')
@@ -204,7 +210,7 @@ async function upload(event: Event) {
<ApiErrorMeta v-if="error && !opened" :details="error" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
<el-alert v-if="managementBlock" :title="t(managementBlock)" type="warning" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
<el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item>
@@ -213,16 +219,17 @@ async function upload(event: Event) {
<ProcurementFinanceFields v-else-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
<el-form-item v-if="command === 'review'" label="采购方向" required><el-select v-model="form.procurementDirection"><el-option v-for="(label, value) in procurementDirections" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item v-if="command === 'review'" label="审核决定"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? '实际海外收货仓' : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" placeholder="选择有效海外收货仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? t('实际海外收货仓') : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" :placeholder="t('选择有效海外收货仓')"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到海外仓时间" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item :label="t('预计到海外仓时间')" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
</el-form><el-alert v-if="error" :title="t(error.message)" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></div></template>
</AppDialog>
</section>
@@ -4,6 +4,8 @@ import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement } from '../procurementTypes'
import { newCarton, shippedCartons, receiptCorrectionReason, dispositionReason, type PhysicalForm } from '../procurementPhysicalForm'
import { regionText } from '../procurementDirection'
const t = (text: string) => regionText(text, props.detail.customerView.order.procurementDirection)
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>()
const form = defineModel<PhysicalForm>({ required: true })
const receipts = computed(() => props.detail.physical.receipts.filter(row => row.active && (props.command === 'receipts/correct' || (row.leg === form.value.leg && row.sourceCartonId === (form.value.leg === 'DOMESTIC' ? form.value.sourceCartonId : null)))))
@@ -20,20 +22,20 @@ const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货'
<template>
<div class="admin-density">
<el-alert v-if="command.endsWith('/complete')" title="仅在全部到齐或差异已有明确处置后确认收货完成;登记实收不会自动完成收货。" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成海外收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成起运仓收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/withdraw'" title="撤回未交寄清单后,需要重新确认装箱并核对后续面单和费用。" type="warning" :closable="false" />
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">海外收货<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">国内收货<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">{{ t('海外收货') }}<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">{{ t('国内收货') }}<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${t(row.leg === 'OVERSEAS' ? '海外' : '国内')}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
<template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${t(row.leg === 'OVERSEAS' ? '海外' : '国内')} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
<template v-if="command === 'packing'">
<el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" />
<section v-for="(box, index) in form.cartons" :key="index" class="packing-box">
<div class="packing-heading"><strong>第 {{ index + 1 }} 箱</strong><el-button class="density-action" data-tone="danger" @click="form.cartons.splice(index, 1)">移除本箱</el-button></div>
<div class="packing-grid"><el-form-item label="箱号" required><el-input v-model="box.cartonNo" maxlength="64" placeholder="填写本单唯一箱号" /></el-form-item><el-form-item label="长(cm)" required><el-input v-model="box.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="宽(cm)" required><el-input v-model="box.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="高(cm)" required><el-input v-model="box.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="毛重(kg)" required><el-input v-model="box.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item label="海外实收来源" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item :label="t('海外实收来源')" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
<el-button class="density-action" @click="box.sources.push({ receiptId: '', quantity: undefined })">增加来源</el-button>
</section><el-button class="density-action" data-tone="primary" @click="form.cartons.push(newCarton())">增加装货箱</el-button>
</template>
@@ -5,6 +5,8 @@ import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import type { ProcurementCommand } from '../api/procurementCommands'
import { transportForm, transportCargo, type TransportForm } from '../procurementTransportForm'
import { dateTime, measurement, money } from '../procurementPresentation'
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<TransportForm>({ required: true })
const cargo = computed(() => transportCargo(props.detail, form.value.leg))
@@ -19,8 +21,8 @@ function selectCarton(id: string) { const box = props.detail.transport.domesticC
<div class="admin-density">
<template v-if="command === 'labels'">
<el-form-item label="面单运输段" required><el-radio-group v-model="form.leg" @change="changeLeg"><el-radio value="OVERSEAS" :disabled="!!detail.purchaseFacts.overseas_shipped_at">国际段</el-radio><el-radio value="DOMESTIC" :disabled="!detail.purchaseFacts.domestic_received_at">寄客户段</el-radio></el-radio-group></el-form-item>
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : '由实际海外收货仓主档提供,保存时核对。' }}</p>
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? '国内接收人' : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" /></el-form-item></div>
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : t('由实际海外收货仓主档提供,保存时核对。') }}</p>
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? t('国内接收人') : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" /></el-form-item></div>
<el-form-item label="完整收件地址" required><el-input v-model="form.receiver.address" maxlength="1000" /></el-form-item>
<el-form-item v-if="changedReceiver" label="客户确认收件信息变更凭证" required><el-select v-model="form.addressVoucherFileId" placeholder="选择本单已上传的客户确认凭证"><el-option v-for="file in files.filter(item => item.category === 'EXCEPTION')" :key="file.fileId" :label="file.fileName" :value="file.fileId" /></el-select></el-form-item>
<section v-for="row in form.labels" :key="row.cartonId" class="label-row">
@@ -39,8 +41,8 @@ function selectCarton(id: string) { const box = props.detail.transport.domesticC
<el-form-item label="实际贴单时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
</template>
<template v-if="command === 'domestic-repack'">
<el-alert title="只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。" type="warning" :closable="false" />
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" placeholder="选择国内可寄原箱" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<el-alert :title="t('只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。')" type="warning" :closable="false" />
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" :placeholder="t('选择国内可寄原箱')" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<div class="measure-grid"><el-form-item label="新长度(cm)" required><el-input v-model="form.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新宽度(cm)" required><el-input v-model="form.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新高度(cm)" required><el-input v-model="form.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新毛重(kg)" required><el-input v-model="form.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
</template>
<template v-if="['overseas-shipment', 'customer-shipment'].includes(command)">
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { actOnOverseasFee, type OverseasFee } from '../api/overseasFees'
import { adjustStorageRentFee } from '../api/storageRent'
import { feePermissions, feeMoney } from '../overseasFeePresentation'
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
const props = defineProps<{ fee: OverseasFee | null; action: string; readonly?: boolean }>()
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const amount = ref(0), reason = ref(''), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const key = ref(''), fingerprint = ref('')
const adjusting = computed(() => props.action === 'ADJUST_RENT')
const allowed = (action: string) => !props.readonly && props.fee?.allowedActions.includes(action) && access.can(action === 'ADJUST_RENT' ? feePermissions.edit : feePermissions.review)
function reset() { amount.value = props.fee?.originalAmount ?? 0; reason.value = ''; error.value = null; key.value = ''; fingerprint.value = '' }
watch(open, value => { if (value) reset() }, { immediate: true })
async function submit(decision?: 'APPROVE' | 'REJECT') {
const fee = props.fee, action = adjusting.value ? 'ADJUST_RENT' : decision
if (!fee || !action || !allowed(action) || busy.value) return
if ((adjusting.value || decision === 'REJECT') && !reason.value.trim()) { ElMessage.warning('请填写说明'); return }
if (adjusting.value && (!Number.isFinite(amount.value) || amount.value < 0)) { ElMessage.warning('请输入有效金额'); return }
const body = adjusting.value ? { expectedVersion: fee.sourceVersion, amount: amount.value, reason: reason.value.trim() }
: { decision, reason: reason.value.trim() || undefined, expectedVersion: fee.sourceVersion }
const next = JSON.stringify([fee.sourceFeeId, body])
if (fingerprint.value !== next) { fingerprint.value = next; key.value = crypto.randomUUID() }
busy.value = true; error.value = null
try {
if (adjusting.value) await adjustStorageRentFee(fee.sourceFeeId, { expectedVersion: fee.sourceVersion, amount: amount.value, reason: reason.value.trim(), idempotencyKey: key.value })
else await actOnOverseasFee(fee.sourceFeeId, decision!, { ...body, idempotencyKey: key.value })
open.value = false; ElMessage.success(adjusting.value ? '仓租已调整' : decision === 'APPROVE' ? '审核通过,正在处理扣款' : '仓租已驳回'); emit('saved')
} catch (failure) { error.value = getApiErrorDetails(failure, '仓租操作失败') }
finally { busy.value = false }
}
</script>
<template>
<AppDialog v-model="open" append-to-body :title="adjusting ? '调整仓租' : '审核仓租'" width="760px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" :confirm-loading="busy" lock-while-submitting>
<template v-if="fee">
<p>{{ fee.businessNo }} · 仓租 {{ feeMoney(fee.originalAmount, fee.originalCurrency) }}</p>
<StorageRentFeeDetails :fee="fee" />
<p v-if="fee.feeRemark">费用说明:{{ fee.feeRemark }}</p>
<el-form label-position="top" :disabled="busy" class="density-form">
<el-form-item v-if="adjusting" :label="`仓租金额(${fee.originalCurrency})`" required><el-input-number v-model="amount" :precision="2" :min="0" :max="999999999999.99" /></el-form-item>
<el-form-item :label="adjusting ? '调整说明' : '审核说明(驳回必填)'" :required="adjusting"><el-input v-model="reason" type="textarea" :maxlength="500" show-word-limit /></el-form-item>
</el-form>
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</template>
<template #footer>
<el-button :disabled="busy" @click="open = false">关闭</el-button>
<template v-if="adjusting"><el-button v-if="allowed('ADJUST_RENT')" type="primary" :loading="busy" :disabled="!reason.trim()" @click="submit()">保存调整</el-button></template>
<template v-else>
<el-button v-if="allowed('REJECT')" type="danger" :disabled="busy || !reason.trim()" @click="submit('REJECT')">驳回</el-button>
<el-button v-if="allowed('APPROVE')" type="primary" :loading="busy" @click="submit('APPROVE')">审核通过</el-button>
</template>
</template>
</AppDialog>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import '../overseasFeeDetails.css'
import type { OverseasFee } from '../api/overseasFees'
import { feeMoney } from '../overseasFeePresentation'
import { storageRentSourceType, storageRentTime } from '../storageRentPresentation'
defineProps<{ fee: OverseasFee }>()
</script>
<template>
<section v-if="fee.storageRent" aria-label="仓租计费明细">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="出库批次">{{ fee.storageRent.outboundNo }}</el-descriptions-item>
<el-descriptions-item label="出库时间">{{ storageRentTime(fee.storageRent.outboundAt) }}</el-descriptions-item>
<el-descriptions-item label="原计算金额">{{ feeMoney(fee.storageRent.calculatedAmount, fee.originalCurrency) }}</el-descriptions-item>
<el-descriptions-item label="应扣金额">{{ feeMoney(fee.amount, fee.currency) }}</el-descriptions-item>
<el-descriptions-item label="实扣金额">{{ feeMoney(fee.storageRent.chargedAmount, fee.currency) }}</el-descriptions-item>
</el-descriptions>
<el-table :data="fee.storageRent.lines" border empty-text="暂无计租批次">
<el-table-column label="来源类型" min-width="130"><template #default="{ row }">{{ storageRentSourceType(row.sourceBusinessType) }}</template></el-table-column>
<el-table-column label="来源单号" min-width="180"><template #default="{ row }">{{ row.sourceBusinessNo || '—' }}</template></el-table-column>
<el-table-column label="入库批次" min-width="160"><template #default="{ row }">{{ row.batchNo || '—' }}</template></el-table-column>
<el-table-column label="入库时间" min-width="170"><template #default="{ row }">{{ storageRentTime(row.inboundAt) }}</template></el-table-column>
<el-table-column prop="quantity" label="本次出库数量" width="130" />
<el-table-column prop="days" label="计租天数" width="100" />
<el-table-column label="原计算仓租" min-width="130"><template #default="{ row }">{{ feeMoney(row.calculatedAmount, fee.originalCurrency) }}</template></el-table-column>
</el-table>
</section>
</template>
@@ -0,0 +1,103 @@
import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import * as api from '../api/storageRent'
import type { StorageRentLot } from '../api/storageRent'
import StorageRentPanel from './StorageRentPanel.vue'
const { default: ElementPlus } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
vi.mock('../api/storageRent', () => ({ getStorageRentLot: vi.fn(), listStorageRentLots: vi.fn(), listStorageRentWarehouses: vi.fn() }))
const lot: StorageRentLot = {
id: '9223372036854775001', customerId: '1', customerName: '测试客户', businessType: 'LASTLEG_ORDER', businessNo: 'FL1',
warehouseId: '2', warehouseName: '测试仓', batchNo: 'B1', inboundAt: '2026-10-22T00:00:00Z', storageDays: 1,
originalQuantity: 10, remainingQuantity: 8, quantityUnit: 'PACKAGE', billingQuantity: 1, billingUnit: 'CBM',
ruleCode: 'TRANSIT_V1', status: 'IN_STOCK', generatedAmount: 0, generatedCurrency: null, generatedAmounts: [],
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [lot], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.listStorageRentWarehouses).mockResolvedValue([{ id: '2', warehouseName: '测试仓' }])
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot, charges: [{
id: '3', outboundNo: 'OUT1', businessNo: 'FL1', quantity: 2, storageDays: 1, originalAmount: 0, currency: 'USD',
settlementAmount: 0, currencyRate: 7, status: 'SETTLED', createdAt: '2026-10-22T01:00:00Z', outboundAt: '2026-10-22T01:00:00Z',
}] })
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('仓租列表服务端契约', () => {
it('shows storage age and remaining quantity and traces each half pallet without financial actions', async () => {
const returned = { ...lot, businessType: 'RETURN_ORDER', businessNo: 'TH1', quantityUnit: 'PIECE', originalQuantity: 6, remainingQuantity: 2, billingQuantity: 1.5, billingUnit: 'PALLET', status: 'IN_STOCK', generatedAmounts: [{ currency: 'USD', amount: 1.5 }], settledAmount: 10, chargeCount: 2, settledChargeCount: 2 }
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [returned], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: returned, charges: ['TH1', 'CD1'].map((businessNo, index) => ({ id: String(index), outboundNo: `OUT${index}`, businessNo, quantity: 2, storageDays: 1, originalAmount: 0.75, currency: 'USD', settlementAmount: 5, currencyRate: 6.67, status: 'SETTLED', createdAt: '', outboundAt: '' })) })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain('2 件')
expect(wrapper.text()).toContain('库龄(天)')
expect(wrapper.text()).toContain('来源单号')
expect(body.text()).toContain('TH1')
expect(wrapper.text()).not.toContain('仓租 / 扣款')
await body.findAll('button').find(button => button.text().trim() === '详情')!.trigger('click'); await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('0.5 托')
expect(dialog.textContent).toContain('OUT0')
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).not.toContain('USD')
expect(dialog.textContent).not.toContain('审核')
expect(dialog.textContent).toContain('CD1')
expect(dialog.textContent).toContain('来源单号')
expect(dialog.textContent).toContain('出库业务单号')
})
it('displays and filters first-leg lots using LASTLEG_ORDER and retains inbound and outbound evidence', async () => {
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_STOCK', page: 1 }))
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false)
const options = toolbar.props('fields').find(field => field.key === 'businessType')?.options
expect(options).toContainEqual({ value: 'LASTLEG_ORDER', label: '头程入仓' })
expect(options).not.toContainEqual({ value: 'FIRST_LEG', label: '头程入仓' })
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('头程入仓')
toolbar.vm.$emit('search', { businessType: 'LASTLEG_ORDER' })
await flushPromises()
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'LASTLEG_ORDER', status: 'IN_STOCK', page: 1 }))
toolbar.vm.$emit('search', {})
await flushPromises()
expect(api.listStorageRentLots).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_STOCK', page: 1 }))
const detailButton = wrapper.findAll('.el-table__body-wrapper button').find(button => button.text().trim() === '详情')
await detailButton!.trigger('click'); await flushPromises()
expect(api.getStorageRentLot).toHaveBeenCalledWith(lot.id)
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('2026-10-22')
expect(dialog.textContent).toContain('8 箱')
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).not.toContain('实扣金额')
})
it('keeps inventory focused on age and outbound history even when charges use different currencies', async () => {
const mixedLot = { ...lot, generatedAmount: null, generatedCurrency: null, generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }] }
vi.mocked(api.listStorageRentLots).mockResolvedValue({ records: [mixedLot], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getStorageRentLot).mockResolvedValue({ lot: mixedLot, charges: [
{ id: '3', outboundNo: 'OUT1', businessNo: 'FL1', quantity: 1, storageDays: 8, originalAmount: 12, currency: 'USD', settlementAmount: 84, currencyRate: 7, status: 'SETTLED', createdAt: '2026-10-22T01:00:00Z', outboundAt: '2026-10-22T01:00:00Z' },
{ id: '4', outboundNo: 'OUT2', businessNo: 'FL1', quantity: 1, storageDays: 9, originalAmount: 23, currency: 'CNY', settlementAmount: 23, currencyRate: 1, status: 'SETTLED', createdAt: '2026-10-22T02:00:00Z', outboundAt: '2026-10-22T02:00:00Z' },
] })
const wrapper = mount(StorageRentPanel, { attachTo: document.body, props: { customers: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain('8 箱')
expect(body.text()).not.toContain('USD')
expect(body.text()).not.toContain('CNY')
await body.findAll('button').find(button => button.text().trim() === '详情')!.trigger('click'); await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('OUT1')
expect(dialog.textContent).toContain('OUT2')
expect(dialog.textContent).toContain('计租天数')
const rows = Array.from(dialog.querySelectorAll('.el-table__body-wrapper tbody tr'))
expect(rows[0]?.textContent).toContain('8')
expect(rows[1]?.textContent).toContain('9')
expect(dialog.textContent).not.toContain('USD')
expect(dialog.textContent).not.toContain('CNY')
})
})
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses, type StorageRentCharge, type StorageRentDetail, type StorageRentLot } from '../api/storageRent'
import { storageRentBillingShare, storageRentTime } from '../storageRentPresentation'
const props = defineProps<{ customers: Array<{ customerId: string; customerName: string }> }>()
const rows = ref<StorageRentLot[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const warehouses = ref<Array<{ id: string; warehouseName: string }>>([])
const warehouseError = ref<ApiErrorDetails | null>(null), warehouseLoading = ref(false)
const page = ref(1), pageSize = ref(20), total = ref(0), detail = ref<StorageRentDetail | null>(null), detailOpen = ref(false)
const detailLoading = ref('')
const filters = ref<QueryModel>({ customerId: '', businessNo: '', warehouseId: '', businessType: '' }), applied = ref({ ...filters.value })
const typeLabels: Record<string, string> = { LASTLEG_ORDER: '头程入仓', WAREHOUSE_TRANSFER: '仓储转运', RETURN_ORDER: '退货', RELABEL_ORDER: '换标' }
const unitLabels: Record<string, string> = { PACKAGE: '箱', PIECE: '件', CBM: 'CBM', PALLET: '托' }
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '客户', options: props.customers.map(row => ({ label: row.customerName, value: row.customerId })), props: { filterable: true, 'aria-label': '客户' } },
{ key: 'businessNo', type: 'input', label: '来源单号', placeholder: '来源单号', width: 220, props: { 'aria-label': '来源单号' } },
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '海外仓', options: warehouses.value.map(row => ({ value: String(row.id), label: row.warehouseName })), props: { filterable: true, 'aria-label': '海外仓', loading: warehouseLoading.value } },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '业务类型', options: Object.entries(typeLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '业务类型' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' },
{ prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'businessNo', label: '来源单号', minWidth: 180 },
{ prop: 'businessType', label: '业务类型', width: 110, formatter: (row: StorageRentLot) => typeLabels[row.businessType] || row.businessType },
{ prop: 'warehouseName', label: '海外仓', minWidth: 140 }, { prop: 'batchNo', label: '批次号', minWidth: 150 },
{ prop: 'inboundAt', label: '入库时间', minWidth: 170, formatter: (row: StorageRentLot) => storageRentTime(row.inboundAt) },
{ prop: 'storageDays', label: '库龄(天)', width: 100, align: 'right' },
{ key: 'remaining', label: '剩余数量', width: 110, formatter: (row: StorageRentLot) => `${row.remainingQuantity} ${unitLabels[row.quantityUnit] || row.quantityUnit}` },
{ key: 'billing', label: '计租数量', width: 120, formatter: (row: StorageRentLot) => `${row.billingQuantity} ${unitLabels[row.billingUnit] || row.billingUnit}` },
]
const chargeColumns = computed<DataTableColumn[]>(() => [
{ prop: 'outboundNo', label: '出库批次', minWidth: 170 },
{ prop: 'businessNo', label: '出库业务单号', minWidth: 170 },
{ prop: 'quantity', label: '出库数量', width: 105, align: 'right', formatter: (row: { quantity: number }) => `${row.quantity} ${unitLabels[detail.value?.lot.quantityUnit || ''] || ''}`.trim() }, { prop: 'storageDays', label: '计租天数', width: 100, align: 'right' },
{ key: 'billingShare', label: '本次计租份额', width: 120, formatter: (row: StorageRentCharge) => detail.value ? storageRentBillingShare(row.quantity, detail.value.lot) : '—' },
{ prop: 'outboundAt', label: '出库时间', minWidth: 165, formatter: (row: { outboundAt: string }) => storageRentTime(row.outboundAt) },
])
let sequence = 0
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
const query = Object.fromEntries(Object.entries(applied.value).map(([key, value]) => [key, value ? String(value) : undefined]))
const data = await listStorageRentLots({ ...query, status: 'IN_STOCK', page: page.value, size: pageSize.value })
if (current === sequence) { rows.value = data.records; total.value = data.total }
} catch (failure) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '仓租库存加载失败') } }
finally { if (current === sequence) loading.value = false }
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
async function openDetail(row: StorageRentLot) {
if (detailLoading.value) return
detailLoading.value = row.id
try { detail.value = await getStorageRentLot(row.id); detailOpen.value = true }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '仓租详情加载失败').message) }
finally { detailLoading.value = '' }
}
async function loadWarehouses() {
warehouseLoading.value = true; warehouseError.value = null
try { warehouses.value = await listStorageRentWarehouses() }
catch (failure) { warehouseError.value = getApiErrorDetails(failure, '仓库选项加载失败') }
finally { warehouseLoading.value = false }
}
watch([page, pageSize], () => void load())
onMounted(() => { void load(); void loadWarehouses() })
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" toolbar-placement="filters" preference-key="overseas-storage-rent" row-key="id" empty-text="暂无仓租库存">
<template #filters><QueryToolbar v-model="filters" ariaLabel="仓租库存筛选" :fields="fields" :loading="loading" @search="search" /></template>
<template #notices>
<LoadErrorState v-if="error" title="仓租库存加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="仓库选项加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="[{ key: 'DETAIL', label: '详情', loading: detailLoading === (row as StorageRentLot).id, disabled: !!detailLoading }]" @action="openDetail(row as StorageRentLot)" /></template>
<template #pagination-left>共 {{ total }} 个批次</template>
</DataTable>
<AppDialog v-model="detailOpen" title="仓租详情" width="1080px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="3" border class="density-descriptions">
<el-descriptions-item label="客户">{{ detail.lot.customerName }}</el-descriptions-item><el-descriptions-item label="来源单号">{{ detail.lot.businessNo }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ detail.lot.warehouseName }}</el-descriptions-item>
<el-descriptions-item label="批次号">{{ detail.lot.batchNo }}</el-descriptions-item><el-descriptions-item label="入库时间">{{ storageRentTime(detail.lot.inboundAt) }}</el-descriptions-item><el-descriptions-item label="库龄">{{ detail.lot.storageDays }} 天</el-descriptions-item>
<el-descriptions-item label="入库数量">{{ detail.lot.originalQuantity }} {{ unitLabels[detail.lot.quantityUnit] }}</el-descriptions-item><el-descriptions-item label="剩余数量">{{ detail.lot.remainingQuantity }} {{ unitLabels[detail.lot.quantityUnit] }}</el-descriptions-item><el-descriptions-item label="计租规则">{{ detail.lot.ruleCode === 'RETURN_V1' ? '退货仓租' : '中转仓租' }}</el-descriptions-item>
</el-descriptions>
<div class="rent-charges"><DataTable :data="detail.charges" :columns="chargeColumns" :page-sizes="[20]" empty-text="暂无出库记录" max-height="360px" /></div>
</template>
</AppDialog>
</template>
<style scoped>
.rent-charges { margin-top: 16px; }
</style>
+5
View File
@@ -1,3 +1,8 @@
export { overseasBusinessPermissions } from './permissions'
export { overseasBusinessRoutes } from './routes'
export type * from './types'
export { listOverseasFeeItems } from './api/overseasFees'
export { feeStatus, sameProjectFees, sameProjectFeeSummary } from './overseasFeePresentation'
export type { OverseasFee } from './api/overseasFees'
export { default as BusinessDocumentFees } from './components/BusinessDocumentFees.vue'
@@ -0,0 +1,8 @@
.overseas-fee-descriptions .el-descriptions__table {
table-layout: fixed;
}
.overseas-fee-descriptions .el-descriptions__content.el-descriptions__cell {
overflow-wrap: anywhere;
white-space: normal;
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { feeActions, feePermissions, feeProcessingLabel, feeProcessingStatus, feeStatus, feeTime, sameProjectFees, sameProjectFeeSummary } from './overseasFeePresentation'
import type { OverseasFee } from './api/overseasFees'
const fee = (overrides: Partial<OverseasFee> = {}): OverseasFee => ({
direction: 'RECEIVABLE',
id: '9007199254740993', sourceFeeId: '9007199254740993', sourceVersion: 1, customerId: '1', customerName: '客户',
sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER', businessId: '2', businessNo: 'WT1',
orderId: null, warehouseId: '3', feeItemCode: 'FEE1', 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: 'PENDING',
collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewedAt: null,
createdAt: '', updatedAt: '', allowedActions: [], ...overrides,
})
describe('海外费用动作资格', () => {
it('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => {
expect(feeTime('2026-10-23T10:43:25Z')).toBe(feeTime('2026-10-23T18:43:25+08:00'))
const date = new Date('2026-10-23T10:43:25Z')
expect(feeTime('2026-10-23T10:43:25Z')).toContain(String(date.getHours()).padStart(2, '0') + ':43:25')
expect(feeTime('2026-10-23T10:43:25')).toBe('2026-10-23 10:43:25')
expect(feeTime(null)).toBe('—')
})
it('以服务端动作白名单为准,不根据待审核状态自行开放审核', () => {
expect(feeActions(fee(), () => true).map(row => row.key)).toEqual(['DETAIL'])
expect(feeActions(fee({ allowedActions: ['APPROVE', 'REJECT', 'CANCEL'] }), permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'REVIEW'])
expect(feeActions(fee({ allowedActions: ['APPROVE', 'CANCEL'] }), permission => permission === feePermissions.edit).map(row => row.key)).toEqual(['DETAIL'])
expect(feeActions(fee({ allowedActions: ['REJECT', 'CANCEL'] }), permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'REVIEW'])
})
it('不为未知动作暴露按钮,请求中全部动作禁用', () => {
const actions = feeActions(fee({ allowedActions: ['DELETE', 'RETRY_FREEZE'] }), () => true, true)
expect(actions.map(row => row.key)).toEqual(['DETAIL', 'RETRY_FREEZE'])
expect(actions.every(row => row.disabled)).toBe(true)
})
it.each([{ reviewStatus: 'REJECTED' }, { sourceStatus: 'VOIDED' }])('关闭费用仅保留服务端允许的资金释放恢复 %o', overrides => {
const closedFee = fee({ ...overrides, walletStatus: 'RELEASE_FAILED', allowedActions: ['APPROVE', 'ADJUST', 'RETRY_SETTLE', 'RETRY_FREEZE', 'RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'] })
expect(feeActions(closedFee, () => true).map(row => row.key)).toEqual(['DETAIL', 'RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'])
expect(feeActions(closedFee, () => false).map(row => row.key)).toEqual(['DETAIL'])
expect(feeProcessingLabel(closedFee)).toBe('处理失败')
})
it('区分账单推送与实际余额扣款', () => {
expect(feeStatus(fee({ walletStatus: 'SETTLE_PENDING' }))).toBe('扣费中')
expect(feeStatus(fee({ walletStatus: 'SETTLED' }))).toBe('已扣费')
expect(feeStatus(fee({ collectionMode: 'BILL', publicationStatus: 'PUBLISHED' }))).toBe('已推送结算')
expect(feeStatus(fee({ sourceStatus: 'CANCELLED' }))).toBe('已取消')
})
it.each([
[{ walletStatus: 'FROZEN' }, 'WAITING'], [{ walletStatus: 'FREEZE_PENDING' }, 'PROCESSING'],
[{ walletStatus: 'SETTLE_FAILED' }, 'FAILED'], [{ walletStatus: 'RELEASE_FAILED' }, 'FAILED'],
[{ walletStatus: 'SETTLED' }, 'COMPLETED'], [{ collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING' }, 'PROCESSING'],
[{ publicationStatus: 'PUBLISHED', deliveryStatus: 'DEAD' }, 'FAILED'], [{ publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', walletStatus: 'SETTLED', deliveryStatus: 'PENDING', deliveryErrorMessage: 'S0500' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', walletStatus: 'SETTLED', deliveryStatus: 'PROCESSING', deliveryErrorMessage: 'S0500' }, 'COMPLETED'],
[{ publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED', deliveryErrorMessage: '旧错误' }, 'COMPLETED'],
[{ reviewStatus: 'REJECTED', walletStatus: 'RELEASE_PENDING' }, 'PROCESSING'], [{ sourceStatus: 'VOIDED' }, 'CLOSED'],
] as Array<[Partial<OverseasFee>, string]>)('业务状态归并 %o → %s', (overrides, status) => {
expect(feeProcessingStatus(fee(overrides))).toBe(status)
})
it('业务界面只显示完成或失败,审核前及短暂处理过程不显示额外状态', () => {
for (const walletStatus of ['FREEZE_PENDING', 'FROZEN', 'SETTLE_PENDING', 'RELEASE_PENDING', 'RELEASED']) {
expect(feeProcessingLabel(fee({ walletStatus }))).toBe('—')
}
expect(feeProcessingLabel(fee({ reviewStatus: 'REJECTED' }))).toBe('—')
expect(feeProcessingLabel(fee({ walletStatus: 'SETTLE_FAILED' }))).toBe('处理失败')
expect(feeProcessingLabel(fee({ walletStatus: 'SETTLED' }))).toBe('已完成')
})
it('应付成功只表示推送成功,失败或等待回执时不能显示已付款', () => {
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', walletStatus: 'NOT_REQUIRED', publicationStatus: 'PUBLISHED', deliveryStatus: 'PUBLISHED' }))).toBe('推送成功')
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'DEAD' }))).toBe('处理失败')
expect(feeProcessingLabel(fee({ direction: 'PAYABLE', collectionMode: 'BILL', publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING' }))).toBe('—')
})
it('重复提醒包含驳回记录且区分收付方向,金额按原币展示', () => {
const original = fee({ sourceFeeId: 'A', reviewStatus: 'REJECTED' })
const payable = fee({ sourceFeeId: 'B', direction: 'PAYABLE' })
const voided = fee({ sourceFeeId: 'C', sourceStatus: 'VOIDED' })
const differentItem = fee({ sourceFeeId: 'D', feeItemCode: 'OTHER' })
expect(sameProjectFees([original, payable, voided, differentItem], 'FEE1', 'RECEIVABLE')).toEqual([original])
expect(sameProjectFees([original], 'FEE1', 'RECEIVABLE', 'A')).toEqual([])
expect(sameProjectFeeSummary(original)).toBe('USD 10.00 · 已驳回')
})
})
@@ -0,0 +1,107 @@
import type { RowActionItem } from '@/shared/components/RowActions.vue'
import type { OverseasFee } from './api/overseasFees'
export const feePermissions = {
list: 'platform:overseas-fee:list', edit: 'platform:finance:fee-source:edit', review: 'platform:finance:fee-source:review',
} as const
export const businessLabels: Record<string, string> = {
LASTLEG_ORDER: '尾程订单', WAREHOUSE_TRANSFER: '仓储转运', RETURN_ORDER: '退货', RELABEL_ORDER: '换标',
US_TRUCK: '本土卡车', LOCAL_LOOSEBOARD: '本土散板', OVERSEAS_PROCUREMENT: '海外采购', DISCARD_ORDER: '废弃',
OVERSEAS_INBOUND: '入库', WAREHOUSE_SERVICE: '库内服务', DELIVERY_ORDER: '派送', OVERSEAS_CONTAINER: '海外柜次',
}
export const reviewLabels: Record<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
/** 已终结来源优先显示终态,避免保留的审核快照误导用户继续审核。 */
export function feeReviewLabel(fee: Pick<OverseasFee, 'sourceStatus' | 'reviewStatus'>) {
if (fee.sourceStatus === 'VOIDED') return '已作废'
if (fee.sourceStatus === 'CANCELLED') return '已取消'
if (fee.sourceStatus === 'SUPERSEDED') return '已调整'
return reviewLabels[fee.reviewStatus] || fee.reviewStatus
}
/** 失败发生于冻结、实扣或释放的不同阶段,不能一律称为扣费失败。 */
export function feeFundingErrorLabel(fee: Pick<OverseasFee, 'walletStatus'>) {
if (fee.walletStatus === 'FREEZE_FAILED') return '冻结失败'
if (fee.walletStatus === 'RELEASE_FAILED') return '释放失败'
return '扣费异常'
}
/** 业务分类取服务端真实单据类型,派送费项目不改变换标单归属。 */
export function feeBusinessLabel(fee: Pick<OverseasFee, 'businessType' | 'displayBusinessType'>) {
const type = fee.displayBusinessType || fee.businessType
return businessLabels[type] || type
}
export const walletLabels: Record<string, string> = {
FREEZE_PENDING: '锁定中', FROZEN: '已锁定', FREEZE_FAILED: '锁定失败', SETTLE_PENDING: '扣费中', SETTLED: '已扣费',
SETTLE_FAILED: '扣费失败', RELEASE_PENDING: '释放中', RELEASED: '已释放', RELEASE_FAILED: '释放失败',
}
export const processingLabels: Record<string, string> = { COMPLETED: '已完成', FAILED: '处理失败' }
/** 余额实扣即完成;账单推送按接收回执判断,自动重试期间保留真实失败。 */
export function feeProcessingStatus(fee: Pick<OverseasFee, 'sourceStatus' | 'publicationStatus' | 'reviewStatus' | 'walletStatus' | 'deliveryStatus' | 'deliveryErrorMessage'>) {
// 业务关闭不代表冻结资金已释放;失败必须保留可见,方便恢复处理。
if (fee.walletStatus === 'RELEASE_FAILED' || (['FREEZE_PENDING', 'RELEASE_PENDING'].includes(fee.walletStatus || '')
&& (fee.deliveryStatus === 'DEAD' || !!fee.deliveryErrorMessage?.trim()))) return 'FAILED'
if (['FREEZE_PENDING', 'RELEASE_PENDING'].includes(fee.walletStatus || '')) return 'PROCESSING'
if (['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus) || fee.reviewStatus === 'REJECTED') return 'CLOSED'
if (fee.walletStatus === 'SETTLED') return 'COMPLETED'
if (fee.deliveryStatus === 'DEAD' || (['PENDING', 'PROCESSING'].includes(fee.deliveryStatus || '') && fee.deliveryErrorMessage?.trim()) || fee.walletStatus?.endsWith('_FAILED')) return 'FAILED'
if (fee.deliveryStatus === 'PENDING' || fee.deliveryStatus === 'PROCESSING' || fee.walletStatus?.endsWith('_PENDING')) return 'PROCESSING'
if (fee.publicationStatus === 'PUBLISHED') return 'COMPLETED'
return 'WAITING'
}
/** 应付完成仅代表财务已接收,不能用完成文案暗示供应商已收款。 */
export const feeProcessingLabel = (fee: OverseasFee) => feeProcessingStatus(fee) === 'COMPLETED' && fee.direction === 'PAYABLE'
? '推送成功' : processingLabels[feeProcessingStatus(fee)] || '—'
/** 只提醒当前有效版本的同项目,已驳回记录仍用于解释重新录费原因。 */
export function sameProjectFees(rows: OverseasFee[], code: string, direction: string, excludeId?: string) {
return rows.filter(row => row.feeItemCode === code && row.direction === direction && row.sourceStatus === 'ACTIVE' && row.sourceFeeId !== excludeId)
}
/** 重复收费提示同时交代原币金额和审核结果,不把不同币种直接相加。 */
export const sameProjectFeeSummary = (fee: OverseasFee) => `${feeMoney(fee.originalAmount, fee.originalCurrency)} · ${reviewLabels[fee.reviewStatus] || fee.reviewStatus}`
export const retryResultLabels: Record<string, string> = { SUCCESS: '重试成功', FAILED: '重试后仍失败', SUBMITTED: '已提交重试' }
export const actionLabels: Record<string, string> = {
REVIEW: '审核', APPROVE: '审核通过', REJECT: '驳回', RETRY_FREEZE: '重试锁定', RETRY_SETTLE: '重试扣费',
RETRY_RELEASE: '重试释放', SUBMIT: '提交审核', VOID: '作废', ADJUST: '调整',
RETRY_PUBLISH: '重试推送', RETRY_FUNDING_DELIVERY: '重试资金指令', ADJUST_RENT: '调整仓租',
}
export function feeStatus(fee: Pick<OverseasFee, 'sourceStatus' | 'publicationStatus' | 'deliveryStatus' | 'deliveryKind'> & { collectionMode?: string | null; walletStatus?: string | null }) {
if (fee.sourceStatus === 'VOIDED') return '已作废'
if (fee.sourceStatus === 'SUPERSEDED') return '已调整'
if (fee.sourceStatus === 'CANCELLED') return '已取消'
if (fee.collectionMode === 'WALLET' && fee.walletStatus === 'SETTLED') return '已扣费'
if (fee.deliveryKind === 'FUNDING' && fee.deliveryStatus === 'DEAD') return '资金指令发送失败'
if (fee.deliveryKind === 'FEE' && fee.deliveryStatus) {
if (fee.deliveryStatus === 'DEAD') return '推送失败'
if (fee.deliveryStatus !== 'PUBLISHED') return fee.collectionMode === 'WALLET' ? '已扣费,待推送' : '推送中'
}
if (fee.collectionMode === 'BILL') return fee.publicationStatus === 'PUBLISHED' ? '已推送结算' : '待推送结算'
return fee.walletStatus ? walletLabels[fee.walletStatus] || fee.walletStatus : fee.collectionMode === 'WALLET' ? '待锁定' : '—'
}
export function feeActions(fee: OverseasFee, can: (permission: string) => boolean, busy = false): RowActionItem[] {
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
const canReview = can(feePermissions.review)
const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action))
.filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action))
.filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit))
.map(action => ({ key: action, label: actionLabels[action]!, priority: 20,
tone: ['VOID'].includes(action) ? 'danger' as const : 'primary' as const, disabled: busy }))
// 审核通过与驳回共用一个入口;取消不在业务单据费用行提供。
if (!closed && canReview && fee.allowedActions.some(action => ['APPROVE', 'REJECT'].includes(action))) {
visibleActions.push({ key: 'REVIEW', label: actionLabels.REVIEW!, priority: 90, tone: 'primary', disabled: busy })
}
return [{ key: 'DETAIL', label: '详情', priority: 100, disabled: busy }, ...visibleActions]
}
export function feeMoney(amount: number | string | null | undefined, currency = '') {
if (amount == null) return '—'
return `${currency} ${Number(amount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`.trim()
}
export function feeTime(value?: string | null) {
if (!value) return '—'
// 只有服务端明确标记时区的时间才转换,兼容旧的无时区数据。
if (/(?:Z|[+-]\d{2}:\d{2})$/i.test(value)) {
const date = new Date(value)
if (!Number.isNaN(date.getTime())) {
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
}
return value.replace('T', ' ').slice(0, 19)
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import type { OverseasFeeRetryRecord } from './api/overseasFees'
import { retryFailureReason } from './overseasFeeRetryPresentation'
const failed = (errorMessage: string | null, action = '重试推送'): OverseasFeeRetryRecord => ({
requestId: 'retry-1', requestedAt: '', operatorName: '操作员', action, result: 'FAILED', errorMessage,
})
describe('费用重试失败原因', () => {
it('指出财务接收失败,但不把通用内部错误猜成字段故障', () => {
const text = retryFailureReason(failed('EventDeliveryFailure: Consumer rejected event: code=S0500, message=Internal server error, requestId=abc, traceId=def'))
expect(text).toBe('推送结算失败:财务系统处理异常,请联系管理员排查后重试。')
expect(text).not.toMatch(/字段|EventDeliveryFailure|requestId|traceId/)
})
it('只有明确的字段错误才说明字段长度不足', () => {
expect(retryFailureReason(failed("Data truncation: Data too long for column 'payload_hash' at row 1")))
.toBe('推送结算失败:财务系统的摘要字段长度不足,请联系管理员更新数据库后重试。')
})
it('扣费与余额不足明确指出客户账户及处理方式', () => {
expect(retryFailureReason(failed('可用额度不足', '重试扣费')))
.toBe('扣费失败:客户余额及可用授信不足,请充值或调整授信后重试。')
})
it('超时不能当作未扣款,提示先核查实际费用状态', () => {
expect(retryFailureReason(failed('Read timed out', '重试资金指令')))
.toBe('发送资金指令失败:未及时收到财务处理结果,请先核对费用状态再重试。')
})
it.each([
['Connection refused', '无法连接财务系统'],
["Table 'baiyue_finance.fin_inbox_event' doesn't exist", '缺少必要的表或字段'],
[null, '未返回具体原因'],
['UnknownFailure: unrecognized', '根据技术详情排查'],
['Consumer rejected event: code=FIN-EVT-017, message=费用来源与成功扣款的客户、业务或金额不匹配, requestId=abc', '费用来源与成功扣款的客户、业务或金额不匹配'],
])('显示已知原因,未知异常保留排查入口:%s', (raw, expected) => {
expect(retryFailureReason(failed(raw))).toContain(expected)
})
it('成功及已提交记录不展示过期失败原因', () => {
for (const result of ['SUCCESS', 'SUBMITTED'] as const) {
expect(retryFailureReason({ ...failed('旧失败原因'), result })).toBe('—')
}
})
})
@@ -0,0 +1,34 @@
import type { OverseasFeeRetryRecord } from './api/overseasFees'
/** 将已知失败转换成业务说明,保留原始异常供技术详情查看,不从通用错误猜测具体故障。 */
export function retryFailureReason(row: OverseasFeeRetryRecord): string {
if (row.result !== 'FAILED') return '—'
const step = ({ 重试推送: '推送结算', 重试扣费: '扣费', 重试锁定: '锁定费用', 重试释放: '释放费用', 重试资金指令: '发送资金指令' } as Record<string, string>)[row.action] || '费用处理'
const prefix = `${step}失败:`
const raw = row.errorMessage?.trim() || ''
if (!raw) return `${prefix}系统未返回具体原因,请联系管理员核查。`
if (/余额不足|额度不足|授信不足|insufficient (?:funds|balance|credit)/i.test(raw)) {
return `${prefix}客户余额及可用授信不足,请充值或调整授信后重试。`
}
if (/Data too long for column ['"`](?:payload_hash|source_hash)['"`]/i.test(raw)) {
return `${prefix}财务系统的摘要字段长度不足,请联系管理员更新数据库后重试。`
}
if (/doesn't exist|unknown column|缺少.*(?:表|字段)/i.test(raw)) {
return `${prefix}系统缺少必要的表或字段,请联系管理员更新数据库后重试。`
}
if (/timeout|timed out|超时/i.test(raw)) {
return `${prefix}未及时收到财务处理结果,请先核对费用状态再重试。`
}
if (/connection refused|connectexception|connection reset|连接失败|连接被拒绝|no available (?:server|instance)/i.test(raw)) {
return `${prefix}无法连接财务系统,请联系管理员检查服务后重试。`
}
if (/code=S0500|internal server error/i.test(raw)) {
return `${prefix}财务系统处理异常,请联系管理员排查后重试。`
}
// 保留服务端返回的中文业务原因,隐藏异常类名、请求编号和堆栈。
const message = raw.match(/\bmessage=(.*?)(?:,\s*(?:requestId|traceId)=|$)/i)?.[1]?.trim() || raw
if (/[\u4e00-\u9fff]/.test(message) && !/Exception|\bat\s+[\w.]+\(|\b(?:SELECT|INSERT|UPDATE|DELETE)\b|requestId=|traceId=/i.test(message)) {
return prefix + message
}
return `${prefix}系统处理异常,请联系管理员根据技术详情排查。`
}
@@ -0,0 +1,76 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { ApiResponseError } from '@/shared/api/http'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import type { LocalLooseboardDetail } from '../types'
import LooseboardOrderDetailView from './LooseboardOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('../api/platformOverseasBusiness', () => ({ getLocalLooseboardOrder: vi.fn() }))
function order(overrides: Partial<LocalLooseboardDetail> = {}): LocalLooseboardDetail {
return {
id: '9007199254741001', orderNo: 'LB-TEST', customerId: '9007199254741999', fleetArrangement: null,
palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', productName: '测试货物', estimatedPalletCount: 2,
origin: 'LA', originAddress: '起运仓地址', destination: 'ONT', destinationAddress: '目的仓地址',
originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', packageCount: 20,
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
status: 'PENDING_ACCEPTANCE', version: 1, remark: '运输备注', createdBy: '7',
createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], ...overrides,
}
}
function mountDetail() {
return mount(LooseboardOrderDetailView, { global: { plugins: [ElementPlus], stubs: { BusinessDocumentFees: true, FleetArrangementSummary: true, DataTable: true } } })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
routing.route = reactive({ params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order())
})
describe('looseboard document fees', () => {
it('loads the customer scoped document and passes its exact identity to fees', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999')
expect(wrapper.findComponent(BusinessDocumentFees).props()).toMatchObject({
businessType: 'LOCAL_LOOSEBOARD', businessNo: 'LB-TEST', customerId: '9007199254741999', businessId: '9007199254741001',
})
expect(wrapper.text()).toContain('起运仓地址')
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
})
it('does not expose fees or query a document without a real customer', async () => {
routing.route.query = {}
const wrapper = mountDetail(); await flushPromises()
expect(getLocalLooseboardOrder).not.toHaveBeenCalled()
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(false)
expect(wrapper.text()).toContain('缺少有效客户或订单信息')
})
it('keeps error metadata and retries before exposing fee operations', async () => {
vi.mocked(getLocalLooseboardOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, undefined, 'LB-409')).mockResolvedValueOnce(order())
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).toContain('LB-409')
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(false)
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).exists()).toBe(true)
})
it('discards an older response when navigating to another order', async () => {
let completeFirst!: (value: LocalLooseboardDetail) => void
vi.mocked(getLocalLooseboardOrder).mockImplementationOnce(() => new Promise(resolve => { completeFirst = resolve }))
.mockResolvedValueOnce(order({ id: '9007199254741002', orderNo: 'LB-NEXT' }))
const wrapper = mountDetail()
routing.route.params.orderId = 'LB-NEXT'; await flushPromises()
completeFirst(order()); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).props('businessId')).toBe('9007199254741002')
expect(wrapper.findComponent(BusinessDocumentFees).props('businessNo')).toBe('LB-NEXT')
})
})
@@ -0,0 +1,87 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import { poolOrderStatusLabels } from '../fleetArrangement'
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
const route = useRoute(), router = useRouter()
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<LocalLooseboardDetail | null>(null), loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let requestSequence = 0
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
const statusLabels: Record<string, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', ...poolOrderStatusLabels }
const statusLabel = (value: string | null) => value ? statusLabels[value] || value : '—'
const fields = computed(() => detail.value ? [
['客户编号', detail.value.customerId], ['商品名称', detail.value.productName], ['预计托数', detail.value.estimatedPalletCount],
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
] : [])
const operationLabels: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', STATUS_CHANGE: '状态变更', CANCEL: '取消订单' }
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 },
{ key: 'remark', prop: 'remark', label: '操作说明', minWidth: 240 },
]
/** 只用路由中的真实订单和客户查询,切换单据时丢弃旧请求结果。 */
async function load() {
const sequence = ++requestSequence
detail.value = null; error.value = null
if (!orderNo.value || !customerId.value || customerId.value === '0') {
loading.value = false; error.value = { message: '缺少有效客户或订单信息,请返回本土散板列表重新打开' }; return
}
loading.value = true
try { const result = await getLocalLooseboardOrder(orderNo.value, customerId.value); if (sequence === requestSequence) detail.value = result }
catch (cause) { if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '本土散板订单加载失败,请稍后重试') }
finally { if (sequence === requestSequence) loading.value = false }
}
watch([orderNo, customerId], load, { immediate: true })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土散板订单详情" class="density-detail-layout workbench-panel is-flush is-page-surface">
<template #header>
<div class="density-detail-header looseboard-detail-header"><div class="density-detail-heading">
<el-button link class="density-back" @click="router.push({ name: 'overseas-business-looseboards' })"><ArrowLeft :size="16" />返回列表</el-button>
<h1 class="density-detail-title">本土散板订单 {{ detail?.orderNo || orderNo }}</h1>
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
</div><el-button v-if="detail" @click="load">刷新订单</el-button></div>
</template>
<LoadErrorState v-if="error" title="本土散板订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="looseboard-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="looseboard-detail-section"><BusinessDocumentFees business-type="LOCAL_LOOSEBOARD" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.looseboard-detail-header { flex-wrap: wrap; }
.looseboard-detail-header .density-detail-heading { flex: 1; }
.looseboard-detail-section { margin-bottom: var(--admin-space-6); }
</style>
@@ -0,0 +1,215 @@
import ElementPlus 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 QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import * as api from '../api/overseasFees'
import type { OverseasFee } from '../api/overseasFees'
import OverseasFeesView from './OverseasFeesView.vue'
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(), 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, reviewedAt: null,
createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'],
}
async function mountPage() {
const wrapper = mount(OverseasFeesView, { 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.listOverseasFees).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('推送请求提交后继续刷新,真实成功时列表自动显示已完成', async () => {
const pushing = { ...fee, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED',
walletStatus: 'NOT_REQUIRED', deliveryStatus: 'PENDING' as const, allowedActions: [] }
const wrapper = await mountPage()
try {
// 挂载后再使用假时钟,避免影响Element Plus自身挂载任务。
vi.useFakeTimers()
vi.mocked(api.listOverseasFees).mockResolvedValueOnce({ records: [pushing], total: 1, page: 1, size: 20, pages: 1 })
.mockResolvedValue({ records: [{ ...pushing, deliveryStatus: 'PUBLISHED' }], total: 1, page: 1, size: 20, pages: 1 })
wrapper.getComponent(QueryToolbar).vm.$emit('search', {})
await flushPromises()
expect(wrapper.get('.el-table__body-wrapper').text()).not.toContain('已完成')
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('已完成')
const calls = vi.mocked(api.listOverseasFees).mock.calls.length
await vi.advanceTimersByTimeAsync(4000)
expect(api.listOverseasFees).toHaveBeenCalledTimes(calls)
} finally { wrapper.unmount(); vi.useRealTimers() }
})
it('shows relabel delivery as 换标 in table and detail and filters by that business type', async () => {
const relabel = { ...fee, businessType: 'LASTLEG_ORDER', displayBusinessType: 'RELABEL_ORDER',
businessNo: 'TB202610220001', feeItemName: '派送费', originalCurrency: 'CNY', originalAmount: 66.70, amount: 66.70 }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [relabel], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(relabel)
const wrapper = await mountPage()
const table = wrapper.get('.el-table__body-wrapper')
expect(table.text()).toContain('换标'); expect(table.text()).toContain('派送费')
expect(table.text()).toContain('66.70'); expect(table.text()).not.toContain('尾程订单')
wrapper.getComponent(QueryToolbar).vm.$emit('search', { businessType: 'RELABEL_ORDER' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'RELABEL_ORDER', page: 1 }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('换标'); expect(dialog.text()).toContain('派送费')
expect(dialog.text()).not.toContain('尾程订单')
})
it('keeps approval and amount changes on the business document', async () => {
const allActions = { ...fee, allowedActions: ['APPROVE', 'REJECT', 'CANCEL', 'ADJUST', 'VOID', 'SUBMIT'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [allActions], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(allActions)
const wrapper = await mountPage()
wrapper.getComponent(QueryToolbar).vm.$emit('search', { customerId: fee.customerId, businessNo: ' WT2026 ', reviewStatus: 'PENDING' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: fee.customerId, businessNo: 'WT2026', reviewStatus: 'PENDING', page: 1 }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain(fee.businessNo)
expect(dialog.text()).toContain('余额/授信')
const buttons = wrapper.findAll('button').map(button => button.text().trim())
for (const label of ['新增费用', '调整', '审核通过', '驳回', '取消费用', '作废', '提交审核', '重试锁定']) expect(buttons).not.toContain(label)
expect(api.createOverseasFee).not.toHaveBeenCalled()
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it.each(['RETRY_FREEZE', 'RETRY_SETTLE', 'RETRY_RELEASE', 'RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'])('retries %s only after refreshing eligibility and preserving the original identity', async action => {
const failed = { ...fee, reviewStatus: 'APPROVED', walletStatus: 'SETTLE_FAILED', deliveryEventId: 'original-event', allowedActions: [action] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
const labels: Record<string, string> = { RETRY_FREEZE: '重试锁定', RETRY_SETTLE: '重试扣费', RETRY_RELEASE: '重试释放', RETRY_PUBLISH: '重试推送', RETRY_FUNDING_DELIVERY: '重试资金指令' }
const wrapper = await mountPage()
await click(wrapper, labels[action]!, '.el-table__body-wrapper button')
expect(api.getOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId)
await click(activeDialog(wrapper), labels[action]!)
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, action, {
idempotencyKey: expect.any(String), ...(['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? { expectedVersion: fee.sourceVersion, eventId: 'original-event' } : {}),
})
expect(api.listOverseasFees).toHaveBeenCalledTimes(2)
})
it('does not retry a fee that has already succeeded or belongs to another customer', async () => {
const failed = { ...fee, allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = await mountPage()
for (const refreshed of [{ ...failed, allowedActions: [] }, { ...failed, customerId: 'other' }]) {
vi.mocked(api.getOverseasFee).mockResolvedValueOnce(refreshed)
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('blocks concurrent clicks and reuses the same request key after an uncertain failure', async () => {
const failed = { ...fee, allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
let reject!: (reason: unknown) => void
vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const wrapper = await mountPage()
await click(wrapper, '重试扣费', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '重试扣费'); await click(dialog, '重试扣费')
expect(api.actOnOverseasFee).toHaveBeenCalledTimes(1)
reject(new ApiResponseError('连接中断', 'UNAVAILABLE', 503)); await flushPromises()
expect(dialog.text()).toContain('连接中断')
const key = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey
await click(dialog, '重试扣费')
expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(key)
})
it('hides retries without edit permission and keeps rejected fees read-only', async () => {
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['RETRY_SETTLE'] }, { ...fee, sourceFeeId: 'rejected', reviewStatus: 'REJECTED', allowedActions: ['RETRY_SETTLE'] }], total: 2, page: 1, size: 20, pages: 1 })
can.mockReturnValue(false)
const wrapper = await mountPage()
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text())).not.toContain('重试扣费')
})
it.each(['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'])('recovers rejected frozen funds through the server-authorized %s action', async action => {
const rejected = { ...fee, reviewStatus: 'REJECTED', walletStatus: 'RELEASE_FAILED', deliveryEventId: 'release-event', allowedActions: [action, 'RETRY_SETTLE', 'RETRY_FREEZE'] }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [rejected], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rejected)
const wrapper = await mountPage()
const buttons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(buttons).not.toContain('重试扣费'); expect(buttons).not.toContain('重试锁定')
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('处理失败')
const label = action === 'RETRY_RELEASE' ? '重试释放' : '重试资金指令'
await click(wrapper, label, '.el-table__body-wrapper button')
await click(activeDialog(wrapper), label)
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, action, expect.objectContaining({
idempotencyKey: expect.any(String), ...(action === 'RETRY_FUNDING_DELIVERY' ? { expectedVersion: fee.sourceVersion, eventId: 'release-event' } : {}),
}))
})
it('keeps automatic rent available as a summary filter', async () => {
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }])
const wrapper = await mountPage()
expect(wrapper.getComponent(QueryToolbar).props('fields').find(field => field.key === 'feeItemCode')?.options).toContainEqual({ value: 'FEE_TYPE_077', label: '仓储费' })
})
it('shows only final results and queries failure filtering on the server for either direction', async () => {
const wrapper = await mountPage()
const fields = wrapper.getComponent(QueryToolbar).props('fields')
expect(fields.find(field => field.key === 'processingStatus')?.options?.map(option => option.label)).toEqual(['已完成', '处理失败'])
expect(fields.some(field => field.key === 'walletStatus')).toBe(false)
wrapper.getComponent(QueryToolbar).vm.$emit('search', { direction: 'PAYABLE', processingStatus: 'FAILED' })
await flushPromises()
expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ direction: 'PAYABLE', processingStatus: 'FAILED', page: 1 }))
expect(wrapper.get('.el-table__body-wrapper').text()).toContain('—')
expect(wrapper.get('.el-table__body-wrapper').text()).not.toContain('已锁定')
})
})
it('海外费用管理只查询仓租依据,审核和调价留在对应单据详情', async () => {
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: 4, days: 21, calculatedAmount: 10 }] } }
vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rent)
const wrapper = await mountPage()
expect(wrapper.findAll('[role="tab"]').map(item => item.text())).toEqual(['费用明细', '仓租库存'])
const actions = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(actions).not.toContain('调整仓租')
expect(actions).not.toContain('审核仓租')
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('2026-10-01'); expect(dialog.text()).toContain('2026-10-22')
expect(dialog.text()).toContain('实扣金额')
expect(dialog.text()).toContain('OUT-1')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
@@ -0,0 +1,186 @@
<script setup lang="ts">
import '../overseasFeeDetails.css'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import type { FeeItemOption } from '../components/OverseasFeeEditor.vue'
import StorageRentFeeDetails from '../components/StorageRentFeeDetails.vue'
import StorageRentPanel from '../components/StorageRentPanel.vue'
import OverseasFeeRetryHistory from '../components/OverseasFeeRetryHistory.vue'
import { useFeeStatusRefresh } from '../useFeeStatusRefresh'
import { actOnOverseasFee, getOverseasFee, listOverseasFeeCustomers, listOverseasFeeItems, listOverseasFees, type OverseasFee } from '../api/overseasFees'
import { actionLabels, businessLabels, feeBusinessLabel, feeMoney, feePermissions, feeProcessingLabel, feeTime, feeReviewLabel, feeFundingErrorLabel, processingLabels, reviewLabels } from '../overseasFeePresentation'
const tab = ref('fees'), loading = ref(false), error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
const page = ref(1), pageSize = ref(20), total = ref(0), rows = ref<OverseasFee[]>([])
const customers = ref<Array<{ customerId: string; customerName: string }>>([]), items = ref<FeeItemOption[]>([])
const contextLoading = ref(false), detailLoading = ref(''), detail = ref<OverseasFee | null>(null), detailOpen = ref(false)
const filters = ref<QueryModel>({ customerId: '', businessNo: '', businessType: '', feeItemCode: '', reviewStatus: '', processingStatus: '', direction: 'RECEIVABLE' })
const applied = ref({ ...filters.value })
const access = useAccessPreviewStore()
const retryFee = ref<OverseasFee | null>(null), retryOpen = ref(false), retryAction = ref(''), retryKey = ref(''), retrySaving = ref(false), retryError = ref<ApiErrorDetails | null>(null)
const retryActions = ['RETRY_FREEZE', 'RETRY_SETTLE', 'RETRY_RELEASE', 'RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY']
function rowActions(fee: OverseasFee) {
const disabled = !!detailLoading.value || retrySaving.value
const actions = [{ key: 'DETAIL', label: '详情', disabled }]
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
if (access.can(feePermissions.edit)) for (const action of retryActions) {
if (closed && !['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action)) continue
if (fee.allowedActions.includes(action)) actions.push({ key: action, label: actionLabels[action]!, disabled })
}
return actions
}
const fields = computed<QueryField[]>(() => [
{ key: 'direction', type: 'select', label: '收付方向', placeholder: '收付方向', options: [{ value: 'RECEIVABLE', label: '应收客户' }, { value: 'PAYABLE', label: '应付供应商' }], props: { 'aria-label': '收付方向', clearable: false } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '客户', width: 200, options: customers.value.map(row => ({ value: row.customerId, label: row.customerName })), props: { filterable: true, loading: contextLoading.value, 'aria-label': '客户' } },
{ key: 'businessNo', type: 'input', label: '业务单号', placeholder: '业务单号', width: 220, props: { 'aria-label': '业务单号' } },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '业务类型', options: Object.entries(businessLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '业务类型' } },
{ key: 'feeItemCode', type: 'select', label: '费用项目', placeholder: '费用项目', options: items.value.map(row => ({ value: row.feeCode, label: row.feeTypeName })), props: { filterable: true, 'aria-label': '费用项目' } },
{ key: 'reviewStatus', type: 'select', label: '审核状态', placeholder: '审核状态', options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '审核状态' } },
{ key: 'processingStatus', type: 'select', label: '费用状态', placeholder: '费用状态', options: Object.entries(processingLabels).map(([value, label]) => ({ value, label: value === 'COMPLETED' && filters.value.direction === 'PAYABLE' ? '推送成功' : label })), props: { 'aria-label': '费用状态' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'businessNo', label: '业务单号', minWidth: 185 },
{ prop: 'businessType', label: '业务类型', width: 115, formatter: feeBusinessLabel },
{ prop: 'feeItemName', label: '费用项目', minWidth: 145 },
{ prop: 'supplierName', label: '供应商', minWidth: 150, defaultVisible: false },
{ key: 'amount', label: '结算金额(CNY)', width: 145, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.amount) },
{ prop: 'collectionMode', label: '结算方式', width: 115, formatter: (row: OverseasFee) => row.direction === 'PAYABLE' ? '推送结算' : row.collectionMode === 'BILL' ? '账单结算' : '余额/授信' },
{ prop: 'reviewStatus', label: '审核状态', width: 100, slotName: 'reviewStatus' },
{ key: 'chargeStatus', label: '费用状态', minWidth: 115, formatter: feeProcessingLabel },
{ prop: 'businessDate', label: '费用日期', width: 110 },
{ prop: 'createdAt', label: '创建时间', minWidth: 165, defaultVisible: false, formatter: (row: OverseasFee) => feeTime(row.createdAt) },
]
let loadSequence = 0, detailSequence = 0
const statusRefresh = useFeeStatusRefresh(rows, () => load(true), computed(() => tab.value !== 'rent' && !error.value))
async function load(background = false) {
if (!background) statusRefresh.reset()
const current = ++loadSequence; if (!background) loading.value = true; error.value = null
try {
const params = Object.fromEntries(Object.entries(applied.value).map(([key, value]) => [key, value ? String(value).trim() : undefined]))
const data = await listOverseasFees({ ...params, page: page.value, size: pageSize.value, direction: params.direction || 'RECEIVABLE' })
if (current === loadSequence) {
rows.value = data.records; total.value = data.total
const latestDetail = rows.value.find(row => row.sourceFeeId === detail.value?.sourceFeeId)
if (latestDetail) detail.value = latestDetail
}
} catch (failure) { if (current === loadSequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '费用列表加载失败') } }
finally { if (current === loadSequence) loading.value = false }
}
async function loadContext() {
contextLoading.value = true; contextError.value = null
const results = await Promise.allSettled([listOverseasFeeCustomers(), listOverseasFeeItems()])
if (results[0].status === 'fulfilled') customers.value = results[0].value
else contextError.value = getApiErrorDetails(results[0].reason, '客户选项加载失败')
if (results[1].status === 'fulfilled') items.value = results[1].value
else contextError.value = getApiErrorDetails(results[1].reason, '费用项目加载失败')
contextLoading.value = false
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
async function freshFee(row: OverseasFee) {
const current = ++detailSequence; detailLoading.value = row.sourceFeeId
try { const data = await getOverseasFee(row.sourceFeeId); return current === detailSequence ? data : null }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '费用详情加载失败').message); return null }
finally { if (current === detailSequence) detailLoading.value = '' }
}
async function showDetail(row: OverseasFee) {
if (detailLoading.value) return
const current = await freshFee(row)
if (current) { detail.value = current; detailOpen.value = true }
}
async function onAction(action: string, row: OverseasFee) {
if (detailLoading.value || retrySaving.value) return
if (action === 'DETAIL') { await showDetail(row); return }
if (!retryActions.includes(action) || !rowActions(row).some(item => item.key === action)) return
const current = await freshFee(row)
if (!current) return
if (current.sourceFeeId !== row.sourceFeeId || current.customerId !== row.customerId || !rowActions(current).some(item => item.key === action)) {
ElMessage.warning('费用状态已变化,请刷新后重试'); await load(); return
}
retryFee.value = current; retryAction.value = action; retryKey.value = crypto.randomUUID(); retryError.value = null; retryOpen.value = true
}
async function confirmRetry() {
const fee = retryFee.value, action = retryAction.value
if (!fee || retrySaving.value || !rowActions(fee).some(item => item.key === action) || !access.can(feePermissions.edit)) return
const delivery = ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action)
if (delivery && !fee.deliveryEventId) return
retrySaving.value = true; retryError.value = null
try {
await actOnOverseasFee(fee.sourceFeeId, action, { idempotencyKey: retryKey.value,
...(delivery ? { expectedVersion: fee.sourceVersion, eventId: fee.deliveryEventId } : {}),
})
retryOpen.value = false; ElMessage.success('重试已提交'); await load()
} catch (failure) { retryError.value = getApiErrorDetails(failure, '重试失败') }
finally { retrySaving.value = false }
}
// 返回费用页签时更新审核与资金结果,保留用户原来的查询条件。
watch(tab, () => { if (tab.value === 'fees') void load() })
watch([page, pageSize], () => { if (tab.value !== 'rent') void load() })
onMounted(() => { void load(); void loadContext() })
</script>
<template>
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="海外费用管理">
<section class="workbench-table is-page-surface">
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<el-tab-pane label="费用明细" name="fees">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" toolbar-placement="filters" preference-key="overseas-fees" row-key="sourceFeeId" empty-text="暂无费用明细">
<template #filters><QueryToolbar v-model="filters" ariaLabel="海外费用筛选" :fields="fields" :loading="loading" @search="search" /></template>
<template #notices>
<LoadErrorState v-if="error" title="费用列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="contextError" title="筛选选项加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadContext"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as OverseasFee)" @action="onAction($event, row as OverseasFee)" /></template>
<template #reviewStatus="{ row }"><el-tag :type="(row as OverseasFee).reviewStatus === 'APPROVED' ? 'success' : (row as OverseasFee).reviewStatus === 'REJECTED' ? 'danger' : 'info'">{{ feeReviewLabel(row as OverseasFee) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条费用</template>
</DataTable>
</el-tab-pane>
<el-tab-pane label="仓租库存" name="rent" lazy><StorageRentPanel :customers="customers" /></el-tab-pane>
</el-tabs>
</section>
</ListPageLayout>
<AppDialog v-model="detailOpen" title="费用详情" width="860px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="业务单号">{{ detail.businessNo }}</el-descriptions-item>
<el-descriptions-item label="业务类型">{{ feeBusinessLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item>
<el-descriptions-item v-if="detail.feeRemark" label="费用说明" :span="2">{{ detail.feeRemark }}</el-descriptions-item>
<el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '—' }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(detail.originalAmount, detail.originalCurrency) }}</el-descriptions-item><el-descriptions-item label="结算方式">{{ detail.direction === 'PAYABLE' ? '推送结算' : detail.collectionMode === 'BILL' ? '账单结算' : '余额/授信' }}</el-descriptions-item>
<el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算金额">{{ feeMoney(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算汇率">{{ detail.exchangeRate || '—' }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item>
<el-descriptions-item label="审核状态">{{ feeReviewLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ feeProcessingLabel(detail) }}</el-descriptions-item>
<el-descriptions-item label="费用日期">{{ detail.businessDate }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ feeTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.periodStart || detail.periodEnd" label="计费期间" :span="2">{{ detail.periodStart }} — {{ detail.periodEnd }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewReason" label="审核意见" :span="2">{{ detail.reviewReason }}</el-descriptions-item>
<el-descriptions-item v-if="detail.walletErrorMessage" :label="feeFundingErrorLabel(detail)" :span="2">{{ detail.walletErrorMessage }}<span v-if="detail.sourceStatus === 'VOIDED'">(此费用已作废,无需继续审核)</span></el-descriptions-item>
<el-descriptions-item v-if="detail.deliveryErrorMessage" label="推送异常" :span="2">{{ detail.deliveryErrorMessage }}</el-descriptions-item>
</el-descriptions>
<StorageRentFeeDetails :fee="detail" />
<OverseasFeeRetryHistory v-if="detailOpen" :fee-id="detail.sourceFeeId" :refresh-key="[detail.deliveryStatus, detail.deliveryErrorMessage, detail.walletStatus].join('|')" />
</template>
</AppDialog>
<AppDialog v-model="retryOpen" :title="actionLabels[retryAction] || '重试'" width="560px" class="admin-density-dialog admin-workbench is-dialog" :confirm-text="actionLabels[retryAction] || '重试'" :confirm-loading="retrySaving" lock-while-submitting @confirm="confirmRetry">
<el-descriptions v-if="retryFee" :column="1" border class="density-descriptions">
<el-descriptions-item label="业务单号">{{ retryFee.businessNo }}</el-descriptions-item>
<el-descriptions-item label="费用项目">{{ retryFee.feeItemName }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(retryFee.amount, retryFee.currency) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">{{ feeProcessingLabel(retryFee) }}</el-descriptions-item>
</el-descriptions>
<LoadErrorState v-if="retryError" title="重试失败" :message="retryError.message" :loading="retrySaving" @retry="confirmRetry"><template #details><ApiErrorMeta :details="retryError" /></template></LoadErrorState>
</AppDialog>
</AdminPage>
</template>
@@ -23,6 +23,7 @@ import { overseasBusinessPermissions } from '../permissions'
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
import { directionLabel, regionText } from '../procurementDirection'
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
@@ -30,10 +31,12 @@ const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
const downloading = ref<string | null>(null)
const order = computed(() => detail.value?.customerView.order)
const t = (text: string) => regionText(text, order.value?.procurementDirection)
const fields = computed(() => [
['采购方向', directionLabel(order.value?.procurementDirection)],
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['商品规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity],
['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['有效预算总额', money(detail.value?.customerView.budget.totalAmount, detail.value?.customerView.budget.currency)],
['预计到海外仓', dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
[t('预计到海外仓'), dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
] as const)
const purchaseFields = computed(() => [
@@ -43,14 +46,14 @@ const purchaseFields = computed(() => [
] as const)
const receiptColumns: DataTableColumn[] = [
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => t(row.leg === 'OVERSEAS' ? '海外' : '国内') },
{ key: 'source', label: '来源箱', minWidth: 160, formatter: (row: ProcurementReceipt) => detail.value?.physical.cartons.find(box => box.id === row.sourceCartonId)?.cartonNo || '—' },
{ key: 'goodQuantity', prop: 'goodQuantity', label: '合格件数', width: 100 }, { key: 'damagedQuantity', prop: 'damagedQuantity', label: '破损件数', width: 100 }, { key: 'extraQuantity', prop: 'extraQuantity', label: '多货件数', width: 100 },
{ key: 'receivedAt', label: '实收时间', minWidth: 160, formatter: (row: ProcurementReceipt) => dateTime(row.receivedAt) },
{ key: 'active', label: '记录状态', width: 100, formatter: (row: ProcurementReceipt) => row.active ? '有效' : '已更正' },
]
const dispositionColumns: DataTableColumn[] = [
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => t(row.leg === 'OVERSEAS' ? '海外' : '国内') },
{ key: 'source', label: '实收或原箱来源', minWidth: 180, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => (row.receipt_id ? detail.value?.physical.receipts.find(receipt => receipt.id === row.receipt_id)?.receiptNo : row.source_carton_id ? detail.value?.physical.cartons.find(box => box.id === row.source_carton_id)?.cartonNo : order.value?.orderNo) || '—' },
{ key: 'kind', label: '差异', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => ({ SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' })[row.kind] },
{ key: 'quantity', prop: 'quantity', label: '处置件数', width: 100 }, { key: 'reason', prop: 'reason', label: '处置依据', minWidth: 220 },
@@ -68,7 +71,7 @@ const shipmentColumns: DataTableColumn[] = [
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
]
const feeColumns: DataTableColumn[] = [
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => feePurposes[row.purpose] || '待核对' },
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => t(feePurposes[row.purpose] || '待核对') },
{ key: 'name', prop: 'name', label: '费用项目', minWidth: 120 }, { key: 'original', label: '原币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.originalAmount, row.originalCurrency) },
{ key: 'amount', label: '人民币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.amount, row.currency) },
{ key: 'rate', label: '汇率 / 月份', minWidth: 150, formatter: (row: ProcurementFee) => `${text(row.exchangeRate)} / ${text(row.exchangeRateMonth)}` },
@@ -152,7 +155,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<template>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" />
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单履约详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status, order.procurementDirection)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
<template v-if="order" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ text(order.customerName) }}</dd></div><div><dt>计划采购仓</dt><dd>{{ text(order.plannedWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }}</dd></div></dl></template>
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-grid">
@@ -164,7 +167,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<section><h3>分次实收记录</h3><div class="workbench-table"><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></div></section>
<section><h3>差异处置记录</h3><div class="workbench-table"><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></div></section>
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><div class="workbench-table"><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></div></section>
<section v-if="detail.transport.domesticCargo.length"><h3>国内包装与可寄货物核对</h3><div class="workbench-table"><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></div></section>
<section v-if="detail.transport.domesticCargo.length"><h3>{{ t('国内包装与可寄货物核对') }}</h3><div class="workbench-table"><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></div></section>
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版<InlineHelp title="面单核对说明"><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p></InlineHelp></h3><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><div class="workbench-table"><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></div></section>
<section><h3>两段实际交寄</h3><div class="workbench-table"><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></div></section>
<section><h3>费用与资金结果<InlineHelp title="费用与资金说明"><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p></InlineHelp></h3><div class="workbench-table"><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></div></section>
@@ -17,6 +17,7 @@ import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../a
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
import { directionLabel } from '../procurementDirection'
const router = useRouter(); const access = useAccessPreviewStore(); const auth = useAuthStore()
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
@@ -27,7 +28,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label: ({ WAIT_OVERSEAS_RECEIPT: '待起运仓收货', OVERSEAS_RECEIVING: '起运地收货中', WAIT_OVERSEAS_SHIPMENT: '待国际交寄', DOMESTIC_RECEIVING: '目的地收货中', WAIT_CHARGES: '待目的地核费' } as Record<string, string>)[value] || label })) },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
@@ -41,6 +42,7 @@ const columns: DataTableColumn[] = [
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
{ key: 'status', label: '状态', minWidth: 145, slotName: 'status' },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
{ key: 'procurementDirection', label: '采购方向', minWidth: 170, formatter: (row: OverseasPurchaseSummary) => directionLabel(row.procurementDirection) },
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
@@ -74,13 +76,13 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<template #toolbar-start><h1>海外采购订单</h1></template>
<template #toolbar-start><h1>采购订单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template>
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status, row.procurementDirection) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
</DataTable></div></section>
</ListPageLayout></AdminPage>
@@ -0,0 +1,68 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import RowActions from '@/shared/components/RowActions.vue'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalLooseboardOrder } from '../types'
import TransportOrdersView from './TransportOrdersView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { query: {} } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('../api/platformOverseasBusiness', () => ({ listLocalLooseboardOrders: vi.fn(), listUsTruckOrders: vi.fn() }))
vi.mock('@/modules/overseas-master-data', () => ({ inquiryReturnTarget: () => null, TransportRouteDialog: { template: '<div />' }, routeLocation: (name: string) => ({ name }) }))
function order(): LocalLooseboardOrder {
return {
id: '9007199254741001', orderNo: 'LB-TEST', customerId: '9007199254741999', fleetArrangement: null,
palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', productName: '测试货物', estimatedPalletCount: 2,
origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
pickupAt: null, deliveryAt: null, packageCount: 20, totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
status: 'PENDING_ACCEPTANCE', version: 1, createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00',
}
}
function mountList(domain: 'truck' | 'looseboard', edit = false) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, ElementPlus], stubs: {
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>' },
AppDialog: true,
} } })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records: [order()], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
})
describe('transport document entry', () => {
it('lets list-only users open looseboard details without edit permission', async () => {
const wrapper = mountList('looseboard'); await flushPromises()
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('详情')
expect(actions.text()).not.toContain('编辑')
actions.vm.$emit('action', 'detail'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-detail', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
})
it('preserves the permitted looseboard edit entry alongside its detail', async () => {
const wrapper = mountList('looseboard', true); await flushPromises()
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('编辑')
actions.vm.$emit('action', 'edit'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-edit', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
})
it('keeps truck mode on its own list API', async () => {
mountList('truck'); await flushPromises()
expect(listUsTruckOrders).toHaveBeenCalledOnce()
expect(listLocalLooseboardOrders).not.toHaveBeenCalled()
})
})
@@ -67,7 +67,7 @@ const truckColumns: DataTableColumn[] = [
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.createdAt) },
]
const looseboardColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
@@ -106,6 +106,18 @@ function search(model: QueryModel) { applied.value = { ...model }; if (page.valu
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
/** 单据详情保留订单与客户范围,费用在原业务单中处理。 */
function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-detail' : 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
}
/** 散板详情不依赖编辑资格,已安排承运的订单也能处理自己的费用。 */
function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
]
}
function truckActions(row: LocalTransportOrder): RowActionItem[] {
return [
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 },
@@ -113,10 +125,10 @@ function truckActions(row: LocalTransportOrder): RowActionItem[] {
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
]
}
function handleTruckAction(key: string, row: LocalTransportOrder) {
function handleTruckAction(key: string, row: LocalTransportOrder | LocalLooseboardOrder) {
if (key === 'edit') openEdit(row)
else if (key === 'arrangement') showArrangement(row)
else if (key === 'detail') void router.push({ name: 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
else if (key === 'detail') openDetail(row)
}
let activatedOnce = false
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
@@ -134,7 +146,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><RowActions v-if="!isLooseboard" :actions="truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder)" /><div v-else class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #actions="{ row }"><RowActions :actions="isLooseboard ? looseboardActions(row as LocalLooseboardOrder) : truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder | LocalLooseboardOrder)" /></template>
<template #status="{ row }"><el-tag :type="statusTones[row.status as LocalTransportStatus | LocalLooseboardStatus] || 'info'" size="small" effect="light">{{ statusLabels[row.status] || '—' }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
@@ -7,6 +7,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { executeUsTruckCommand, getUsTruckOrder } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalTransportDetail } from '../types'
@@ -34,7 +35,7 @@ function mountDetail(manage = true) {
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true } } })
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true } } })
}
async function click(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('button').find(item => item.text() === label)
@@ -56,6 +57,13 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals())
describe('truck review and fulfillment', () => {
it('scopes fee operations to the loaded truck document', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.findComponent(BusinessDocumentFees).props()).toMatchObject({
businessType: 'US_TRUCK', businessNo: 'TR-TEST', customerId: '9007199254741999', businessId: '9007199254741001',
})
})
it('groups the business number, single status and actions in the shared detail header', async () => {
const wrapper = mountDetail(); await flushPromises()
const header = wrapper.find('.detail-page-layout__header')
@@ -14,6 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TruckOrderActions from '../components/TruckOrderActions.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { truckStatusLabels } from '../truckWorkflow'
import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistory } from '../types'
@@ -85,6 +86,7 @@ watch([orderNo, customerId], load, { immediate: true })
<template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
@@ -0,0 +1,9 @@
export type ProcurementDirection = 'OVERSEAS_TO_DOMESTIC' | 'DOMESTIC_TO_OVERSEAS'
export const procurementDirections: Record<ProcurementDirection, string> = {
OVERSEAS_TO_DOMESTIC: '海外采购,发往国内', DOMESTIC_TO_OVERSEAS: '国内采购,发往海外',
}
export function directionLabel(direction?: string | null) { return direction === 'DOMESTIC_TO_OVERSEAS' ? procurementDirections.DOMESTIC_TO_OVERSEAS : procurementDirections.OVERSEAS_TO_DOMESTIC }
// 两个历史段编码表示起运段和目的地段,展示区域按订单方向转换。
export function regionText(text: string, direction?: string | null) {
return direction === 'DOMESTIC_TO_OVERSEAS' ? text.replaceAll('海外', '__ORIGIN__').replaceAll('国内', '海外').replaceAll('__ORIGIN__', '国内') : text
}
@@ -5,3 +5,122 @@ export function commandKey() {
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes)
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
}
export function readPendingProcurement(raw: string | null): PendingProcurement | null {
if (raw === null) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
const payload = value.payload
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'procurementDirection', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
const body = payload as Record<string, unknown>
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
if (body.procurementDirection !== undefined && !['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'].includes(String(body.procurementDirection))) throw new Error('invalid procurement direction')
if (value.command === 'review') {
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
} else if (!nonempty('reason')) throw new Error('missing pending reason')
if (value.command === 'planned-warehouse' && !positiveId('warehouseId')) throw new Error('invalid pending warehouse')
if (['purchase', 'budget-authorizations'].includes(String(value.command))) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !['CNY', 'USD'].includes(String(body.currency)) || !positiveId('voucherFileId')) throw new Error('invalid pending amount')
}
if (value.command === 'purchase' && (!nonempty('productName') || !Number.isSafeInteger(body.quantity) || Number(body.quantity) < 1 || !nonempty('purchasedAt') || !nonempty('estimatedArrivalAt'))) throw new Error('invalid pending purchase')
const count = (field: string, minimum: number) => Number.isSafeInteger(body[field]) && Number(body[field]) >= minimum
if (['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'receipts/correct', 'dispositions'].includes(String(value.command))) {
if (!['OVERSEAS', 'DOMESTIC'].includes(String(body.leg))) throw new Error('invalid receipt leg')
if ((String(value.command).startsWith('overseas') && body.leg !== 'OVERSEAS') || (String(value.command).startsWith('domestic') && body.leg !== 'DOMESTIC')) throw new Error('wrong receipt endpoint')
}
if (['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(String(value.command))) {
if (!['goodQuantity', 'damagedQuantity', 'extraQuantity'].every(field => count(field, 0)) || Number(body.goodQuantity) + Number(body.damagedQuantity) + Number(body.extraQuantity) <= 0 || !nonempty('receivedAt')) throw new Error('invalid pending receipt')
if (body.leg === 'OVERSEAS' ? !positiveId('warehouseId') || body.sourceCartonId != null : !positiveId('sourceCartonId') || body.warehouseId != null) throw new Error('invalid receipt source')
}
if (['receipts/correct', 'dispositions', 'dispositions/revoke'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing correction evidence')
if (value.command === 'receipts/correct' && !positiveId('receiptId')) throw new Error('missing original receipt')
if (value.command === 'dispositions/revoke' && !positiveId('dispositionId')) throw new Error('missing original disposition')
if (value.command === 'dispositions') {
const decisions: Record<string, string> = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' }
if (!count('quantity', 1) || !decisions[String(body.kind)] || decisions[String(body.kind)] !== body.decision || (body.kind !== 'SHORTAGE' && !positiveId('receiptId')) || (body.kind === 'SHORTAGE' && body.receiptId != null) || (body.leg === 'DOMESTIC' && !positiveId('sourceCartonId'))) throw new Error('invalid pending disposition')
}
if (value.command === 'packing/confirm' && !count('packingVersion', 1)) throw new Error('invalid packing version')
if (value.command === 'packing') {
if (!Array.isArray(body.cartons) || !body.cartons.length) throw new Error('missing packing list')
const names = new Set<string>()
for (const item of body.cartons) {
if (!item || typeof item !== 'object' || typeof item.cartonNo !== 'string' || !item.cartonNo.trim() || names.has(item.cartonNo) || Object.keys(item).some(key => !['cartonNo', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'sources'].includes(key))) throw new Error('invalid packing carton')
names.add(item.cartonNo)
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
if (typeof item[field] !== 'string' || !pattern.test(item[field]) || Number(item[field]) <= 0) throw new Error('invalid packing measurement')
}
if (!Array.isArray(item.sources) || !item.sources.length || item.sources.some((source: unknown) => {
if (!source || typeof source !== 'object') return true
const row = source as Record<string, unknown>
return Object.keys(row).some(key => !['receiptId', 'quantity'].includes(key)) || typeof row.receiptId !== 'string' || !/^[1-9]\d*$/.test(row.receiptId) || !Number.isSafeInteger(row.quantity) || Number(row.quantity) < 1
})) throw new Error('invalid packing source')
}
}
if (value.command === 'labels') {
const receiver = body.receiver
if (!count('packingVersion', 1) || !['OVERSEAS', 'DOMESTIC'].includes(String(body.leg)) || !receiver || typeof receiver !== 'object' || !['name', 'phone', 'address'].every(key => typeof (receiver as Record<string, unknown>)[key] === 'string' && String((receiver as Record<string, unknown>)[key]).trim())) throw new Error('invalid label address')
if (!Array.isArray(body.labels) || !body.labels.length) throw new Error('missing labels')
const cartonIds = new Set<string>()
for (const row of body.labels) {
if (!row || typeof row !== 'object' || typeof row.cartonId !== 'string' || !/^[1-9]\d*$/.test(row.cartonId) || typeof row.fileId !== 'string' || !/^[1-9]\d*$/.test(row.fileId) || !Number.isSafeInteger(row.page) || row.page < 1 || !row.carrier?.trim() || !row.trackingNo?.trim() || cartonIds.has(row.cartonId)) throw new Error('invalid label association')
cartonIds.add(row.cartonId)
}
}
if (value.command === 'international-estimate' && (!count('labelVersion', 1) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !nonempty('businessDate'))) throw new Error('invalid pending estimate')
if (['labels/apply', 'overseas-shipment', 'customer-shipment'].includes(String(value.command))) {
if (!count('labelVersion', 1) || !Array.isArray(body.cartonIds) || !body.cartonIds.length || body.cartonIds.some(id => typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) || new Set(body.cartonIds).size !== body.cartonIds.length) throw new Error('invalid transport cartons')
if (!nonempty(value.command === 'labels/apply' ? 'appliedAt' : 'shippedAt')) throw new Error('missing actual transport time')
if ((value.command === 'overseas-shipment' && body.leg !== 'OVERSEAS') || (value.command === 'customer-shipment' && body.leg !== 'DOMESTIC')) throw new Error('wrong shipment leg')
}
if (value.command === 'domestic-repack') {
if (!positiveId('cartonId') || !positiveId('voucherFileId')) throw new Error('invalid repack reference')
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
if (typeof body[field] !== 'string' || !pattern.test(String(body[field])) || Number(body[field]) <= 0) throw new Error('invalid repack measurement')
}
}
if (String(value.command).startsWith('fees/') && value.command !== 'fees/additional') {
if (!positiveId('feeId')) throw new Error('invalid original fee')
if (['fees/correct-target', 'fees/refund', 'fees/replace-failed'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing finance evidence')
if (['fees/correct-target', 'fees/replace-failed'].includes(String(value.command)) && !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.targetOriginalAmount))) throw new Error('invalid correction target')
if (value.command === 'fees/refund' && (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0)) throw new Error('invalid refund amount')
}
if (value.command === 'charges/confirm') {
const selection = (input: unknown) => {
if (!input || typeof input !== 'object') throw new Error('missing fee determination')
const item = input as Record<string, unknown>
if ((item.feeId == null) === (item.value == null) || Object.keys(item).some(key => !['feeId', 'value'].includes(key))) throw new Error('ambiguous fee determination')
if (item.feeId != null) { if (typeof item.feeId !== 'string' || !/^[1-9]\d*$/.test(item.feeId)) throw new Error('invalid fee reference'); return }
if (!item.value || typeof item.value !== 'object') throw new Error('invalid fee amount')
const value = item.value as Record<string, unknown>
if (Object.keys(value).some(key => !['determination', 'amount', 'currency', 'businessDate', 'voucherFileId', 'reason'].includes(key))) throw new Error('unexpected fee amount field')
if (!['POSITIVE', 'ZERO', 'NOT_APPLICABLE'].includes(String(value.determination)) || typeof value.amount !== 'string' || !/^\d{1,16}(?:\.\d{1,2})?$/.test(value.amount) || (value.determination === 'POSITIVE' ? Number(value.amount) <= 0 : Number(value.amount) !== 0) || !['CNY', 'USD'].includes(String(value.currency)) || typeof value.businessDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.businessDate) || typeof value.reason !== 'string' || !value.reason.trim()) throw new Error('invalid fee determination value')
if (value.determination === 'POSITIVE' && (typeof value.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(value.voucherFileId))) throw new Error('missing fee voucher')
}
for (const key of ['internationalFreight', 'duty', 'domesticFreight']) selection(body[key])
if (!['NOT_APPLICABLE', 'ITEMIZED'].includes(String(body.otherDetermination)) || !Array.isArray(body.otherFees) || (body.otherDetermination === 'NOT_APPLICABLE') !== (body.otherFees.length === 0)) throw new Error('invalid other fee determination')
const codes = new Set<string>()
for (const row of body.otherFees) {
if (!row || typeof row !== 'object' || typeof row.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || typeof row.name !== 'string' || !row.name.trim()) throw new Error('invalid other fee')
codes.add(row.code); selection(row.selection)
}
}
if (['control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing control evidence')
if (value.command === 'control-notes' && (!['PURCHASE', 'WAREHOUSE', 'RECEIPT', 'SHIPMENT'].includes(String(body.targetType)) || typeof body.blocking !== 'boolean' || (body.targetType === 'PURCHASE' ? body.targetId != null : !positiveId('targetId')))) throw new Error('invalid control target')
if (value.command === 'control-notes/resolve' && !positiveId('noteId')) throw new Error('missing original blocker')
if (value.command === 'actual-warehouse/correct' && !positiveId('warehouseId')) throw new Error('invalid actual warehouse')
if (value.command === 'exception-close') {
if (!positiveId('customerVoucherFileId') || !nonempty('financialConclusion') || !nonempty('clientSummary') || !Array.isArray(body.resolutions)) throw new Error('incomplete closure facts')
const decisions: Record<string, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
const sources = new Set<string>()
for (const row of body.resolutions) {
if (!row || typeof row !== 'object' || !decisions[row.kind]?.includes(row.decision) || !Number.isSafeInteger(row.quantity) || row.quantity < 1 || typeof row.destination !== 'string' || !row.destination.trim() || typeof row.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(row.voucherFileId) || (row.kind === 'PURCHASE_REMAINDER' ? row.sourceId != null : typeof row.sourceId !== 'string' || !/^[1-9]\d*$/.test(row.sourceId))) throw new Error('invalid closure resolution')
const key = `${row.kind}:${row.sourceId ?? ''}`; if (sources.has(key)) throw new Error('duplicate closure source'); sources.add(key)
}
}
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
return value as PendingProcurement
}
@@ -1,3 +1,4 @@
import { regionText } from './procurementDirection'
import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = {
@@ -10,7 +11,7 @@ export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
}
export function statusLabel(status: string | null | undefined) { return status ? procurementStatuses[status] || '待核对进度' : '—' }
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || '待核对进度' : '—', direction) }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
@@ -1,5 +1,6 @@
export type Amount = number | string
export interface ProcurementSummary {
procurementDirection?: import('./procurementDirection').ProcurementDirection
orderNo: string
purchaseCategory: string
productName: string
+4 -2
View File
@@ -5,6 +5,7 @@ import { overseasBusinessRoutes } from './routes'
describe('overseas business routes', () => {
it('keeps the frozen paths and permissions', () => {
expect(overseasBusinessRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
['overseas-business/fees', 'platform:overseas-fee:list'],
['overseas-business/purchases', permission.purchaseList],
['overseas-business/purchases/:purchaseId', permission.purchaseList],
['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit],
@@ -14,15 +15,16 @@ describe('overseas business routes', () => {
['overseas-business/trucks/:orderId/edit', permission.truckEdit],
['overseas-business/looseboards', permission.looseboardList],
['overseas-business/looseboards/new', permission.looseboardCreate],
['overseas-business/looseboards/:orderId', permission.looseboardList],
['overseas-business/looseboards/:orderId/edit', permission.looseboardEdit],
])
})
it('adds the requested truck detail without opening unrelated routes', () => {
it('opens both transport document details without granting edit access', () => {
const paths = overseasBusinessRoutes.map((route) => route.path)
expect(paths).not.toContain('overseas-business/purchases/new')
expect(paths).toContain('overseas-business/trucks/:orderId')
expect(paths).not.toContain('overseas-business/looseboards/:orderId')
expect(paths).toContain('overseas-business/looseboards/:orderId')
expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf')
})
})
+12
View File
@@ -2,6 +2,12 @@ import type { RouteRecordRaw } from 'vue-router'
import { overseasBusinessPermissions as permission } from './permissions'
export const overseasBusinessRoutes = [
{
path: 'overseas-business/fees',
name: 'overseas-business-fees',
component: () => import('./pages/OverseasFeesView.vue'),
meta: { title: '海外费用管理', permission: 'platform:overseas-fee:list' },
},
{
path: 'overseas-business/purchases',
name: 'overseas-business-purchases',
@@ -61,6 +67,12 @@ export const overseasBusinessRoutes = [
props: { domain: 'looseboard' },
meta: { title: '新增本土散板订单', description: '创建海外本土散板订单', permission: permission.looseboardCreate, showPageHeading: false },
},
{
path: 'overseas-business/looseboards/:orderId',
name: 'overseas-business-looseboard-detail',
component: () => import('./pages/LooseboardOrderDetailView.vue'),
meta: { title: '本土散板订单详情', permission: permission.looseboardList, showPageHeading: false },
},
{
path: 'overseas-business/looseboards/:orderId/edit',
name: 'overseas-business-looseboard-edit',
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { storageRentBillingShare, storageRentChargeStatus, storageRentGeneratedAmounts, storageRentSummaryStatus, storageRentTime } from './storageRentPresentation'
import type { StorageRentLot } from './api/storageRent'
it('distinguishes missing charges, genuine free rent and incomplete settlement', () => {
const lot = { status: 'CLEARED', originalQuantity: 4, billingQuantity: 1, billingUnit: 'PALLET', chargeCount: 2, settledChargeCount: 2, settledAmount: 10 } as StorageRentLot
expect(storageRentSummaryStatus(lot)).toBe('已扣款')
expect(storageRentSummaryStatus({ ...lot, settledChargeCount: 1 })).toBe('有待完成扣款')
expect(storageRentSummaryStatus({ ...lot, settledAmount: 0 })).toBe('免租 · 已结算')
expect(storageRentSummaryStatus({ ...lot, chargeCount: 0 })).toBe('仓租记录待核对')
expect(storageRentBillingShare(2, lot)).toBe('0.5 托')
})
describe('仓租原币汇总显示', () => {
it('displays server currency groups without adding different currencies', () => {
expect(storageRentGeneratedAmounts({ generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }], generatedAmount: null, generatedCurrency: null })).toEqual(['USD 12.00', 'CNY 23.00'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [{ currency: 'EUR', amount: 0 }], generatedAmount: 0, generatedCurrency: 'EUR' })).toEqual(['EUR 0.00'])
})
it('keeps scalar single currency totals and empty totals free of assumed currencies', () => {
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: 8.5, generatedCurrency: 'CNY' })).toEqual(['CNY 8.50'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: 0, generatedCurrency: null })).toEqual(['0.00'])
expect(storageRentGeneratedAmounts({ generatedAmounts: [], generatedAmount: null, generatedCurrency: null })).toEqual(['—'])
})
})
describe('仓租扣费状态', () => {
it('labels only settled zero-original-amount charges as rent free', () => {
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: 0 })).toBe('免租')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: '0.00' })).toBe('免租')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: 12 })).toBe('已扣费')
expect(storageRentChargeStatus({ status: 'SETTLED', originalAmount: null })).toBe('已扣费')
expect(storageRentChargeStatus({ status: 'CONSUMED', originalAmount: 0 })).toBe('扣费中')
expect(storageRentChargeStatus({ status: 'SETTLE_FAILED', originalAmount: 0 })).toBe('扣费失败')
})
})
describe('仓租带时区时间显示', () => {
it('converts UTC and equivalent offset timestamps before displaying local business dates', () => {
expect(storageRentTime('2026-10-22T18:30:45Z', 'Asia/Shanghai')).toBe('2026-10-23 02:30:45')
expect(storageRentTime('2026-10-23T02:30:45+08:00', 'Asia/Shanghai')).toBe('2026-10-23 02:30:45')
expect(storageRentTime('2026-10-22T02:30:45Z', 'America/Los_Angeles')).toBe('2026-10-21 19:30:45')
})
it('defaults to the browser timezone and handles missing or invalid values', () => {
const value = '2026-10-22T18:30:45Z'
expect(storageRentTime(value)).toBe(storageRentTime(value, Intl.DateTimeFormat().resolvedOptions().timeZone))
expect(storageRentTime(null)).toBe('—')
expect(storageRentTime('invalid')).toBe('—')
})
})
@@ -0,0 +1,41 @@
import { feeMoney } from './overseasFeePresentation'
import type { StorageRentLot } from './api/storageRent'
export function storageRentSourceType(type?: string | null) {
const labels: Record<string, string> = { LASTLEG_ORDER: '头程尾程订单', WAREHOUSE_TRANSFER: '仓储转运单', RETURN_ORDER: '退货单' }
return type ? labels[type] || type : '—'
}
export function storageRentGeneratedAmounts(lot: Pick<StorageRentLot, 'generatedAmounts' | 'generatedAmount' | 'generatedCurrency'>) {
if (lot.generatedAmounts?.length) return lot.generatedAmounts.map(item => feeMoney(item.amount, item.currency))
return [feeMoney(lot.generatedAmount, lot.generatedCurrency || '')]
}
export function storageRentChargeStatus(charge: { status: string; originalAmount: number | string | null }) {
if (charge.status === 'SETTLED' && charge.originalAmount != null && Number(charge.originalAmount) === 0) return '免租'
const labels: Record<string, string> = { PENDING_REVIEW: '待审核', REJECTED: '已驳回', REVIEW_FREEZE_PENDING: '调整锁定中', REVIEW_FREEZE_FAILED: '调整锁定失败', FREEZE_PENDING: '锁定中', READY: '待出库', CONSUMED: '扣费中', SETTLED: '已扣费', SETTLE_FAILED: '扣费失败', FAILED: '锁定失败', EXPIRED: '已过期', CANCELLED: '已取消' }
return labels[charge.status] || charge.status
}
export function storageRentSummaryStatus(lot: StorageRentLot) {
if (lot.chargeCount == null) return '待核对'
if (!Number(lot.chargeCount)) return lot.status === 'CLEARED' ? '仓租记录待核对' : '尚未出库计租'
if (Number(lot.settledChargeCount) < Number(lot.chargeCount)) return '有待完成扣款'
return Number(lot.settledAmount) === 0 ? '免租 · 已结算' : '已扣款'
}
export function storageRentBillingShare(quantity: number, lot: StorageRentLot) {
if (!(Number(lot.originalQuantity) > 0) || lot.billingQuantity == null) return '—'
const units = Number(quantity) * Number(lot.billingQuantity) / Number(lot.originalQuantity)
return `${Number(units.toFixed(6))} ${lot.billingUnit === 'PALLET' ? '托' : lot.billingUnit}`
}
export function storageRentTime(value?: string | null, timeZone?: string) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('sv-SE', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23', timeZone,
}).format(date)
}
+1
View File
@@ -12,6 +12,7 @@ export interface OverseasPurchaseQuery {
purchaseWarehouseId?: string; createdFrom?: string; createdTo?: string
}
export interface OverseasPurchaseSummary {
procurementDirection?: import('./procurementDirection').ProcurementDirection
id: string; orderNo: string; customerId: string; customerName: string | null
purchaseCategory: string; productName: string; productSpecification: string | null
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
@@ -0,0 +1,72 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OverseasFee } from './api/overseasFees'
import { feeProcessingLabel } from './overseasFeePresentation'
import { useFeeStatusRefresh } from './useFeeStatusRefresh'
const pending = () => ({ sourceFeeId: '1', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED',
publicationStatus: 'PUBLISHED', deliveryStatus: 'PENDING', walletStatus: 'NOT_REQUIRED' }) as OverseasFee
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
describe('费用最终状态自动回查', () => {
it('余额已扣成功不再等待旧费用推送,显示完成并停止回查', async () => {
const scope = effectScope(), rows = ref([{ ...pending(), walletStatus: 'SETTLED', deliveryErrorMessage: '旧推送失败' }]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
expect(feeProcessingLabel(rows.value[0]!)).toBe('已完成')
await vi.advanceTimersByTimeAsync(20000)
expect(refresh).not.toHaveBeenCalled()
scope.stop()
})
it.each(['PUBLISHED', 'DEAD'] as const)('读取实际 %s 回执后停止,显示最终业务结果', async deliveryStatus => {
const scope = effectScope(), rows = ref([pending()]), enabled = ref(true)
const refresh = vi.fn(async () => { rows.value = [{ ...pending(), deliveryStatus }] })
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(2000)
expect(refresh).toHaveBeenCalledTimes(1)
expect(feeProcessingLabel(rows.value[0]!)).toBe(deliveryStatus === 'PUBLISHED' ? '已完成' : '处理失败')
await vi.advanceTimersByTimeAsync(10000)
expect(refresh).toHaveBeenCalledTimes(1)
scope.stop()
})
it('离开当前列表或销毁组件后不再回查,回到列表可恢复', async () => {
const scope = effectScope(), rows = ref<OverseasFee[]>([]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
rows.value = [pending()]; await nextTick()
enabled.value = false; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).not.toHaveBeenCalled()
enabled.value = true; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).toHaveBeenCalledTimes(1)
scope.stop()
await vi.advanceTimersByTimeAsync(10000); expect(refresh).toHaveBeenCalledTimes(1)
})
it('长时间未完成只回查有限次数,人工重新操作后可开启新一轮', async () => {
const scope = effectScope(), rows = ref<OverseasFee[]>([]), enabled = ref(true)
const refresh = vi.fn(async () => { rows.value = [pending()] })
const control = scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))!
rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(700000); expect(refresh).toHaveBeenCalledTimes(90)
control.reset(); rows.value = [pending()]; await nextTick()
await vi.advanceTimersByTimeAsync(2000); expect(refresh).toHaveBeenCalledTimes(91)
scope.stop()
})
it('自动重试中的失败保持可见,超过一分钟仍跟进最终接收结果', async () => {
const scope = effectScope(), rows = ref([{ ...pending(), deliveryErrorMessage: 'S0500' }]), enabled = ref(true)
const refresh = vi.fn(async () => {})
scope.run(() => useFeeStatusRefresh(rows, refresh, enabled))
expect(feeProcessingLabel(rows.value[0]!)).toBe('处理失败')
await vi.advanceTimersByTimeAsync(80000)
expect(refresh).toHaveBeenCalledTimes(32)
rows.value = [{ ...pending(), deliveryStatus: 'PUBLISHED', deliveryErrorMessage: '' }]; await nextTick()
expect(feeProcessingLabel(rows.value[0]!)).toBe('已完成')
await vi.advanceTimersByTimeAsync(20000); expect(refresh).toHaveBeenCalledTimes(32)
scope.stop()
})
})
@@ -0,0 +1,28 @@
import { onScopeDispose, watch, type Ref } from 'vue'
import type { OverseasFee } from './api/overseasFees'
/** 跟踪尚未收到回执的事件;失败后自动重试期间继续回查,离开页面即停止。 */
export function useFeeStatusRefresh(rows: Ref<OverseasFee[]>, refresh: () => Promise<void>, enabled: Ref<boolean>) {
let timer: ReturnType<typeof setTimeout> | undefined
let remaining = 90
let stopped = false
const pending = () => rows.value.some(row => row.walletStatus !== 'SETTLED' && (['PENDING', 'PROCESSING'].includes(row.deliveryStatus || '') || row.walletStatus?.endsWith('_PENDING')))
function clear() { if (timer !== undefined) clearTimeout(timer); timer = undefined }
function schedule() {
clear()
if (stopped || !enabled.value || !pending() || remaining <= 0) return
timer = setTimeout(async () => {
timer = undefined
if (stopped || !enabled.value) return
remaining--
await refresh()
schedule()
}, remaining > 60 ? 2000 : 10000)
}
watch([rows, enabled], () => {
if (!pending()) remaining = 90
schedule()
}, { immediate: true })
onScopeDispose(() => { stopped = true; clear() })
return { reset: () => { remaining = 90; clear() } }
}
@@ -154,6 +154,7 @@ export interface LastMileStatusHistoryDto {
}
export interface LastMileFeeDto {
collectionMode?: 'BILL' | 'WALLET' | null; walletStatus?: string | null
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; orderId?: string | null
feeItemCode: string; feeItemName: string; direction: 'RECEIVABLE' | 'PAYABLE'
currency: 'CNY'; amount: number; originalCurrency: 'CNY' | 'USD'; originalAmount: number
@@ -173,9 +174,10 @@ export interface LastMileOperationDto {
}
export interface CreateLastMileFeePayload {
feeRemark?: string
feeItemCode: string; feeItemName: string; direction: 'RECEIVABLE' | 'PAYABLE'; currency: 'CNY' | 'USD'
amount: number; businessDate: string; supplierId?: string; supplierName?: string; supplierCategory?: string
settlementMode?: 'ON_ACCOUNT' | 'DIRECT_PAID'; paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID'; paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'
paymentReferenceNo?: string; paymentVoucherFileIds?: string[]; chargeableQuantity?: number
quantityUnit?: string; unitPrice?: number; periodStart?: string; periodEnd?: string
}
@@ -14,6 +14,9 @@ const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessId', 'businessNo', 'customerId'], template: '<div>本单费用操作</div>',
} }))
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo, getCargoDiscardStatistics: api.statistics }))
enableAutoUnmount(afterEach)
@@ -40,6 +43,14 @@ function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: strin
async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() }
describe('native cargo discard detail and approval', () => {
it('binds fees to the discard application instead of its inventory or outbound order, and unmounts them on close', async () => {
const { dialog } = await open()
expect(dialog.getComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
businessType: 'DISCARD_ORDER', businessId: '9007199254740993', businessNo: 'CD-001', customerId: '9007199254740994',
})
await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
})
it('preserves all readonly facts, full item values and history through fullscreen', async () => {
const { wrapper, dialog } = await open()
expect(api.statistics).not.toHaveBeenCalled()
@@ -134,6 +145,7 @@ describe('native cargo discard detail and approval', () => {
expect(api.photo).toHaveBeenLastCalledWith('CD-002', '9007199254740996', 'CUSTOMER-2')
resolve(new Blob(['old'], { type: 'image/png' })); await flushPromises()
expect(URL.createObjectURL).toHaveBeenCalledOnce(); expect(dialog.props('title')).toBe('废弃单详情 · CD-002')
expect(dialog.getComponent({ name: 'BusinessDocumentFees' }).props()).toMatchObject({ businessNo: 'CD-002', customerId: 'CUSTOMER-2' })
})
it('ignores a detail response after unmounting before it can start photo downloads', async () => {
let resolve!: (value: CargoDiscardDetail) => void
@@ -15,6 +15,7 @@ import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { BusinessDocumentFees } from '@/modules/overseas-business'
import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
@@ -90,6 +91,10 @@ onMounted(() => { void loadCustomers(); void load() })
<tr data-tone="source"><th>仓库</th><td>{{ detail.summary.overseasWarehouseName || '—' }}</td><th>入仓码</th><td>{{ detail.summary.inboundCodes || '—' }}</td></tr>
<tr data-tone="work"><th>状态</th><td>{{ statusLabels[detail.summary.status || ''] || detail.summary.status || '未记录' }}</td><th>备注</th><td>{{ detail.remark || '—' }}</td></tr>
</tbody></table>
<section v-if="detailOpen" aria-label="废弃单费用明细">
<h3>费用明细</h3>
<BusinessDocumentFees :key="detail.summary.id" business-type="DISCARD_ORDER" :business-id="detail.summary.id" :business-no="detail.summary.discardNo" :customer-id="detail.summary.customerId || undefined" />
</section>
<h3>废弃明细</h3><el-table :data="detail.items" border>
<el-table-column label="入仓码"><template #default="{ row }"><LineClampText :content="row.inboundCode || ''" /></template></el-table-column>
<el-table-column label="SKU"><template #default="{ row }"><LineClampText :content="row.skuCode || ''" /></template></el-table-column>
@@ -7,6 +7,9 @@ import DeliveryOrderDetailView from './DeliveryOrderDetailView.vue'
const routeState = vi.hoisted(() => ({ params: { deliveryNo: 'DEL/1' }, query: { customerId: '100' } as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn() }))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
} }))
describe('DeliveryOrderDetailView', () => {
beforeEach(() => {
@@ -34,6 +37,10 @@ describe('DeliveryOrderDetailView', () => {
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.text()).toContain('ACY2')
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
businessType: 'DELIVERY_ORDER', businessNo: 'DEL/1', customerId: '100', businessId: '1',
})
expect(wrapper.text()).toContain('履约费用记录')
expect(wrapper.text()).toContain('私人仓尾程派送费')
expect(wrapper.text()).toContain('派送费用')
expect(wrapper.text()).toContain('作废待财务确认')
@@ -48,6 +55,7 @@ describe('DeliveryOrderDetailView', () => {
await flushPromises()
expect(getDeliveryOrder).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少派送单客户范围')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
wrapper.unmount()
})
it('shows frozen original coverage separately from new fees', async () => {
@@ -13,6 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryFeeRow } from '../api/deliveryOrders'
import { coveredServiceLabels } from '../feeCoverageTypes'
import { BusinessDocumentFees } from '@/modules/overseas-business'
const route = useRoute()
const router = useRouter()
@@ -102,13 +103,15 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</section>
</el-tab-pane>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>{{ `费用明细 (${detail?.fees.length || 0})` }}</span></template>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template>
<section class="tab-content" aria-label="派送单费用明细">
<BusinessDocumentFees v-if="detail" business-type="DELIVERY_ORDER" :business-no="detail.order.deliveryNo" :customer-id="detail.order.customerId" :business-id="detail.order.id" />
<template v-if="detail?.feeCoverage?.references.length">
<p>本次已确认的原费用覆盖({{ formatOverseasDateTime(detail.feeCoverage.confirmedAt) }}):原费用金额供核对,不计作本次新增。</p>
<p v-for="reference in detail.feeCoverage.references" :key="`${reference.source.financeFeeId}-${reference.services.join('-')}`">{{ detail.feeCoverage.cargo.find(cargo => cargo.sourceInboundId === reference.source.sourceInboundId)?.inboundCode || reference.source.inboundCode || '—' }} · {{ reference.source.feeItemName || '—' }} · {{ reference.source.currency }} {{ reference.source.amount }} · 版本 {{ reference.source.sourceVersion }} · 已含{{ reference.services.map(service => coveredServiceLabels[service]).join('、') }}</p>
</template>
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
<h2 v-if="detail?.fees.length" class="fulfillment-fees-heading">履约费用记录</h2>
<el-table v-if="detail?.fees.length" :data="detail.fees" border stripe empty-text="暂无履约费用">
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column label="费用状态" min-width="165"><template #default="scope">{{ feeStatus(scope.row as DeliveryFeeRow) }}</template></el-table-column><el-table-column prop="remark" label="备注" min-width="240" />
@@ -142,6 +145,7 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
.title-line h1 { margin: 0; font-size: var(--admin-font-section); line-height: 28px; font-weight: 500; }
.delivery-detail { min-height: 0; overflow: hidden; border: 0; border-radius: 0; }
.tab-content { width: 100%; padding: 0; box-sizing: border-box; }
.tab-content .fulfillment-fees-heading { margin-top: var(--admin-space-5); }
.info-item--wide { grid-column: 1 / -1; }
</style>
@@ -14,7 +14,7 @@ it('provides the scoped plan facts and reloads after shortage confirmation', asy
status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] }
vi.mocked(getInboundPlan).mockResolvedValue(detail)
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } })
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, BusinessDocumentFees: true } } })
await flushPromises()
expect(wrapper.findComponent({ name: 'InboundShortageConfirmation' }).props('detail')).toEqual(detail)
expect(wrapper.text()).toContain('待差异核对')
@@ -0,0 +1,43 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getInboundPlan } from '../api/inboundPlans'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { customerId: '9007199254741001' } as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
} }))
describe('InboundOrderDetailView fees', () => {
beforeEach(() => {
route.query = { customerId: '9007199254741001' }
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({
id: '9007199254741002', planNo: 'PLAN-1', customerId: '9007199254741001', sourceOrderId: '9007199254741003',
lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [],
})
})
it('uses the loaded inbound plan identity without substituting its source order', async () => {
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, InboundShortageConfirmation: true } } })
await flushPromises()
expect(getInboundPlan).toHaveBeenCalledWith('PLAN-1', '9007199254741001')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
businessType: 'OVERSEAS_INBOUND', businessNo: 'PLAN-1', customerId: '9007199254741001', businessId: '9007199254741002',
})
expect(wrapper.text()).not.toContain('当前入库计划详情接口未提供应收费用')
wrapper.unmount()
})
it('does not expose fee operations when the customer scope is absent', async () => {
route.query = {}
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, InboundShortageConfirmation: true } } })
await flushPromises()
expect(getInboundPlan).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
wrapper.unmount()
})
})
@@ -3,6 +3,7 @@ import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { BusinessDocumentFees } from '@/modules/overseas-business'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
@@ -45,7 +46,6 @@ const summaryGroups = [
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '货物毛重(kg)', '货物净重(kg)', '计费体积(m³)', '总托盘数'] },
]
const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注']
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
const palletColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
@@ -101,7 +101,7 @@ onMounted(load)
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
<section v-if="detail" class="detail-section fee-section" aria-label="入库单费用"><BusinessDocumentFees business-type="OVERSEAS_INBOUND" :business-no="detail.planNo" :customer-id="detail.customerId" :business-id="detail.id" /></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="移库来源"><h3 class="density-dialog-section__title">来源信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ referenceValue }}</td></tr><tr><th scope="row">托盘号</th><td>{{ moveRow?.palletNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="moveLocationError" compact title="可用库位加载失败" :message="moveLocationError.message" :loading="moveLocationsLoading" @retry="loadMoveLocations"><template #details><ApiErrorMeta :details="moveLocationError" /></template></LoadErrorState>
@@ -118,7 +118,7 @@ onMounted(load)
.compact-section-heading { display:flex; min-height:42px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-2) var(--admin-space-5); border-bottom:1px solid var(--workbench-border); }
.compact-section-heading h2 { margin:0; color:var(--workbench-text); font-size:var(--admin-font-section); }
.compact-section-heading span { color:var(--workbench-muted); font-size:var(--admin-font-caption); }
.fee-section { overflow:hidden; }.fee-total { display:flex; min-height:44px; align-items:center; justify-content:flex-end; gap:40px; margin:0 var(--admin-space-5) var(--admin-space-4); padding:var(--admin-space-3) var(--admin-space-5); border:1px solid var(--workbench-border); border-top:0; }.fee-total strong { min-width:120px; font-size:var(--admin-font-section); }
.fee-section { overflow:hidden; padding:var(--admin-space-5); }
.pallet-section { overflow: hidden; }.section-heading { display:flex; min-height:50px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-3) var(--admin-space-5); border-bottom:1px solid var(--workbench-border); }.pallet-section h2 { margin: 0 0 var(--admin-space-1); color: var(--workbench-text); font-size: var(--admin-font-section); }.pallet-note { margin: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }.section-count { flex:0 0 auto; color:var(--workbench-muted); font-size:var(--admin-font-caption); font-variant-numeric:tabular-nums; }
.appointment-section,.fee-section { border-top:1px solid var(--workbench-border); }
.appointment-section { padding:0 var(--admin-space-5) var(--admin-space-4); }
@@ -133,6 +133,5 @@ onMounted(load)
.detail-fields { --admin-field-label-width: 108px; grid-template-columns: repeat(5, minmax(0, 1fr)); }
:deep(.density-detail-header) { border-bottom: 1px solid var(--workbench-border); }
.detail-table-wrap { padding:var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); }
.fee-section .detail-table-wrap { padding-bottom:0; }
</style>
@@ -7,11 +7,14 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listOverseasFeeItems, type OverseasFee } from '@/modules/overseas-business'
import { getPlatformWarehouse } from '@/modules/warehouses'
import { getWarehouseTransfer } from '../api/warehouseTransfers'
const can = vi.hoisted(() => vi.fn(() => false))
const push = vi.hoisted(() => vi.fn())
const getDocumentFees = vi.hoisted(() => vi.fn(async (): Promise<OverseasFee[]> => []))
vi.mock('@/modules/overseas-business', async () => ({ BusinessDocumentFees: { name: 'BusinessDocumentFees', props: { businessType: String, businessId: String, businessNo: String, customerId: String, relatedDocuments: Array, direction: String, canCreate: Boolean, hideWhenEmpty: Boolean, heading: String }, template: '<div><slot name="toolbar-actions" /></div>', methods: { refresh() {}, getFees: getDocumentFees } }, listOverseasFeeItems: vi.fn(), sameProjectFees: (await import('../../overseas-business/overseasFeePresentation')).sameProjectFees, sameProjectFeeSummary: (await import('../../overseas-business/overseasFeePresentation')).sameProjectFeeSummary, feeStatus: (await import('../../overseas-business/overseasFeePresentation')).feeStatus, overseasBusinessPermissions: (await import('../../overseas-business/permissions')).overseasBusinessPermissions }))
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
@@ -30,14 +33,12 @@ async function openFeeEditor() {
}
async function fillFee(dialog: VueWrapper, direction: 'RECEIVABLE' | 'PAYABLE') {
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'STORAGE_FEE'); selects[0]!.vm.$emit('change', 'STORAGE_FEE')
selects[1]!.vm.$emit('update:modelValue', direction); await flushPromises()
selects[0]!.vm.$emit('update:modelValue', 'FEE_TYPE_071')
if (direction === 'PAYABLE') dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!.vm.$emit('update:modelValue', feeSupplier.id)
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择币种')!.vm.$emit('update:modelValue', direction === 'PAYABLE' ? 'CNY' : 'USD')
const numbers = dialog.findAllComponents({ name: 'ElInputNumber' })
numbers[0]!.vm.$emit('update:modelValue', 80); numbers[1]!.vm.$emit('update:modelValue', 2)
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
dates[0]!.vm.$emit('update:modelValue', '2026-10-20'); dates[1]!.vm.$emit('update:modelValue', '2026-10-01'); dates[2]!.vm.$emit('update:modelValue', '2026-10-20')
await flushPromises()
await dialog.get('input[placeholder="如 箱、托、天、m³"]').setValue('托')
}
@@ -130,9 +131,11 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
})
beforeEach(() => {
getDocumentFees.mockReset().mockResolvedValue([])
can.mockReset().mockReturnValue(false)
vi.mocked(listOverseasFeeItems).mockReset().mockResolvedValue([{ feeCode: 'FEE_TYPE_071', feeTypeName: '贴标费', defaultPrice: 1 }, { feeCode: 'FEE_TYPE_077', feeTypeName: '仓租', defaultPrice: 1 }])
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [feeSupplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'FEE_TYPE_071', feeItemName: '贴标费', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
push.mockReset()
vi.mocked(getWarehouseTransfer).mockReset()
vi.mocked(getPlatformWarehouse).mockReset()
@@ -252,14 +255,19 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
const { dialog } = await openFeeEditor()
expect(dialog.props('title')).toContain('FL-WH-001')
expect(dialog.props('width')).toContain('720px')
expect(dialog.text()).not.toContain('业务日期')
expect(dialog.text()).not.toContain('计费开始日期')
expect(dialog.text()).not.toContain('计费结束日期')
expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
expect(dialog.get('[aria-label="尾程费用登记"]').attributes('data-tone')).toBeUndefined()
await fillFee(dialog, direction)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
const payable = direction === 'PAYABLE'
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', {
feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: '2026-10-20',
feeItemCode: 'FEE_TYPE_071', feeItemName: '贴标费', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: new Date().toISOString().slice(0, 10),
supplierId: payable ? feeSupplier.id : undefined, supplierName: payable ? feeSupplier.name : undefined, supplierCategory: payable ? feeSupplier.category : undefined,
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40, periodStart: '2026-10-01', periodEnd: '2026-10-20',
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40,
})
})
it('keeps fee required-field validation before a request', async () => {
@@ -267,6 +275,98 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
})
it('uses the live catalog, excludes automatic rent and only loads suppliers for payables', async () => {
const { dialog } = await openFeeEditor()
expect(listOverseasFeeItems).toHaveBeenCalledWith('RECEIVABLE')
expect(listSuppliers).not.toHaveBeenCalled()
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.props('allowCreate')).toBe(false)
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071'])
await fillFee(dialog, 'PAYABLE')
expect(listOverseasFeeItems).toHaveBeenLastCalledWith('PAYABLE')
expect(listSuppliers).toHaveBeenCalledOnce()
})
it('blocks saving when the catalog fails and supports retry', async () => {
vi.mocked(listOverseasFeeItems).mockRejectedValueOnce(new Error('目录暂不可用'))
const { dialog } = await openFeeEditor()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.text()).toContain('费用项目加载失败')
await dialog.findAll('button').find(button => button.text() === '重新加载')!.trigger('click'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(createLastMileOrderFee).not.toHaveBeenCalled()
})
it('keeps supplier failures in the editor, blocks payables and reloads the shared supplier list', async () => {
vi.mocked(listSuppliers).mockRejectedValueOnce(new ApiResponseError('仓储数据库暂不可用', 'S0503', 503, null, 'supplier-request'))
const { dialog } = await openFeeEditor()
await fillFee(dialog, 'PAYABLE')
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
expect(dialog.text()).toContain('供应商加载失败')
expect(dialog.props('confirmDisabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
await dialog.findAll('button').find(button => button.text() === '重试加载供应商')!.trigger('click'); await flushPromises()
expect(dialog.text()).not.toContain('供应商加载失败')
expect(dialog.props('confirmDisabled')).toBe(false)
const supplier = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!
expect(supplier.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual([feeSupplier.id])
})
it('ignores a supplier response after switching back to customer receivables', async () => {
let reject!: (reason: unknown) => void
vi.mocked(listSuppliers).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { dialog } = await openFeeEditor()
const direction = dialog.findAllComponents({ name: 'ElSelect' })[1]!
direction.vm.$emit('update:modelValue', 'PAYABLE'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
direction.vm.$emit('update:modelValue', 'RECEIVABLE'); await flushPromises()
reject(new Error('旧请求失败')); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(dialog.text()).not.toContain('供应商加载失败')
})
it('places fees and related source charges inside the exact order without a duplicated receivable table', async () => {
const result = await getLastMileOrder('internal-order-id')
vi.mocked(getLastMileOrder).mockResolvedValue({ ...result, order: { ...result.order, orderType: 'RETURN_RELABEL', sourceOrderNo: 'TH-01' } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
const panels = wrapper.findAllComponents({ name: 'BusinessDocumentFees' })
expect(panels).toHaveLength(2)
for (const [index, panel] of panels.entries()) expect(panel.props()).toMatchObject({
businessType: 'LASTLEG_ORDER', businessId: result.order.id, businessNo: result.order.orderNo, customerId: result.order.customerId,
direction: index === 0 ? 'RECEIVABLE' : 'PAYABLE',
relatedDocuments: [{ businessType: 'RETURN_ORDER', businessNo: 'TH-01', customerId: result.order.customerId }],
})
expect(panels[0]!.props()).toMatchObject({ heading: '应收明细', hideWhenEmpty: true, canCreate: false })
expect(panels[1]!.props()).toMatchObject({ heading: '应付明细', hideWhenEmpty: true, canCreate: false })
expect(wrapper.findAll('#pane-fees .el-table').length).toBe(0)
})
it('uses one create button and selects payable inside the shared editor', async () => {
can.mockReturnValue(true)
const wrapper = mount(LastMileOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
const createButtons = wrapper.findAll('#pane-fees button').filter(button => button.text().trim() === '新增费用')
expect(createButtons).toHaveLength(1)
await createButtons[0]!.trigger('click'); await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.findAllComponents({ name: 'ElSelect' })[1]!.props('modelValue')).toBe('RECEIVABLE')
await fillFee(dialog, 'PAYABLE')
expect(listOverseasFeeItems).toHaveBeenCalledWith('PAYABLE')
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({
direction: 'PAYABLE', supplierId: feeSupplier.id, settlementMode: 'ON_ACCOUNT',
}))
})
it('shows existing amount and approval before allowing another fee with a reason', async () => {
getDocumentFees.mockResolvedValue([{ sourceFeeId: 'old-fee', feeItemCode: 'FEE_TYPE_071', direction: 'RECEIVABLE', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', originalCurrency: 'USD', originalAmount: 80 } as OverseasFee])
const { dialog } = await openFeeEditor()
await fillFee(dialog, 'RECEIVABLE')
expect(dialog.text()).toContain('USD 80.00 · 已通过')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
await dialog.get('textarea[placeholder="补收或重复作业时,请说明原因"]').setValue('第二次贴标作业')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeRemark: '第二次贴标作业' }))
})
it('locks fee controls and preserves failed values and diagnostic context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createLastMileOrderFee).mockReturnValue(new Promise((_, no) => { reject = no }))
@@ -8,6 +8,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getPlatformWarehouse } from '@/modules/warehouses'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { BusinessDocumentFees, listOverseasFeeItems, sameProjectFees, sameProjectFeeSummary, type OverseasFee } from '@/modules/overseas-business'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -28,7 +29,6 @@ import TransferPickupProgressPanel from '../components/TransferPickupProgressPan
interface DetailField { label: string; value: string | number; wide?: boolean }
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
interface DocumentRow { status?: string; quantity?: number; type: string; number: string; createdAt: string; quote: string }
interface FeeRow { id: string; item: string; currency: string; amount: string; status: string; description: string }
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string; sortAt: string }
const route = useRoute()
@@ -56,9 +56,27 @@ const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const activeTab = ref('basic')
const feeDialogOpen = ref(false)
const feePanel = ref<InstanceType<typeof BusinessDocumentFees>>()
const payableFeePanel = ref<InstanceType<typeof BusinessDocumentFees>>()
const relatedFeeDocuments = computed(() => {
const order = detail.value?.order
const sourceTypes: Record<string, string> = { WAREHOUSE_TRANSFER: 'WAREHOUSE_TRANSFER', RETURN_RELABEL: 'RETURN_ORDER', RELABEL_APPLICATION: 'RELABEL_ORDER', CARGO_DISCARD: 'DISCARD_ORDER', LOCAL_LOOSEBOARD: 'LOCAL_LOOSEBOARD' }
const businessType = sourceTypes[order?.orderType || '']
const businessNo = order?.orderType === 'RELABEL_APPLICATION' ? order.relabelOrderNo || order.sourceOrderNo : order?.sourceOrderNo
return order && businessNo && businessType ? [{ businessType, businessNo, customerId: order.customerId }] : []
})
const supplierOptions = ref<Supplier[]>([])
const feeForm = reactive({ itemCode: '', itemName: '', direction: 'RECEIVABLE' as 'RECEIVABLE' | 'PAYABLE', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined as number | undefined, quantityUnit: '', periodStart: '', periodEnd: '' })
const feeItemOptions = [{ code: 'STORAGE_FEE', name: '仓租' }, { code: 'INBOUND_OPERATION', name: '入库操作费' }, { code: 'OUTBOUND_OPERATION', name: '出库操作费' }, { code: 'RELABEL_FEE', name: '换标费' }, { code: 'REBOX_FEE', name: '换箱费' }, { code: 'PALLET_FEE', name: '托盘费' }, { code: 'DELIVERY_FEE', name: '派送费' }, { code: 'TRANSFER_FEE', name: '转仓费' }, { code: 'EXCEPTION_FEE', name: '异常处理费' }, { code: 'OTHER', name: '其他' }]
const suppliersLoading = ref(false)
const suppliersError = ref<ApiErrorDetails | null>(null)
let supplierRequest = 0
const feeForm = reactive({ itemCode: '', itemName: '', feeRemark: '', settlementMode: '' as '' | 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID', direction: 'RECEIVABLE' as 'RECEIVABLE' | 'PAYABLE', currency: 'USD' as 'CNY' | 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined as number | undefined, quantityUnit: '' })
const existingFees = ref<OverseasFee[]>([]), existingFeesLoading = ref(false), existingFeesError = ref('')
const duplicateFees = computed(() => sameProjectFees(existingFees.value, feeForm.itemCode, feeForm.direction))
let existingFeesRequest = 0
const feeItemOptions = ref<Awaited<ReturnType<typeof listOverseasFeeItems>>>([])
const feeItemsLoading = ref(false)
const feeItemsError = ref('')
let feeItemsRequest = 0
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function containerNodeLabel(value: unknown) {
@@ -71,7 +89,6 @@ function containerNodeLabel(value: unknown) {
} as Record<string, string>)[code] || text(code)
}
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
@@ -205,10 +222,6 @@ const cargo = computed<CargoRow[]>(() => isReturnOrder.value
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0),
})))
const fees = computed<FeeRow[]>(() => (detail.value?.fees || []).map((item) => ({
id: String(item.id), item: item.feeItemName, currency: item.originalCurrency, amount: `${item.originalCurrency} ${Number(item.originalAmount).toFixed(2)}`,
status: feeStatusLabel(item.reviewStatus === 'PENDING' ? 'PENDING_REVIEW' : item.reviewStatus), description: text(item.supplierName || (item.chargeableQuantity == null ? null : `${item.chargeableQuantity} ${item.quantityUnit || ''}`)),
})))
const logs = computed<LogRow[]>(() => {
const statusRows = (detail.value?.statusHistory || []).map((item) => ({
id: `status-${item.id}`, occurredAt: formatOverseasDateTime(item.createdAt), sortAt: item.createdAt || '', node: lastMileStatusLabel(item.toStatus), operator: '系统',
@@ -235,11 +248,6 @@ const documentColumns: DataTableColumn[] = [
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
]
const feeColumns: DataTableColumn[] = [
{ key: 'item', prop: 'item', label: '费用项目', minWidth: 180 }, { key: 'currency', prop: 'currency', label: '币种', width: 120 },
{ key: 'amount', prop: 'amount', label: '金额', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 140 },
{ key: 'description', prop: 'description', label: '供应商/说明', minWidth: 260 },
]
const logColumns: DataTableColumn[] = [
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 180 }, { key: 'node', prop: 'node', label: '操作节点', minWidth: 180 },
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 150 }, { key: 'result', prop: 'result', label: '处理结果', minWidth: 320 },
@@ -252,23 +260,73 @@ async function load() {
catch (error) { loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌' ? { message: error.message } : getApiErrorDetails(error, '尾程订单详情加载失败,请重试') }
finally { loading.value = false }
}
async function openFeeDialog() {
Object.assign(feeForm, { itemCode: '', itemName: '', direction: 'RECEIVABLE', currency: 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined, quantityUnit: '', periodStart: '', periodEnd: '' })
async function openFeeDialog(direction: 'RECEIVABLE' | 'PAYABLE' = 'RECEIVABLE') {
Object.assign(feeForm, { itemCode: '', itemName: '', feeRemark: '', settlementMode: '', direction, currency: 'USD', amount: 0, businessDate: new Date().toISOString().slice(0, 10), supplierId: '', quantity: undefined, quantityUnit: '' })
mutationError.value = null; feeDialogOpen.value = true
if (access.can('platform:supplier:list') && supplierOptions.value.length === 0) {
try { supplierOptions.value = (await listSuppliers({ page: 1, size: 200, status: 'ACTIVE' })).records.filter((item) => item.status === 'ACTIVE') }
catch (error) { mutationError.value = getApiErrorDetails(error, '供应商列表加载失败') }
}
await loadFeeItems()
}
async function loadFeeItems() {
const request = ++feeItemsRequest
const direction = feeForm.direction
feeItemsLoading.value = true; feeItemsError.value = ''; feeItemOptions.value = []
feeForm.itemCode = ''; feeForm.itemName = ''
try {
const items = await listOverseasFeeItems(direction)
if (request === feeItemsRequest) feeItemOptions.value = items.filter(item => item.feeCode !== 'FEE_TYPE_077')
} catch (error) {
if (request === feeItemsRequest) feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败').message
} finally { if (request === feeItemsRequest) feeItemsLoading.value = false }
}
async function loadFeeSuppliers() {
const request = ++supplierRequest
supplierOptions.value = []; suppliersError.value = null; suppliersLoading.value = false
if (!feeDialogOpen.value || feeForm.direction !== 'PAYABLE') return
if (!access.can('platform:supplier:list')) { suppliersError.value = { message: '当前账号没有供应商查询权限' }; return }
suppliersLoading.value = true
try {
const result = await listSuppliers({ page: 1, size: 200, status: 'ACTIVE' })
if (request === supplierRequest) supplierOptions.value = result.records.filter(item => item.status === 'ACTIVE')
} catch (error) {
if (request === supplierRequest) suppliersError.value = getApiErrorDetails(error, '供应商列表加载失败')
} finally { if (request === supplierRequest) suppliersLoading.value = false }
}
watch(() => feeForm.direction, () => {
if (!feeDialogOpen.value) return
void loadFeeItems()
})
watch([feeDialogOpen, () => feeForm.direction], () => {
feeForm.supplierId = ''
void loadFeeSuppliers()
})
/** 从对应收付表读取完整单据费用,换标申请等关联单据一并核对,避免分页漏提醒。 */
async function loadExistingFees() {
const request = ++existingFeesRequest
existingFees.value = []; existingFeesError.value = ''; existingFeesLoading.value = false
if (!feeDialogOpen.value) return
existingFeesLoading.value = true
try {
const panel = feeForm.direction === 'PAYABLE' ? payableFeePanel.value : feePanel.value
if (!panel) throw new Error('单据费用尚未加载完成')
const rows = await panel.getFees()
if (request === existingFeesRequest) existingFees.value = rows
} catch (failure) { if (request === existingFeesRequest) existingFeesError.value = getApiErrorDetails(failure, '已有费用加载失败').message }
finally { if (request === existingFeesRequest) existingFeesLoading.value = false }
}
watch([feeDialogOpen, () => feeForm.direction, orderId], loadExistingFees)
async function saveFee() {
if (saving.value) return
if (!feeForm.itemCode || !feeForm.itemName.trim() || feeForm.amount <= 0 || !feeForm.businessDate) { ElMessage.warning('请填写费用项目、业务日期和大于 0 的金额'); return }
if (existingFeesLoading.value || existingFeesError.value) { ElMessage.warning(existingFeesError.value || '正在核对已有费用'); return }
if (duplicateFees.value.length && !feeForm.feeRemark.trim()) { ElMessage.warning('此单已有相同费用项目,请填写费用说明后继续添加'); return }
if (feeForm.direction === 'PAYABLE' && (suppliersLoading.value || suppliersError.value)) return
const selectedItem = feeItemOptions.value.find(item => item.feeCode === feeForm.itemCode)
if (feeItemsLoading.value || feeItemsError.value || !selectedItem || feeForm.amount <= 0) { ElMessage.warning('请选择费用项目,填写大于 0 的金额'); return }
const supplier = supplierOptions.value.find((item) => String(item.id) === feeForm.supplierId)
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
saving.value = true; mutationError.value = null
try {
await createLastMileOrderFee(orderId.value, { feeItemCode: feeForm.itemCode, feeItemName: feeForm.itemName.trim(), direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: supplier ? 'ON_ACCOUNT' : undefined, chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined, periodStart: feeForm.periodStart || undefined, periodEnd: feeForm.periodEnd || undefined })
feeDialogOpen.value = false; ElMessage.success('费用已提交财务审核'); await load(); activeTab.value = 'fees'
await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined })
feeDialogOpen.value = false; ElMessage.success('费用已提交审核'); await load()
await Promise.all([feePanel.value?.refresh(), payableFeePanel.value?.refresh()]); activeTab.value = 'fees'
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用新增失败,请重试') }
finally { saving.value = false }
}
@@ -366,19 +424,29 @@ onMounted(load)
<section class="info-panel plan-panel" aria-label="计划信息"><dl class="detail-fields plan-fields density-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
<section class="table-panel related-panel"><div class="workbench-table"><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据"><template #toolbar-start><h2>关联单据</h2></template></DataTable></div></section>
</el-tab-pane>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template><section class="table-panel"><div class="workbench-table"><DataTable :data="fees" :columns="feeColumns" column-settings-mode="hide" row-key="id" empty-text="暂无费用明细"><template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" />新增费用</el-button></template></DataTable></div></section></el-tab-pane>
<el-tab-pane name="fees" lazy><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template>
<template v-if="detail">
<div class="fee-create-toolbar"><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog()"><Plus :size="16" />新增费用</el-button></div>
<BusinessDocumentFees ref="feePanel" heading="应收明细" hide-when-empty direction="RECEIVABLE" :can-create="false" business-type="LASTLEG_ORDER" :business-id="detail.order.id" :business-no="detail.order.orderNo" :customer-id="detail.order.customerId" :related-documents="relatedFeeDocuments" @changed="load" />
<BusinessDocumentFees ref="payableFeePanel" heading="应付明细" hide-when-empty direction="PAYABLE" :can-create="false" business-type="LASTLEG_ORDER" :business-id="detail.order.id" :business-no="detail.order.orderNo" :customer-id="detail.order.customerId" :related-documents="relatedFeeDocuments" @changed="load" />
</template>
</el-tab-pane>
<el-tab-pane name="logs"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>操作记录</span></template><section class="table-panel"><div class="workbench-table"><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录"></DataTable></div></section></el-tab-pane>
</el-tabs>
</section>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeDialogOpen" :title="`新增费用 · ${referenceValue}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<section class="density-dialog-section" data-tone="work" aria-label="尾程费用登记"><h3 class="density-dialog-section__title">费用登记</h3>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeDialogOpen" :title="`新增费用 · ${referenceValue}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存费用" :confirm-loading="saving" :confirm-disabled="feeItemsLoading || !!feeItemsError || (feeForm.direction === 'PAYABLE' && (suppliersLoading || !!suppliersError))" @confirm="saveFee">
<section class="density-dialog-section" aria-label="尾程费用登记"><h3 class="density-dialog-section__title">费用登记</h3>
<el-alert v-if="feeItemsError" type="error" :closable="false" :title="feeItemsError"><el-button text type="primary" @click="loadFeeItems">重新加载</el-button></el-alert>
<el-alert v-if="feeForm.direction === 'PAYABLE' && suppliersError" type="error" :closable="false" :title="`供应商加载失败:${suppliersError.message}`"><el-button text type="primary" @click="loadFeeSuppliers">重试加载供应商</el-button><ApiErrorMeta :details="suppliersError" /></el-alert>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving"><table class="density-table density-table--editable fee-editor-table"><tbody>
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td><el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item></td><td><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item></td></tr>
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable clearable :loading="feeItemsLoading" :disabled="feeItemsLoading || !!feeItemsError" placeholder="请选择费用项目"><el-option v-for="item in feeItemOptions" :key="item.feeCode" :label="item.feeTypeName" :value="item.feeCode" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr>
<tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr>
<tr v-if="existingFeesError"><td colspan="2">{{ existingFeesError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></td></tr>
</tbody></table></el-form>
</section>
</AppDialog>
@@ -386,6 +454,7 @@ onMounted(load)
</template>
<style scoped>
.fee-create-toolbar { display: flex; justify-content: flex-end; margin-bottom: var(--admin-space-3); }
.source-heading { margin: 0; padding: var(--admin-space-3); font-size: var(--admin-font-section); font-weight: 600; }
.return-carton-title { display:flex; align-items:center; flex-wrap:wrap; gap:8px 16px; padding:4px 0; line-height:1.5; }
.return-carton-measurements { color:var(--cc-text-secondary); font-weight:400; }
@@ -209,6 +209,33 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('opens the same inbound-code detail from all and before departure despite different order IDs', async () => {
const wrapper = await mountView()
const vm = wrapper.vm as unknown as { openDetail(row: object): void }
const row = { ...selectedRow(), orderTypeCode: 'FIRST_LEG', containerNode: '—' }
routerPush.mockClear()
vm.openDetail(row)
expect(routerPush).toHaveBeenLastCalledWith({ name: 'unified-order-detail-by-inbound-code', params: { inboundCode: row.inboundCode }, query: { tenantId: customerId, from: 'last-mile-orders' } })
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '开船前')!.trigger('click')
await flushPromises()
vm.openDetail({ ...row, id: '9007199254740999', orderNo: '—' })
expect(routerPush).toHaveBeenLastCalledWith({ name: 'unified-order-detail-by-inbound-code', params: { inboundCode: row.inboundCode }, query: { tenantId: customerId, from: 'last-mile-orders' } })
vm.openDetail({ ...row, containerNode: 'ACTUAL_DEPARTURE' })
expect(routerPush).toHaveBeenLastCalledWith({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
})
it('only uses the domestic ID fallback for source rows without an inbound code', async () => {
const wrapper = await mountView()
const vm = wrapper.vm as unknown as { openDetail(row: object): void }
const row = { ...selectedRow(), orderTypeCode: 'FIRST_LEG', containerNode: '—', inboundCode: '—', orderNo: '—' }
routerPush.mockClear()
vm.openDetail(row)
expect(routerPush).toHaveBeenCalledWith({ name: 'unified-order-detail', params: { orderId: row.id }, query: { tenantId: customerId, from: 'last-mile-orders' } })
routerPush.mockClear()
vm.openDetail({ ...row, orderNo: 'WC-1' })
expect(routerPush).not.toHaveBeenCalled()
})
it('keeps return row statistics across pages without rendering the long summary blocks', async () => {
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5, receivedBoxCount: 3, receivedParcelCount: 2, receivedPalletCount: 1 }
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 30, pages: 3 })
@@ -1119,10 +1119,19 @@ async function submitReworkRelabel() {
} catch (error) { ElMessage.error(getApiErrorDetails(error, '重新换标失败').message) }
finally { reworkSaving.value = false }
}
/** 开船前统一按入仓码联查头程详情,保留返回尾程列表的来源。 */
function openDetail(source: object) {
const row = source as LastMileOrderListItem
if (isBeforeDeparture(row)) {
void router.push({ name: 'unified-order-detail', params: { orderId: row.id }, query: { tenantId: row.customerId } })
const query = { tenantId: row.customerId, from: 'last-mile-orders' }
if (row.inboundCode && row.inboundCode !== '—') {
void router.push({ name: 'unified-order-detail-by-inbound-code', params: { inboundCode: row.inboundCode }, query })
} else if (!row.orderNo || row.orderNo === '—') {
// 未生成尾程单的聚合行 ID 才是头程订单 ID。
void router.push({ name: 'unified-order-detail', params: { orderId: row.id }, query })
} else {
ElMessage.warning('该订单缺少入仓码,暂时无法定位头程详情')
}
return
}
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
@@ -166,7 +166,7 @@ onMounted(initialize)
</script>
<template>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
<AdminPage spacing="compact" class="pallet-page admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
<section class="summary-grid" aria-label="海外托盘数量概览">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Layers aria-hidden="true" /></span><h2 class="workbench-metric-label">托盘总数</h2></div>
@@ -220,6 +220,7 @@ onMounted(initialize)
</template>
<style scoped>
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }
.pallet-editor-table { table-layout: fixed; }
.pallet-editor-table td:not([colspan]) { width: 50%; }
@@ -1,10 +1,41 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { getRelabelOrder, getRelabelQuantities } from '../api/relabelOrders'
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RELABEL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('../api/relabelOrders', async importOriginal => ({
...await importOriginal<typeof import('../api/relabelOrders')>(), getRelabelOrder: vi.fn(), getRelabelQuantities: vi.fn(),
}))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
} }))
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue'), 'utf8')
describe('RelabelOrderDetailView', () => {
it('resolves fees by the relabel identity without reusing the original customer order', async () => {
vi.mocked(getRelabelOrder).mockResolvedValue({
application: { id: '9007199254741004', relabelNo: 'RELABEL-1', lastlegOrderId: '9007199254741005', customerOrderNo: 'RETURN-1', chargeAmount: 25, chargeCurrency: 'CNY', walletStatus: 'NOT_REQUIRED' },
items: [], history: [],
})
vi.mocked(getRelabelQuantities).mockRejectedValue(new Error('数量暂不可用'))
const wrapper = mount(RelabelOrderDetailView, { global: { plugins: [ElementPlus], stubs: { AppDialog: true, RelabelDocumentsPanel: true } } })
await flushPromises()
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
businessType: 'RELABEL_ORDER', businessNo: 'RELABEL-1', customerId: undefined, businessId: '9007199254741004',
})
expect(wrapper.text()).toContain('原换标费')
expect(wrapper.text()).toContain('25.00 CNY')
expect(wrapper.text()).toContain('无需冻结或扣款')
expect(wrapper.text()).not.toContain('NOT_REQUIRED')
wrapper.unmount()
})
it('renders carton relabel mappings as a row list instead of summary form fields', () => {
expect(source).toContain('<template #content-after>')
expect(source).toContain('<h2>箱唛换标信息</h2>')
@@ -17,6 +17,7 @@ import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
import { BusinessDocumentFees } from '@/modules/overseas-business'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
const route = useRoute()
@@ -83,7 +84,7 @@ const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '贴标件数', 'SKU种数', '当前关联托盘', '本轮目标托盘', '原托盘快照', '预算总价'] },
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
{ title: '费用明细', fields: ['换标费总计', '资金异常说明'] },
{ title: '原换标收费', fields: ['原换标费', '资金异常说明'] },
]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function status(value: unknown) {
@@ -98,7 +99,7 @@ function status(value: unknown) {
CANCELLED: '已作废',
} as Record<string, string>)[String(value || '')] || text(value)
}
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
function walletStatus(value: unknown) { return ({ NOT_REQUIRED: '无需冻结或扣款', FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充'))
const fieldValues = computed<Record<string, string | number>>(() => {
const value = detail.value
@@ -122,7 +123,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
备注: text(value?.remark),
换标费总计: value?.application.chargeAmount == null ? '—' : `${Number(value.application.chargeAmount).toFixed(2)} ${value.application.chargeCurrency || 'USD'}`,
原换标费: value?.application.chargeAmount == null ? '—' : `${Number(value.application.chargeAmount).toFixed(2)} ${value.application.chargeCurrency || 'USD'}`,
资金异常说明: text(value?.application.walletErrorMessage),
}
})
@@ -182,6 +183,9 @@ async function previewCompletionPhoto(fileId: string) {
<OverseasReadOnlyDetail workbench class="admin-density" dense title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section v-if="detail" class="relabel-document-panel" aria-label="换标单费用">
<BusinessDocumentFees business-type="RELABEL_ORDER" :business-no="detail.application.relabelNo" :business-id="detail.application.id" />
</section>
<section v-if="access.can(permission.relabelOrderStatus) && canVoid" class="relabel-document-panel relabel-review-actions"><el-button type="danger" :loading="voiding" :disabled="confirming || returning" @click="voidOrder">作废</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" :disabled="returning || voiding" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" :disabled="confirming || voiding" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<LoadErrorState v-if="uploadError" title="换标PDF操作失败" :message="uploadError.message" :show-retry="false"><template #details><ApiErrorMeta :details="uploadError" /></template></LoadErrorState>
@@ -111,6 +111,8 @@ describe('OrderDeclarationEditorDialog', () => {
expect(listWarehouseLogisticsChannels).toHaveBeenCalledWith('700000000000000001')
expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单')
expect(wrapper.text()).toContain('RK-001')
expect(wrapper.get('.readonly-facts').text()).not.toContain('版本')
expect(wrapper.findComponent({ name: 'ElDescriptions' }).props('column')).toBe(3)
const volume = wrapper.findComponent({ name: 'ElInputNumber' })
expect(volume.props()).toMatchObject({ min: 0.0001, precision: 4, controls: false, modelValue: 0.625 })
volume.vm.$emit('update:modelValue', 0.875)
@@ -244,24 +244,23 @@ async function save() {
<section class="density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">原订单资料</h3>
<el-descriptions :column="4" border class="readonly-facts density-descriptions">
<el-descriptions :column="3" border class="readonly-facts density-descriptions">
<el-descriptions-item label="入仓码">{{ detail.summary.inboundCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="国内收货仓">{{ detail.summary.receivingWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="目的仓/中转仓">{{ detail.summary.destinationWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="版本">{{ form.expectedVersion }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="density-dialog-section" data-tone="work">
<h3 class="density-dialog-section__title">订单申报资料</h3>
<el-form label-position="left" label-width="110px" class="order-fields density-form">
<el-form label-position="left" label-width="110px" class="order-fields density-form density-form--outlined">
<div class="workbench-evidence"><table class="density-table density-table--editable order-declaration-fields" aria-label="订单申报资料">
<colgroup><col style="width: 40%"><col style="width: 27%"><col style="width: 33%"></colgroup>
<tbody>
<tr>
<td><el-form-item label="渠道名称" required><el-select v-model="form.channelCode" aria-label="渠道名称" filterable clearable :loading="channelsLoading" placeholder="请选择正式渠道" @change="selectChannel"><el-option v-for="channel in channels" :key="channel.channelCode" :label="`${channel.channelName} · ${channel.channelCode}`" :value="channel.channelCode" /></el-select></el-form-item></td>
<td><el-form-item label="渠道编码"><span aria-label="渠道编码">{{ form.channelCode || '—' }}</span></el-form-item></td>
<td><el-form-item label="预估体积(m³)"><el-input-number v-model="form.estimatedVolumeCbm" :min="0.0001" :precision="4" :controls="false" aria-label="预估体积(m³)" placeholder="请输入预估体积(选填)" /></el-form-item></td>
<td><el-form-item label="预估体积(m³)"><el-input-number v-model="form.estimatedVolumeCbm" :min="0.0001" :precision="4" :controls="false" aria-label="预估体积(m³)" placeholder="选填" /></el-form-item></td>
</tr>
<tr>
<td><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" aria-label="预计到货时间" clearable /></el-form-item></td>
@@ -5,9 +5,10 @@ import { downloadPlatformProductImage, getPlatformUnifiedOrder, listPlatformUnif
import { downloadPlatformInboundHoldEvidence, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from '@/modules/inbounds'
import UnifiedOrdersView from './UnifiedOrdersView.vue'
const { routeQuery, routerPush } = vi.hoisted(() => ({ routeQuery: { tenantId: '6001', from: '' }, routerPush: vi.fn() }))
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { orderId: '5001' }, query: { tenantId: '6001' } }),
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ params: { orderId: '5001' }, query: routeQuery }),
useRouter: () => ({ push: routerPush }),
}))
const { canPermission } = vi.hoisted(() => ({ canPermission: vi.fn((..._permissions: string[]) => true) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
@@ -75,6 +76,7 @@ function mountDetail() {
describe('UnifiedOrdersView downstream fulfillment detail', () => {
beforeEach(() => {
vi.clearAllMocks()
routeQuery.from = ''
canPermission.mockImplementation(() => true)
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue(detail as never)
vi.mocked(downloadPlatformProductImage).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
@@ -107,6 +109,15 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
})
})
it.each(['last-mile-orders', ''])('returns to the originating list from detail: %s', async (from) => {
routeQuery.from = from
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '返回列表')!.trigger('click')
expect(routerPush).toHaveBeenCalledWith({ name: from ? 'overseas-last-mile-orders' : 'unified-orders' })
wrapper.unmount()
})
it('keeps unchanged actual and outer volumes on the same server snapshot with three decimal places', async () => {
const wrapper = mountDetail()
await flushPromises()
@@ -2224,11 +2224,12 @@ async function printSelectedCartonLabels() {
}
}
/** 关闭详情时返回实际业务来源列表。 */
function closeDetail() {
releaseCargoImagePreviews()
detail.value = null
detailError.value = null
void router.push({ name: 'unified-orders' })
void router.push({ name: route.query.from === 'last-mile-orders' ? 'overseas-last-mile-orders' : 'unified-orders' })
}
function returnToMeasurementReviews() {
@@ -345,7 +345,7 @@ onMounted(() => {
</script>
<template>
<AdminPage as="div" class="pallet-page admin-density admin-workbench">
<AdminPage as="div" spacing="compact" class="pallet-page admin-density admin-workbench">
<section class="summary-grid" aria-label="托盘数量概览">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Layers aria-hidden="true" /></span><h2 class="workbench-metric-label">托盘总数</h2></div>
@@ -487,7 +487,8 @@ onMounted(() => {
</template>
<style scoped>
.pallet-page { display: grid; min-width: 0; gap: var(--admin-space-6, 16px); }
.pallet-page { display: grid; min-width: 0; }
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.table-panel { min-width: 0; }
.context-empty { padding: var(--admin-space-6, 16px); color: var(--workbench-muted); text-align: center; }
+9 -2
View File
@@ -423,10 +423,10 @@ describe('DataTable column preferences', () => {
expect(wrapper.get('.data-table__toolbar-end').text()).toBe('辅助操作')
})
it('uses a compact right-aligned toolbar when no leading content is provided', () => {
it('uses a compact toolbar and can join filters without duplicating actions or column settings', async () => {
const wrapper = shallowMount(DataTable, {
props: { data: [], columns: configurableColumns, preferenceKey: 'orders-table', columnSettingsMode: 'show' },
slots: { 'toolbar-actions': '<button>新增订单</button>' },
slots: { 'toolbar-actions': '<button>新增订单</button>', filters: '<input aria-label="搜索" />' },
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
stubs: {
@@ -443,6 +443,13 @@ describe('DataTable column preferences', () => {
expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false)
expect(wrapper.get('.data-table__toolbar-actions').classes()).toContain('has-business-actions')
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
expect(wrapper.find('.data-table__toolbar .data-table__filters').exists()).toBe(false)
await wrapper.setProps({ toolbarPlacement: 'filters' })
expect(wrapper.get('.data-table__toolbar').classes()).toContain('has-inline-filters')
expect(wrapper.get('.data-table__toolbar .data-table__filters input').attributes('aria-label')).toBe('搜索')
expect(wrapper.findAll('.data-table__filters')).toHaveLength(1)
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(1)
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
})
it('keeps business actions in a separate mobile-wrappable group from column settings', () => {
+23 -3
View File
@@ -79,6 +79,7 @@ const props = withDefaults(defineProps<{
maxHeight?: string | number
preferenceKey?: string
columnSettingsMode?: 'auto' | 'show' | 'hide'
toolbarPlacement?: 'top' | 'filters'
border?: boolean
stripe?: boolean
appearance?: 'default' | 'integrated'
@@ -90,6 +91,7 @@ const props = withDefaults(defineProps<{
pageSizes: () => [10, 20, 50, 100],
maxHeight: 'calc(100vh - 300px)',
columnSettingsMode: 'auto',
toolbarPlacement: 'top',
border: true,
stripe: true,
appearance: 'default',
@@ -337,11 +339,12 @@ function setPage(value: number) {
<template>
<div class="data-table" :class="{ 'data-table--integrated': appearance === 'integrated' }">
<div
v-if="$slots['toolbar-start'] || $slots['toolbar-actions'] || preferenceEnabled || $slots['toolbar-end']"
v-if="$slots['toolbar-start'] || $slots['toolbar-actions'] || preferenceEnabled || $slots['toolbar-end'] || (toolbarPlacement === 'filters' && $slots.filters)"
class="data-table__toolbar"
:class="{ 'is-compact': !$slots['toolbar-start'] }"
:class="{ 'is-compact': !$slots['toolbar-start'], 'has-inline-filters': toolbarPlacement === 'filters' && $slots.filters }"
>
<div v-if="$slots['toolbar-start']" class="data-table__toolbar-start"><slot name="toolbar-start" /></div>
<div v-if="toolbarPlacement === 'filters' && $slots.filters" class="data-table__filters"><slot name="filters" /></div>
<div class="data-table__toolbar-actions" :class="{ 'has-business-actions': $slots['toolbar-actions'] }">
<div v-if="$slots['toolbar-actions']" class="data-table__business-actions"><slot name="toolbar-actions" /></div>
<div v-if="preferenceEnabled || $slots['toolbar-end']" class="data-table__utility-actions">
@@ -362,7 +365,7 @@ function setPage(value: number) {
</div>
</div>
</div>
<div v-if="$slots.filters" class="data-table__filters"><slot name="filters" /></div>
<div v-if="$slots.filters && toolbarPlacement !== 'filters'" class="data-table__filters"><slot name="filters" /></div>
<div v-if="hasRegion('notices')" class="data-table__notices"><slot name="notices" /></div>
<div v-if="resultsVisible && hasRegion('before-results')" class="data-table__summary"><slot name="before-results" /></div>
<div v-if="$slots.state" class="data-table__state"><slot name="state" /></div>
@@ -580,4 +583,21 @@ function setPage(value: number) {
.data-table--integrated .data-table__filters :deep(.surface-panel) { border: 0; border-radius: 0; box-shadow: none; }
.data-table--integrated .data-table__filters :deep(.integrated-query-group) { display: grid; min-width: 0; gap: 0; }
.data-table--integrated .data-table__filters :deep(.integrated-query-group > .el-radio-group) { padding: 12px 20px 0; }
/* 筛选与操作共用一行,字段设置靠右,避免独占一行留白。 */
.data-table > .data-table__toolbar.has-inline-filters {
align-items: center;
flex-wrap: nowrap;
min-height: 0;
margin-bottom: var(--cc-list-query-gap, 16px);
padding: 16px 20px;
border-bottom: 1px solid var(--list-card-border, var(--cc-border));
background: var(--list-card-muted-bg, #f7f9fc);
}
.data-table__toolbar.has-inline-filters > .data-table__filters { flex: 1 1 0; min-width: 0; }
.data-table__toolbar.has-inline-filters > .data-table__filters :deep(.query-toolbar) { padding: 0; border: 0; background: transparent; }
.data-table__toolbar.has-inline-filters > .data-table__toolbar-actions { margin-left: auto; }
@media (max-width: 720px) {
.data-table > .data-table__toolbar.has-inline-filters { align-items: stretch; flex-direction: column; }
.data-table__toolbar.has-inline-filters > .data-table__filters { flex-basis: auto; }
}
</style>
+3
View File
@@ -285,6 +285,9 @@
--el-input-hover-border-color: var(--el-color-primary);
--el-input-bg-color: var(--admin-surface);
}
:is(.admin-density, .admin-density-dialog) .density-form--outlined {
--el-border-radius-base: var(--cc-radius-lg);
}
:is(.admin-density, .admin-density-dialog) .density-form--outlined :is(.el-input, .el-textarea) {
--el-input-border-color: var(--el-border-color);
--el-input-hover-border-color: var(--el-border-color-hover);
+20 -8
View File
@@ -47,6 +47,7 @@ const overseasConsumers = [
'overseas-warehouse/components/DeliveryDocumentsDialog.vue',
]
const overseasBusinessConsumers = [
'overseas-business/pages/OverseasFeesView.vue',
'overseas-business/components/TransportDemandForm.vue',
'overseas-business/pages/PurchaseOrdersView.vue', 'overseas-business/pages/PurchaseOrderDetailView.vue',
'overseas-business/pages/PurchaseOrderEditView.vue', 'overseas-business/pages/TransportOrdersView.vue',
@@ -102,6 +103,7 @@ const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/components/InventoryTurnoverView.vue']
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue', 'self-operated/pages/OverviewView.vue']
const consumers = [
'overseas-business/components/OverseasFeeEditor.vue', 'overseas-business/components/StorageRentPanel.vue',
...selfOperatedConsumers,
'overseas-business/components/FleetArrangementSummary.vue',
...notificationConsumers,
@@ -219,12 +221,16 @@ const cssRoot = (css: string) => {
}
describe('approved Admin density ownership', () => {
it('keeps domestic pallet metric appearance owned by shared workbench styles', () => {
const page = parse(read('modules/warehouse-resources/pallets/pages/PalletsView.vue')).descriptor
const css = page.styles.map(style => style.content).join('\n')
expect(page.template!.content).toContain('workbench-metric workbench-tone is-compact')
expect(css).not.toMatch(/\.summary-grid\s+(?:article|span|strong|\.idle|\.occupied)\b/)
expect(css).not.toMatch(/\.workbench-metric[\s.{]/)
it('keeps pallet overview spacing and metric appearance owned by shared primitives', () => {
for (const file of ['warehouse-resources/pallets/pages/PalletsView.vue', 'overseas-warehouse/pages/PalletsView.vue']) {
const page = parse(read(`modules/${file}`)).descriptor
const css = page.styles.map(style => style.content).join('\n')
expect(page.template!.content, file).toMatch(/<AdminPage\b[^>]*spacing="compact"/)
expect(css, file).toContain('.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }')
expect(page.template!.content, file).toContain('workbench-metric workbench-tone is-compact')
expect(css, file).not.toMatch(/\.summary-grid\s+(?:article|span|strong|\.idle|\.occupied)\b/)
expect(css, file).not.toMatch(/\.workbench-metric[\s.{]/)
}
})
it('lets local transport editors fill the workspace and keeps form density in shared primitives', () => {
@@ -308,6 +314,9 @@ describe('approved Admin density ownership', () => {
'--el-input-hover-border-color': 'var(--el-border-color-hover)',
'--el-input-bg-color': 'var(--admin-surface)',
})
expect(rules.get(':is(.admin-density, .admin-density-dialog) .density-form--outlined')).toEqual({
'--el-border-radius-base': 'var(--cc-radius-lg)',
})
const customerRules = new Map<string, Record<string, string>>()
cssRoot(read('modules/customers/customerDensity.css')).walkRules(rule => {
const values: Record<string, string> = {}
@@ -356,10 +365,10 @@ describe('approved Admin density ownership', () => {
})
}
})
it('keeps declaration order fields on the continuous cell-editor baseline', () => {
it('keeps declaration order fields outlined within the continuous cell-editor layout', () => {
const source = read('modules/unified-orders/components/OrderDeclarationEditorDialog.vue')
const orderFields = source.slice(source.indexOf('<el-form label-position="left"'), source.indexOf('</el-form>') + '</el-form>'.length)
expect(orderFields).toContain('class="order-fields density-form"')
expect(orderFields).toContain('class="order-fields density-form density-form--outlined"')
expect(orderFields).toContain('density-table density-table--editable')
expect(orderFields).not.toMatch(/<el-(?:row|col)\b/)
expect(orderFields).toContain('<span aria-label="渠道编码">')
@@ -1247,6 +1256,8 @@ describe('approved Admin density ownership', () => {
it('keeps desktop table toolbar dimensions owned by DataTable without cascade overrides', () => {
const tableCss = parse(read('shared/components/DataTable.vue')).descriptor.styles.map(style => style.content).join('\n')
expect(tableCss).toContain('.data-table > .data-table__toolbar.has-inline-filters')
expect(tableCss).toContain('.data-table__toolbar.has-inline-filters > .data-table__filters { flex: 1 1 0; min-width: 0; }')
const dimensions: Record<string, string[]> = { padding: [], 'min-height': [] }
cssRoot(tableCss).walkRules(rule => {
if (!rule.selectors.includes('.data-table__toolbar') && !rule.selectors.includes('.data-table__toolbar.is-compact')) return
@@ -1353,6 +1364,7 @@ describe('approved Admin density ownership', () => {
expect(source).not.toContain('class="container-preview__metric')
const detail = read('modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')
expect(detail).toContain('density-table density-table--editable fee-editor-table')
expect(detail).toContain('<section class="density-dialog-section" aria-label="尾程费用登记">')
expect(detail).toContain('label-position="left" label-width="110px" :disabled="saving"')
expect(detail).not.toContain('.fee-form-grid')
expect(read('modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue')).toContain('data-tone="source"')