This commit is contained in:
2026-08-15 10:03:03 +08:00
parent fc8480426d
commit c464f47fb9
6 changed files with 46 additions and 21 deletions
@@ -61,7 +61,7 @@ const columns: DataTableColumn[] = [
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170 },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
]
const ledgerQueryFields: QueryField[] = [
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
@@ -167,15 +167,32 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail')
})
it('resolves blank detail customer names from master data and preserves unknown source codes', async () => {
it('resolves blank detail customer names and keeps source labels business-facing', async () => {
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName: '', sourceType: 'NEW_SOURCE' })
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
expect(wrapper.text()).toContain('运费 · 客户A')
expect(wrapper.text()).toContain('未识别来源(NEW_SOURCE)')
expect(wrapper.text()).toContain('其他费用来源')
expect(wrapper.text()).not.toContain('其他来源')
expect(wrapper.text()).not.toContain('NEW_SOURCE')
expect(wrapper.text()).not.toContain('42 ·')
})
it('hides technical source identifiers and versions from list rows', async () => {
vi.mocked(listFinanceFeePool).mockResolvedValue([
{ poolItemId: 'PAYABLE:358', feeSourceId: '358', customerId: '10001', direction: 'PAYABLE', sourceType: 'CONTAINER_FEE', sourceId: '2088285842638942210', sourceVersion: 1, orderId: null, inboundId: null, containerId: '2088285834627821569', feeItemName: '吊机费', amount: 300, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-14T23:25:19.684' },
])
const wrapper = await mountView(['platform:finance:customer-arap:list'])
await (wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }).load({ customerId: '', direction: '', status: '', keyword: '' })
const text = wrapper.text()
expect(text).toContain('应付费用 · 吊机费')
expect(text).toContain('柜次费用 · 关联柜次')
expect(text).toContain('2026/8/14 23:25:19')
expect(text).not.toContain('PAYABLE:358')
expect(text).not.toContain('2088285842638942210')
expect(text).not.toContain('/v1')
expect(text).not.toContain('CONTAINER_FEE')
})
it.each(['10001', '客户 10001', '客户10001'])('never displays customer id placeholder %s as a customer name', async (customerName) => {
vi.mocked(listPlatformTenants).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName })
@@ -89,9 +89,9 @@ const queryFields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: (row: ArapRow) => row.direction === 'RECEIVABLE' && Boolean(row.feeSourceId) }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'settlementIncluded', label: '参与结算', width: 110, slotName: 'included' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 130 },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'settlementIncluded', label: '参与结算', width: 110, slotName: 'included' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
]
function customerName(customerId: string, snapshot?: string) {
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
@@ -109,9 +109,15 @@ function sourceTypeDisplay(value: unknown) {
}
function feeDocumentLabel(direction: ArapRow['direction'], feeName: unknown) {
const prefix = direction === 'RECEIVABLE' ? '应收费用' : '应付费用'
const name = String(feeName || '').trim()
const name = displayFeeName(feeName)
return name ? `${prefix} · ${name}` : prefix
}
function displayFeeName(value: unknown) {
const name = String(value || '').trim()
if (!name) return ''
if (/^ADMIN-API-\d+-整柜成本$/i.test(name)) return '整柜成本'
return name
}
function sourceSummary(sourceType: unknown, orderId: unknown, inboundId: unknown, containerId: unknown) {
const relations = [inboundId ? '关联入库单' : '', orderId ? '关联订单' : '', containerId ? '关联柜次' : ''].filter(Boolean)
const type = sourceTypeDisplay(sourceType)
@@ -150,7 +156,7 @@ async function load(model: QueryModel = filters.value) {
try {
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const items = await listFinanceFeePool(customerId || undefined, direction || undefined)
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
}
@@ -217,7 +223,7 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
try {
detail.value = await getFinanceFeeSource(feeSourceId)
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: detail.value.feeItemName || detail.value.sourceId || detail.value.poolItemId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', feeName: detail.value.feeItemName, amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
finally { detailLoading.value = false }
}
@@ -257,7 +263,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联单据">{{ detail.inboundCode || detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联单据">{{ detail.inboundCode || detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
</template>
</AdminPage>
@@ -185,6 +185,7 @@ describe('finance G3/G4 page operations', () => {
expect(wrapper.emitted('openDetail')).toEqual([['801']])
expect(getFinanceSettlement).not.toHaveBeenCalled()
expect(vm.customerName('2088', '上海客户有限公司')).toBe('上海客户有限公司')
expect(vm.customerName('2088', '客户2088')).toBe('—')
expect(vm.customerName('2088')).toBe('—')
})
@@ -3,13 +3,18 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/routes.ts'), 'utf8')
describe('OceanBookingDetailView interaction contract', () => {
it('recognizes the dedicated create route as an editable new booking', () => {
expect(source).toContain("route.name === 'ocean-booking-create' || id.value === 'new'")
expect(source).toContain('const editable = computed(() => isNew.value)')
expect(source).toContain("const canSave = computed(() => isNew.value && access.can('platform:firstleg:ocean-booking:create'))")
expect(source).toContain(':disabled="!editable"')
expect(source).not.toContain("status === 'DRAFT'")
expect(source).toContain("? access.can('platform:firstleg:ocean-booking:create')")
expect(routesSource.indexOf("path: 'ocean-bookings/new'")).toBeLessThan(routesSource.indexOf("path: 'ocean-bookings/:bookingId'"))
expect(routesSource).toContain("name: 'ocean-booking-create'")
expect(routesSource).toContain("permission: 'platform:firstleg:ocean-booking:create'")
})
it('selects business objects and automatically keeps their ids and names together', () => {
@@ -73,7 +78,7 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('v-model="releaseReason"')
expect(source).not.toContain('v-model="releaseForm.')
expect(source).toContain('线下订舱资料已登记并确认')
expect(source).toContain("{{ isNew ? '登记订舱' : '保存' }}")
expect(source).toContain('<Save :size="16" />登记订舱')
expect(source).toContain('登记后直接确认')
})
@@ -111,9 +111,7 @@ const rules: FormRules = {
freightTerm: [{ required: true, message: '请输入运费条款 Freight Term', trigger: 'blur' }],
}
const editable = computed(() => isNew.value)
const canSave = computed(() => isNew.value
? access.can('platform:firstleg:ocean-booking:create')
: editable.value && access.can('platform:firstleg:ocean-booking:edit'))
const canSave = computed(() => isNew.value && access.can('platform:firstleg:ocean-booking:create'))
const availableActions = computed(() => (actionsByStatus[detail.value?.status || 'CONFIRMED'] || [])
.filter((action) => access.can(actionPermissions[action])))
@@ -245,12 +243,10 @@ async function save() {
actionError.value = null
try {
const payload = { ...form.data }
if (isNew.value) {
const created = await createOceanBooking({ expectedVersion: 0, data: payload })
ElMessage.success('线下订舱资料已登记并确认')
await router.replace(`/ocean-bookings/${created.id}`)
applyDetail(created)
}
const created = await createOceanBooking({ expectedVersion: 0, data: payload })
ElMessage.success('线下订舱资料已登记并确认')
await router.replace(`/ocean-bookings/${created.id}`)
applyDetail(created)
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { saving.value = false }
}
@@ -362,7 +358,7 @@ onMounted(() => { void load(); void loadOptions() })
<div><h2>订舱信息</h2><p v-if="detail && !editable">已确认订舱资料仅可查看,船期变更请使用“申请改船”。</p></div>
<div class="actions">
<el-button v-for="item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)">{{ actionLabels[item] }}</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />{{ isNew ? '登记订舱' : '保存' }}</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />登记订舱</el-button>
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>