From 653ce1c511f72902f57f62365653c0a90a04bd0f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 22:03:03 +0800 Subject: [PATCH] feat: separate container fee workbenches --- .../api/overseasContainerFees.test.ts | 25 ++++ .../api/overseasContainerFees.ts | 66 +++++++++ .../components/ContainerLastLegFeesPanel.vue | 140 ++++++++++++++++++ .../pages/ContainerDetailView.test.ts | 25 +++- .../pages/ContainerDetailView.vue | 52 +++++-- .../loading-lists/api/containerFees.test.ts | 10 +- .../loading-lists/api/containerFees.ts | 16 +- .../components/ContainerFeesPanel.test.ts | 14 +- .../components/ContainerFeesPanel.vue | 7 +- .../components/LoadingListDetailDrawer.vue | 16 -- .../components/LoadingListsView.test.ts | 2 +- 11 files changed, 325 insertions(+), 48 deletions(-) create mode 100644 src/modules/container-loading/api/overseasContainerFees.test.ts create mode 100644 src/modules/container-loading/api/overseasContainerFees.ts create mode 100644 src/modules/container-loading/components/ContainerLastLegFeesPanel.vue diff --git a/src/modules/container-loading/api/overseasContainerFees.test.ts b/src/modules/container-loading/api/overseasContainerFees.test.ts new file mode 100644 index 00000000..c881481a --- /dev/null +++ b/src/modules/container-loading/api/overseasContainerFees.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { overseasHttp } from '@/shared/api/overseasHttp' +import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerFeeSaveRequest } from './overseasContainerFees' + +const raw = { id: 91, sourceFeeId: 81, sourceVersion: 2, customerId: 11, customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: 99, businessNo: 'CONT-99', containerBookingId: 99, containerNo: 'CONT-99', feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', amount: 300, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'ACTIVE', businessDate: '2026-09-08' } +const fee: OverseasContainerFeeSaveRequest = { customerId: '11', customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: '99', businessNo: 'CONT-99', orderId: null, inboundId: null, inboundCode: null, containerId: null, containerBookingId: '99', containerNo: 'CONT-99', warehouseId: null, feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', supplierId: '71', supplierName: '港口服务商', supplierCategory: 'TRUCKING', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: '2026-09-08', currency: 'CNY', amount: 300 } +describe('overseas container fees API', () => { + beforeEach(() => vi.restoreAllMocks()) + it('loads all fees and customers with two container aggregate requests', async () => { + const get = vi.spyOn(overseasHttp, 'get').mockResolvedValueOnce({ code: '200', data: [raw] }).mockResolvedValueOnce({ code: '200', data: [{ customerId: 11, customerName: '客户A' }] }) + await expect(listOverseasContainerFees('99')).resolves.toMatchObject([{ sourceFeeId: '81', containerBookingId: '99', feeItemCode: 'PORT_FEE' }]) + await expect(listOverseasContainerCustomers('99')).resolves.toEqual([{ customerId: '11', customerName: '客户A' }]) + expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/finance-fee-sources', { params: { containerBookingId: '99' } }) + expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/finance-fee-sources/container-customers', { params: { containerBookingId: '99' } }) + }) + it('uses the exact create and lifecycle action contracts', async () => { + const post = vi.spyOn(overseasHttp, 'post').mockResolvedValue({ code: '200', data: raw }) + await createOverseasContainerFee(fee, 'create-1'); await submitOverseasContainerFee('81', 'submit-1'); await reviewOverseasContainerFee('81', 'APPROVE', null, 'review-1'); await adjustOverseasContainerFee('81', 2, fee, 'adjust-1'); await voidOverseasContainerFee('81', 2, '重复费用', 'void-1') + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources', fee, { headers: { 'Idempotency-Key': 'create-1' } }) + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/submit', { idempotencyKey: 'submit-1' }) + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/review', { decision: 'APPROVE', reason: null, idempotencyKey: 'review-1' }) + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/adjust', { expectedVersion: 2, fee, idempotencyKey: 'adjust-1' }) + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/void', { expectedVersion: 2, reason: '重复费用', idempotencyKey: 'void-1' }) + }) +}) diff --git a/src/modules/container-loading/api/overseasContainerFees.ts b/src/modules/container-loading/api/overseasContainerFees.ts new file mode 100644 index 00000000..c58769bc --- /dev/null +++ b/src/modules/container-loading/api/overseasContainerFees.ts @@ -0,0 +1,66 @@ +import { overseasHttp } from '@/shared/api/overseasHttp' +import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +const basePath = '/platform/v1/last-leg/finance-fee-sources' +type RawResource = Record + +export type OverseasContainerFeeItem = 'PORT_FEE' | 'STORAGE_FEE' | 'CONTAINER_REPAIR_FEE' | 'CRANE_FEE' | 'TERMINAL_DRAYAGE_FEE' | 'OTHER' +export type OverseasContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE' + +export interface OverseasContainerCustomer { customerId: string; customerName: string | null } +export interface OverseasContainerFeeSource { + id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string | null + sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER'; businessId: string; businessNo: string + containerBookingId: string; containerNo: string; feeItemCode: OverseasContainerFeeItem; feeItemName: string + direction: OverseasContainerFeeDirection; supplierId: string | null; supplierName: string | null; supplierCategory: string | null + settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null + paymentVoucherFileIds: string[]; businessDate: string; currency: 'CNY'; amount: number + reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'; sourceStatus: 'ACTIVE' | 'SUPERSEDED' | 'VOIDED' + reviewReason: string | null; reviewedAt: string | null +} +export interface OverseasContainerFeeSaveRequest { + customerId: string; customerName: string | null; sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER' + businessId: string; businessNo: string; orderId: null; inboundId: null; inboundCode: null; containerId: null + containerBookingId: string; containerNo: string; warehouseId: null; feeItemCode: OverseasContainerFeeItem; feeItemName: string + direction: OverseasContainerFeeDirection; supplierId: string | null; supplierName: string | null; supplierCategory: string | null + settlementMode: 'ON_ACCOUNT' | 'DIRECT_PAID' | null; paidAt: string | null + paymentMethod: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER' | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null + businessDate: string; currency: 'CNY'; amount: number +} + +const text = (value: unknown) => value == null || value === '' ? null : String(value) +const requiredText = (value: unknown) => String(value ?? '') +const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0 +function normalizeFee(raw: RawResource): OverseasContainerFeeSource { + return { + id: requiredText(raw.id), sourceFeeId: requiredText(raw.sourceFeeId), sourceVersion: numberValue(raw.sourceVersion), customerId: requiredText(raw.customerId), customerName: text(raw.customerName), + sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: requiredText(raw.businessId), businessNo: requiredText(raw.businessNo), containerBookingId: requiredText(raw.containerBookingId), containerNo: requiredText(raw.containerNo), + feeItemCode: raw.feeItemCode as OverseasContainerFeeItem, feeItemName: requiredText(raw.feeItemName), direction: raw.direction as OverseasContainerFeeDirection, + supplierId: text(raw.supplierId), supplierName: text(raw.supplierName), supplierCategory: text(raw.supplierCategory), settlementMode: text(raw.settlementMode), paidAt: text(raw.paidAt), paymentMethod: text(raw.paymentMethod), paymentReferenceNo: text(raw.paymentReferenceNo), + paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(String) : [], businessDate: requiredText(raw.businessDate), currency: 'CNY', amount: numberValue(raw.amount), + reviewStatus: raw.reviewStatus as OverseasContainerFeeSource['reviewStatus'], sourceStatus: raw.sourceStatus as OverseasContainerFeeSource['sourceStatus'], reviewReason: text(raw.reviewReason), reviewedAt: text(raw.reviewedAt), + } +} +export async function listOverseasContainerFees(containerBookingId: string) { + const response = await overseasHttp.get, ApiEnvelope>(basePath, { params: { containerBookingId } }) + return unwrapApiResponse(response).map(normalizeFee) +} +export async function listOverseasContainerCustomers(containerBookingId: string) { + const response = await overseasHttp.get>, ApiEnvelope>>(`${basePath}/container-customers`, { params: { containerBookingId } }) + return unwrapApiResponse(response).map((item) => ({ customerId: requiredText(item.customerId), customerName: text(item.customerName) })) +} +export async function createOverseasContainerFee(payload: OverseasContainerFeeSaveRequest, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey } }))) +} +export async function submitOverseasContainerFee(sourceFeeId: string, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey }))) +} +export async function reviewOverseasContainerFee(sourceFeeId: string, decision: 'APPROVE' | 'REJECT', reason: string | null, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/review`, { decision, reason, idempotencyKey }))) +} +export async function adjustOverseasContainerFee(sourceFeeId: string, expectedVersion: number, fee: OverseasContainerFeeSaveRequest, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/adjust`, { expectedVersion, fee, idempotencyKey }))) +} +export async function voidOverseasContainerFee(sourceFeeId: string, expectedVersion: number, reason: string, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/void`, { expectedVersion, reason, idempotencyKey }))) +} diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue new file mode 100644 index 00000000..680ed575 --- /dev/null +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 51f3ff81..6686cbda 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -7,6 +7,7 @@ const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/containe const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8') const loadingRouteSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8') const navigationSource = readFileSync(resolve(process.cwd(), 'src/core/navigation/adminNavigation.ts'), 'utf8') +const lastLegFeesSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerLastLegFeesPanel.vue'), 'utf8') describe('ContainerDetailView customs integration', () => { it('renders the requested detail sections in the confirmed business order', () => { @@ -41,13 +42,35 @@ describe('ContainerDetailView customs integration', () => { expect(routeSource).toContain("name: 'overseas-container-detail'") expect(loadingRouteSource).not.toContain("name: 'loading-lists'") expect(navigationSource).not.toContain("title: '装柜清单'") - expect(source).toContain(' { + expect(source).toContain('label="头程计费" name="first-leg-fees"') + expect(source).toContain('label="尾程计费" name="last-leg-fees"') + expect(source).toContain('v-if="isDomestic && firstLegFeesOpened"') + expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"') + expect(source).toContain('title="头程计费" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"') + expect(source).toContain('v-if="isOverseas && lastLegFeesOpened"') + expect(source).toContain('') + expect(source).toContain("if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)") + expect(source).toContain("tabName === 'loading-list' || tabName === 'first-leg-fees'") + expect(lastLegFeesSource.indexOf('label="操作"')).toBeLessThan(lastLegFeesSource.indexOf('label="客户"')) + expect(lastLegFeesSource).toContain('show-overflow-tooltip') + expect(lastLegFeesSource).toContain('尾程计费加载失败') + expect(lastLegFeesSource).toContain('empty-text="当前货柜暂无尾程费用"') + expect(lastLegFeesSource).toContain('v-loading="loading"') + expect(lastLegFeesSource).toContain('Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), listContainerFeeSupplierOptions(null)])') + expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'") + expect(lastLegFeesSource).toContain('uploadContainerFeePaymentVoucher(form.customerId, file.raw)') + expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:edit')") + expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:review')") + }) + it('mounts the new container customs panel only after loading completes', () => { expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms") expect(source).toContain(' String(route.params.containerId || '')) const isDomestic = computed(() => props.workbench === 'DOMESTIC') const isOverseas = computed(() => props.workbench === 'OVERSEAS') const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview') +const firstLegFeesOpened = ref(false) +const lastLegFeesOpened = ref(false) +const feeCustomers = computed(() => { + const customers = new Map() + for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName) + return [...customers].map(([accountId, customerName]) => ({ accountId, customerName })) +}) +const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({ + accountId: item.accountId, customerName: item.customerName, orderId: item.orderId, orderNo: item.orderNo, + inboundId: item.inboundId, inboundCode: item.inboundCode, +}))) +async function rememberWorkbenchTab(tabName: string | number) { + if (tabName === 'first-leg-fees') firstLegFeesOpened.value = true + if (tabName === 'last-leg-fees') lastLegFeesOpened.value = true + if (isDomestic.value && (tabName === 'loading-list' || tabName === 'first-leg-fees') && detail.value && overview.value) { + await loadLoadingDetail(detail.value, overview.value) + } +} const workbenchDetail = ref(null) const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action)) const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER')) @@ -90,6 +109,7 @@ const outboundError = ref(null) const showPassedGates = ref(false) const carrierRefreshKey = ref(0) const loadingDetail = ref(null) +const loadingDetailLoading = ref(false) const loadingDetailError = ref(null) const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) @@ -173,7 +193,7 @@ async function load() { const [resource, aggregate] = await Promise.all([getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)]) workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate if (activeTab.value === 'loading-list' && resource.planStatus !== 'COMPLETED') activeTab.value = 'overview' - if (isDomestic.value) await loadLoadingDetail(resource, aggregate) + if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate) if (isDomestic.value && resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch() } catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') } finally { loading.value = false } @@ -188,8 +208,10 @@ async function loadOutboundDispatch() { } async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) { + if (loadingDetail.value || loadingDetailLoading.value) return loadingDetail.value = null; loadingDetailError.value = null if (!isDomestic.value || !aggregate.loadingPlanId || !resource.warehouseId) return + loadingDetailLoading.value = true try { if (resource.planStatus === 'COMPLETED') { if (!canViewLoadingList.value) return @@ -200,6 +222,8 @@ async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId) } catch (cause) { loadingDetailError.value = getApiErrorDetails(cause, '装柜清单加载失败') + } finally { + loadingDetailLoading.value = false } } async function refreshAfterCustoms() { @@ -383,21 +407,21 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action diff --git a/src/modules/loading-lists/api/containerFees.test.ts b/src/modules/loading-lists/api/containerFees.test.ts index fd62af8c..586f080f 100644 --- a/src/modules/loading-lists/api/containerFees.test.ts +++ b/src/modules/loading-lists/api/containerFees.test.ts @@ -72,17 +72,17 @@ describe('container fee API', () => { it('maps settled fees and allocation rows from one container aggregate request', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { - customerId: 11, containerId: 99, settledSourceFeeIds: [7], - costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }], + customerId: 11, containerBookingId: 99, settledSourceFeeIds: [7], + costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerBookingId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }], } }) const result = await getContainerSettlementDetails('11', '99') - expect(result).toMatchObject({ customerId: '11', containerId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] }) - expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerId: '99' } }) + expect(result).toMatchObject({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', containerBookingId: '99', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] }) + expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerBookingId: '99' } }) expect(get).toHaveBeenCalledTimes(1) }) it('fails closed when the Finance aggregate does not echo the selected customer and container', async () => { - vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerId: 99, settledSourceFeeIds: [], costs: [] } }) + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerBookingId: 99, settledSourceFeeIds: [], costs: [] } }) await expect(getContainerSettlementDetails('11', '99')).rejects.toThrow('Finance 聚合结果与柜次或客户上下文不一致') }) }) diff --git a/src/modules/loading-lists/api/containerFees.ts b/src/modules/loading-lists/api/containerFees.ts index e5c648c5..5d16c89d 100644 --- a/src/modules/loading-lists/api/containerFees.ts +++ b/src/modules/loading-lists/api/containerFees.ts @@ -118,7 +118,7 @@ export interface ContainerCostDetail { costOrigin: string sourceFeeId: string | null sourceFeeVersion: number - containerId: string | null + containerBookingId: string | null supplierName: string | null feeItemName: string amount: number @@ -129,7 +129,7 @@ export interface ContainerCostDetail { export interface ContainerSettlementDetails { customerId: string - containerId: string + containerBookingId: string settledSourceFeeIds: string[] costs: ContainerCostDetail[] } @@ -243,17 +243,17 @@ export async function uploadContainerFeePaymentVoucher(customerId: string, file: return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) } } -export async function getContainerSettlementDetails(customerId: string, containerId: string): Promise { - const response = await http.get>, ApiEnvelope>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerId } }) +export async function getContainerSettlementDetails(customerId: string, containerBookingId: string): Promise { + const response = await http.get>, ApiEnvelope>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerBookingId } }) const raw = unwrapApiResponse(response) - if (id(raw.customerId) !== customerId || id(raw.containerId) !== containerId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致') + if (id(raw.customerId) !== customerId || id(raw.containerBookingId) !== containerBookingId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致') const costs = (Array.isArray(raw.costs) ? raw.costs : []).map((value) => { const cost = value as Record - if (id(cost.containerId) !== containerId) throw new Error('Finance 聚合成本与柜次上下文不一致') + if (id(cost.containerBookingId) !== containerBookingId) throw new Error('Finance 聚合成本与柜次上下文不一致') return { id: id(cost.costId), costNo: String(cost.costNo || ''), costType: String(cost.costType || ''), costOrigin: String(cost.costOrigin || ''), sourceFeeId: cost.sourceFeeId == null ? null : id(cost.sourceFeeId), sourceFeeVersion: numberValue(cost.sourceFeeVersion), - containerId: cost.containerId == null ? null : id(cost.containerId), supplierName: cost.supplierName == null ? null : String(cost.supplierName), + containerBookingId: cost.containerBookingId == null ? null : id(cost.containerBookingId), supplierName: cost.supplierName == null ? null : String(cost.supplierName), feeItemName: String(cost.feeItemName || ''), amount: numberValue(cost.amount), currency: String(cost.currency || ''), status: String(cost.status || ''), allocations: (Array.isArray(cost.allocations) ? cost.allocations : []).map((value) => { const allocation = value as Record @@ -261,5 +261,5 @@ export async function getContainerSettlementDetails(customerId: string, containe }), } }) - return { customerId, containerId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs } + return { customerId, containerBookingId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs } } diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index 2023ee3b..000e4e73 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -16,6 +16,7 @@ vi.mock('../api/containerFees', async (importOriginal) => { const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8') const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8') +const containerDetail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8') class ResizeObserverStub { observe() {}; unobserve() {}; disconnect() {} } function memoryStorage(): Storage { const values = new Map(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } } @@ -31,7 +32,7 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()) vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([]) vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([]) - vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset() + vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset() vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never) vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' }) document.body.innerHTML = '' @@ -45,9 +46,12 @@ describe('ContainerFeesPanel contract', () => { expect(panel).toContain('.fees-panel :deep(.data-table__toolbar) { min-height: 48px;') }) - it('is exposed from loading-list detail and derives customer context from real items', () => { - expect(detail).toContain(':container-booking-id="summary.containerBookingId"') - expect(detail).toContain('item.accountId') + it('is exposed from the domestic container fee tab and derives customer context from real items', () => { + expect(detail).not.toContain('ContainerFeesPanel') + expect(containerDetail).toContain('title="头程计费"') + expect(containerDetail).toContain(':container-booking-id="detail.id"') + expect(containerDetail).toContain(':plan-id="overview.loadingPlanId"') + expect(containerDetail).toContain('item.accountId') expect(panel).toContain('该柜次包含多个客户,请先选择费用归属客户') expect(panel).toContain('装柜明细缺少客户归属,无法安全维护费用') }) @@ -154,7 +158,7 @@ describe('ContainerFeesPanel contract', () => { vi.mocked(listContainerFees).mockResolvedValue([ { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, ]) - vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: ['1'], costs: [] }) + vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['1'], costs: [] }) const wrapper = await mountPanel([ 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review', ]) diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index 325a2291..3755597c 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -11,12 +11,13 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' -const props = defineProps<{ +const props = withDefaults(defineProps<{ + title?: string planId: string containerBookingId: string customers: Array<{ accountId: string; customerName: string | null }> businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }> -}>() +}>(), { title: '柜次费用' }) const access = useAccessPreviewStore() const accountId = ref('') const fees = ref([]) @@ -350,7 +351,7 @@ onMounted(() => void load())