This commit is contained in:
2026-08-18 16:09:43 +08:00
parent f9ae2d94cb
commit 21249d1dde
52 changed files with 980 additions and 698 deletions
@@ -12,6 +12,11 @@ vi.mock('@/shared/api/http', async () => {
describe('inbound fee-detail API', () => {
beforeEach(() => vi.clearAllMocks())
it.each(['', ' ', 'null', 'undefined'])('rejects invalid inboundId %j without issuing HTTP', async (inboundId) => {
await expect(listInboundFeeDetails('100', inboundId)).rejects.toThrow('inboundId is required')
expect(http.get).not.toHaveBeenCalled()
})
it('maps the complete write contract and optimistic version', async () => {
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, settlementIncluded: true, partnerId: '41', partnerName: '测试供应商', remark: '验收' }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
@@ -50,7 +50,11 @@ export interface InboundFeeItemOption {
export interface InboundFeePartnerOption { id: string | number; partnerName: string }
function basePath(inboundId: string) { return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details` }
function basePath(inboundId: string) {
const identifier = String(inboundId ?? '').trim()
if (!identifier || identifier === 'null' || identifier === 'undefined') throw new TypeError('inboundId is required')
return `/platform/v1/inbounds/${encodeURIComponent(identifier)}/fee-details`
}
const config = (accountId: string) => ({ params: { accountId } })
export async function listInboundFeeDetails(accountId: string, inboundId: string) {
@@ -87,6 +87,18 @@ describe('InboundFeeDetailManager', () => {
expect(document.body.textContent).toContain('没有查看费用明细的权限')
})
it.each(['', ' ', 'null', 'undefined'])('does not request fee rows for invalid inboundId %j', async (inboundId) => {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['firstleg:inbound:fee-detail:view']
useAccessPreviewStore().initializeAfterLogin()
mount(InboundFeeDetailManager, {
props: { accountId: '100', inboundId },
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
expect(listInboundFeeDetails).not.toHaveBeenCalled()
})
it('approves a pending fee then refreshes rows and emits changed', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review'])
await flushPromises()
@@ -55,6 +55,10 @@ const rules: FormRules = {
currency: [{ required: true, pattern: /^[A-Z]{3}$/, message: '货币须为三个大写字母', trigger: 'blur' }],
}
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const normalizedInboundId = computed(() => {
const identifier = String(props.inboundId ?? '').trim()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
const canViewPartners = computed(() => access.can('platform:counterparty:list'))
const directionOptions = computed(() => [
@@ -73,10 +77,13 @@ function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED:
function directionLabel(value: string) { return value === 'RECEIVABLE' ? '应收' : '应付' }
async function load() {
if (!canView.value) return
if (!canView.value || !normalizedInboundId.value) {
rows.value = []
return
}
loading.value = true; loadError.value = null
try {
rows.value = await listInboundFeeDetails(props.accountId, props.inboundId)
rows.value = await listInboundFeeDetails(props.accountId, normalizedInboundId.value)
const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' && row.settlementIncluded)
emit('summary', {
currency: approved.find((row) => row.currency)?.currency || 'CNY',
@@ -142,9 +149,9 @@ async function save() {
saving.value = true; actionError.value = null
try {
const payload = normalizePayload()
if (editorMode.value === 'create') await createInboundFeeDetail(props.accountId, props.inboundId, payload)
else if (editorMode.value === 'edit' && target.value) await updateInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), payload)
else if (target.value) await adjustInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), target.value.sourceFeeVersion, payload)
if (editorMode.value === 'create') await createInboundFeeDetail(props.accountId, normalizedInboundId.value, payload)
else if (editorMode.value === 'edit' && target.value) await updateInboundFeeDetail(props.accountId, normalizedInboundId.value, String(target.value.id), payload)
else if (target.value) await adjustInboundFeeDetail(props.accountId, normalizedInboundId.value, String(target.value.id), target.value.sourceFeeVersion, payload)
editorOpen.value = false
await finishAction(editorMode.value === 'adjust' ? '费用调整已提交审核' : '费用明细已保存')
} catch (error) { actionError.value = getApiErrorDetails(error, '费用明细保存失败') }
@@ -154,12 +161,12 @@ async function save() {
async function remove(row: InboundFeeDetail) {
try {
await ElMessageBox.confirm('删除后无法恢复,是否继续?', '删除费用明细', { type: 'warning', confirmButtonText: '删除' })
await deleteInboundFeeDetail(props.accountId, props.inboundId, String(row.id)); await finishAction('费用明细已删除')
await deleteInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id)); await finishAction('费用明细已删除')
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用明细删除失败') }
}
async function approve(row: InboundFeeDetail) {
try { await reviewInboundFeeDetail(props.accountId, props.inboundId, String(row.id), 'approve'); await finishAction('费用审核通过') }
try { await reviewInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id), 'approve'); await finishAction('费用审核通过') }
catch (error) { actionError.value = getApiErrorDetails(error, '费用审核失败') }
}
@@ -167,7 +174,7 @@ function openReject(row: InboundFeeDetail) { target.value = row; rejectReason.va
async function reject() {
if (!target.value || !rejectReason.value.trim()) { actionError.value = { message: '请填写驳回原因' }; return }
saving.value = true
try { await reviewInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), 'reject', rejectReason.value.trim()); rejectOpen.value = false; await finishAction('费用已驳回') }
try { await reviewInboundFeeDetail(props.accountId, normalizedInboundId.value, String(target.value.id), 'reject', rejectReason.value.trim()); rejectOpen.value = false; await finishAction('费用已驳回') }
catch (error) { actionError.value = getApiErrorDetails(error, '费用驳回失败') }
finally { saving.value = false }
}
@@ -175,11 +182,11 @@ async function reject() {
async function voidRow(row: InboundFeeDetail) {
try {
await ElMessageBox.confirm('作废会同步撤销尚未结算的财务费用来源,是否继续?', '作废费用明细', { type: 'warning', confirmButtonText: '作废' })
await voidInboundFeeDetail(props.accountId, props.inboundId, String(row.id), row.sourceFeeVersion); await finishAction('费用明细已作废')
await voidInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id), row.sourceFeeVersion); await finishAction('费用明细已作废')
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用作废失败') }
}
watch(() => [props.accountId, props.inboundId], () => void load())
watch(() => [props.accountId, normalizedInboundId.value], () => void load())
watch(() => form.direction, (direction) => {
if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
else void loadPartners()
@@ -314,6 +314,14 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('firstLegDestinationTypeLabel(detail.order.order?.destinationType)')
})
it('does not mount fee consumers until a real inboundId exists', () => {
const feeSection = source.slice(source.indexOf("activeDetailTab === 'fees'"), source.indexOf("activeDetailTab === 'history'"))
expect(source).toContain('const normalizedInboundId = computed(() => normalizedIdentifier(detail.value?.summary.inboundId))')
expect(feeSection).toContain('v-if="!normalizedInboundId" description="订单尚未生成入库单,暂无入仓费用"')
expect(feeSection.match(/:inbound-id="normalizedInboundId"/g)).toHaveLength(2)
expect(feeSection).not.toContain('String(detail.summary.inboundId)')
})
it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => {
expect(source).toContain('UnifiedOrderWorkflowPanel')
expect(source).toContain("{ key: 'workflow', label: '后半程作业', icon: OrderFulfillmentIcon }")
@@ -151,6 +151,11 @@ const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
function normalizedIdentifier(value: unknown) {
const identifier = String(value ?? '').trim()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
}
const normalizedInboundId = computed(() => normalizedIdentifier(detail.value?.summary.inboundId))
const detailTabs = [
{ key: 'basic', label: '基本信息', icon: OrderBasicInfoIcon },
@@ -1009,6 +1014,8 @@ onBeforeUnmount(() => {
<template v-else-if="activeDetailTab === 'fees'">
<div class="panel-title"><div><h2>费用信息</h2><p>头程自动初算与其他人工费用在同一页维护</p></div><WalletCards :size="19" /></div>
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,暂无入仓费用" :image-size="64" />
<template v-else>
<dl class="fee-overview" aria-label="费用汇总">
<div><dt>对客费用合计</dt><dd>{{ feeMoney(customerFeeTotal) }}</dd><small>已发布头程费用 + 已审核其他应收</small></div>
<div><dt>已审核其他应付</dt><dd>{{ feeMoney(otherFeeSummary.approvedPayable) }}</dd><small>单列展示,不计入对客费用</small></div>
@@ -1016,7 +1023,7 @@ onBeforeUnmount(() => {
<h3 class="fee-section-title">头程自动初算</h3>
<InboundFeePanel
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:inbound-id="normalizedInboundId"
compact
@loaded="automaticFee = $event"
@changed="loadDetail(true)"
@@ -1024,10 +1031,11 @@ onBeforeUnmount(() => {
<h3 class="fee-section-title fee-section-title--secondary">其他费用明细</h3>
<InboundFeeDetailManager
:account-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:inbound-id="normalizedInboundId"
@summary="otherFeeSummary = $event"
@changed="loadDetail(true)"
/>
</template>
</template>
<template v-else-if="activeDetailTab === 'history'">