import { flushPromises, shallowMount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations' import { downloadPlatformProductImage, getPlatformUnifiedOrder, listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders' import { adjustPublishedInboundFee, calculateInboundFee, confirmInboundFee, downloadPlatformInboundHoldEvidence, getInboundMeasurement, getLatestInboundFee, getPlatformInboundHold, holdPlatformInbound, previewInboundFee, releasePlatformInbound, sendInboundFee, updateMeasurementOuterDimensions, uploadPlatformInboundHoldEvidence, type InboundFeePreview } 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: routeQuery }), useRouter: () => ({ push: routerPush }), })) const { canPermission } = vi.hoisted(() => ({ canPermission: vi.fn((..._permissions: string[]) => true) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) })) vi.mock('@/modules/iam', async importOriginal => ({ ...await importOriginal(), listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) })) vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) })) vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(), updateMeasurementOuterDimensions: vi.fn(), adjustPublishedInboundFee: vi.fn(), calculateInboundFee: vi.fn(), confirmInboundFee: vi.fn(), sendInboundFee: vi.fn(), getLatestInboundFee: vi.fn(), previewInboundFee: vi.fn(), createFeeActionIdempotencyKey: vi.fn((action: string) => `fee-${action}`), createFeeAdjustmentIdempotencyKey: vi.fn(() => 'fee-adjust'), downloadPlatformInboundHoldEvidence: vi.fn(), getPlatformInboundHold: vi.fn(), holdPlatformInbound: vi.fn(), releasePlatformInbound: vi.fn(), uploadPlatformInboundHoldEvidence: vi.fn(), InboundFeePanel: { template: '
' }, InboundMeasurementDialog: { template: '
' }, MeasurementEvidenceThumbnail: { template: '
' }, })) vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '
' } })) vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({ createPlatformCoLoadGroup: vi.fn(), getPlatformCoLoadGroupByInboundCode: vi.fn(), })) vi.mock('@/shared/api/inboundCodeRelations', () => ({ getLoadingAssociationSummary: vi.fn(), getOverseasInboundCodeRelationship: vi.fn(), })) vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(), downloadPlatformProductImage: vi.fn(), getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), getPlatformUnifiedOrderStatusCounts: vi.fn().mockResolvedValue({ counts: {} }), listPlatformUnifiedOrders: vi.fn(), unifiedOrderStatusOptions: [], })) const detail = { summary: { orderId: '5001', tenantId: '6001', orderNo: 'ORD-HIDDEN', mainStatus: 'WAIT_SPLIT', nextAction: null, relation: null, internalOrderStatus: 'WAIT_SPLIT', customerId: '6001', customerName: '测试客户', channelName: '纽约渠道', receivingWarehouseId: '8', receivingWarehouseName: '义乌仓', destinationWarehouseId: '9', destinationWarehouseName: 'ONT8', inboundId: '7001', inboundVersion: 1, inboundCode: 'NYEXX2608310001', expectedShipDate: null, totalPackageCount: 2, holdFlag: false, currentHoldReason: null, locationCodes: null, actualGrossWeightKg: null, actualVolumeCbm: null, measurement: null, preload: null, loading: null, delivery: null, transport: null, coLoadGroup: null, createdAt: null, updatedAt: null, expectedArrivalAt: null, feeReviewStatus: null, requestedPickupAt: null, totalPalletCount: null, chargeableVolumeCbm: null, volumetricWeightKg: null, totalNetWeightKg: null, measurementCompletedAt: null, inboundCompletedAt: null, expressCompany: null, expressTrackingNo: null, skuNos: null, totalQuantity: null, shipmentId: null, referenceId: null, }, order: { order: null, cargoes: [], parties: [], internalNotes: [], statusHistory: [] }, timeline: [], } function mountDetail() { return shallowMount(UnifiedOrdersView, { global: { directives: { loading: () => undefined }, stubs: { AdminPage: { template: '
' }, AppDialog: { props: ['modelValue'], template: '
' }, DetailPageLayout: { template: '
' }, OrderFulfillmentTreePanel: { template: '
' }, LoadErrorState: { props: ['title', 'message'], template: '
{{ title }} {{ message }}
' }, ApiErrorMeta: true, StatusBadge: true, 'el-button': { template: '' }, 'el-table': { props: ['data'], template: '
' }, 'el-table-column': { template: '
' }, 'el-tag': { template: '' }, 'el-empty': { props: ['description'], template: '
{{ description }}
' }, 'el-alert': { props: ['title'], template: '
{{ title }}
' }, }, }, }) } function feePreview(overrides: Partial = {}): InboundFeePreview { return { actualWeightKg: 570.5, actualVolumeCbm: 3.894, chargeableQuantity: 650.298, chargeUnit: 'KG', conversionFactor: 167, roundingScale: 6, roundingMode: 'HALF_UP', unitPrice: 13.6, additionalAmount: 0, totalAmount: 8844.05, currency: 'CNY', priceMatched: true, ...overrides } } function savedOuterMeasurement() { return { actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894, items: [{ id: '9001', itemNo: 'B1', packageCount: 22, lengthCm: 62, widthCm: 52, heightCm: 52, outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53, calculatedOuterLengthCm: 63, calculatedOuterWidthCm: 53, calculatedOuterHeightCm: 53, unitVolumeCbm: 0.177, totalVolumeCbm: 3.894, volumeWeightKg: 650.298, coefficientMode: 'ACTUAL', calculationRuleVersion: 'WAREHOUSE_OUTER_OVERRIDE_V1' }] } } type FeeConfirmVm = { measurementDetail: Record measurementDimensionConfig: Record measurementSectionModes: { outer: string; actual: string } feeConfirmVolume: number feeConfirmWeight: number feeConfirmUnitPrice: number | null feeConfirmPriceReason: string feeConfirmPriceChanged: boolean feeConfirmReference: InboundFeePreview | null feeConfirmReferenceLoading: boolean feeConfirmEstimatedTotal: number | null feeConfirmRows: { source: string; volume: number }[] openFeeConfirmDialog(): Promise refreshFeeConfirmPreview(initializePrice?: boolean): Promise executeConfirmAndSendFee(): Promise } describe('UnifiedOrdersView downstream fulfillment detail', () => { afterEach(() => vi.useRealTimers()) beforeEach(() => { vi.clearAllMocks() vi.mocked(previewInboundFee).mockReset().mockResolvedValue(feePreview()) routeQuery.from = '' canPermission.mockImplementation(() => true) vi.mocked(getPlatformUnifiedOrder).mockResolvedValue(detail as never) vi.mocked(downloadPlatformProductImage).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }) vi.mocked(uploadPlatformInboundHoldEvidence).mockResolvedValue({ fileId: '9007199254740999', fileName: '扣货说明.pdf', contentType: 'application/pdf', sizeBytes: 8, }) vi.mocked(downloadPlatformInboundHoldEvidence).mockResolvedValue(new Blob(['evidence'], { type: 'application/pdf' })) vi.mocked(getPlatformInboundHold).mockResolvedValue({ inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3, canHold: true, canRelease: false, actionBlockReason: null, history: [{ id: '8801', action: 'RELEASE', reason: '货款已核对', evidenceFileId: '9901', operatorName: '操作员A', operatedAt: '2026-09-07T10:00:00' }], }) vi.mocked(holdPlatformInbound).mockResolvedValue({ inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 4, canHold: false, canRelease: true, actionBlockReason: null, history: [], }) vi.mocked(releasePlatformInbound).mockResolvedValue({ inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 4, canHold: true, canRelease: false, actionBlockReason: null, history: [], }) vi.mocked(getLoadingAssociationSummary).mockResolvedValue({ inboundId: '7001', inboundCode: 'NYEXX2608310001', loadingPlanId: null, loadingPlanNo: null, containerBookingId: null, cabinetNo: null, containerNo: null, loadingStatus: null, taskId: null, pdaTaskStatus: null, coLoadGroupId: null, coLoadGroupNo: null, items: [], }) vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValue({ inboundCode: 'NYEXX2608310001', found: false, customer: null, overseasWarehouse: null, overseasReceivingStatus: null, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [], }) }) 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('shows actual and outer unit volumes and totals consistently with four decimal places', async () => { const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as { measurementDetail: Record measurementSectionModes: { outer: string; actual: string } outerMeasurementRows: Record[] actualMeasurementRows: Record[] measurementTotals(rows: Record[]): { volume: number; weightVolume: number } measurementValue(row: Record, key: string): string formatMeasurementVolume(value: number | null): string } const items = [ { lengthCm: 60, widthCm: 50, heightCm: 50, packageCount: 10, unitVolumeCbm: 0.15, totalVolumeCbm: 1.5 }, { lengthCm: 61, widthCm: 51, heightCm: 51, packageCount: 9, unitVolumeCbm: 0.159, totalVolumeCbm: 1.431 }, { lengthCm: 50, widthCm: 40, heightCm: 40, packageCount: 1, unitVolumeCbm: 0.08, totalVolumeCbm: 0.08 }, { lengthCm: 62, widthCm: 52, heightCm: 52, packageCount: 22, unitVolumeCbm: 0.1677, totalVolumeCbm: 3.6894 }, ].map((row, index) => ({ ...row, itemNo: index + 1, coefficientMode: 'ACTUAL', calculationRuleVersion: 'ACTUAL_DIMENSION_V1', calculatedOuterLengthCm: row.lengthCm, calculatedOuterWidthCm: row.widthCm, calculatedOuterHeightCm: row.heightCm, })) vm.measurementDetail = { items } vm.measurementSectionModes.outer = 'ACTUAL' vm.measurementSectionModes.actual = 'ACTUAL' await flushPromises() for (const rows of [vm.outerMeasurementRows, vm.actualMeasurementRows]) { expect(rows.map(row => vm.measurementValue(row, 'totalVolumeCbm'))).toEqual(['1.5000', '1.4310', '0.0800', '3.6894']) expect(rows.map(row => vm.measurementValue(row, 'unitVolumeCbm'))).toEqual(['0.1500', '0.1590', '0.0800', '0.1677']) expect(vm.formatMeasurementVolume(vm.measurementTotals(rows).volume)).toBe('6.7004') expect(vm.formatMeasurementVolume(vm.measurementTotals(rows).weightVolume)).toBe('6.7004') } expect(vm.formatMeasurementVolume(3)).toBe('3.0000') expect(vm.formatMeasurementVolume(0)).toBe('0.0000') expect(vm.formatMeasurementVolume(null)).toBe('—') // 仓库修正的外箱仍与实测尺寸分开,不能把外箱计费快照当成原始实测体积。 vm.measurementDetail = { items: [{ ...items[1], outerLengthCm: 62, calculatedOuterLengthCm: 62, unitVolumeCbm: 0.1613, totalVolumeCbm: 1.4517, calculationRuleVersion: 'WAREHOUSE_OUTER_OVERRIDE_V1' }] } await flushPromises() expect(vm.measurementValue(vm.outerMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.4517') expect(vm.measurementValue(vm.actualMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.4283') vm.measurementDetail = { items: [{ ...items[1], coefficientMode: 'ADD', calculatedOuterLengthCm: 62, calculatedOuterWidthCm: 52, calculatedOuterHeightCm: 52, unitVolumeCbm: 0.168, totalVolumeCbm: 1.512 }] } await flushPromises() expect(vm.measurementValue(vm.actualMeasurementRows[0]!, 'totalVolumeCbm')).toBe('1.4283') wrapper.unmount() }) it.each([3.894, 4.25])('defaults billing to the saved outer volume and sends the confirmed value %s', async (confirmedVolume) => { const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, chargeUnit: 'KG', roundingScale: 4, rateCode: 'RATE', feeStatus: 'CALCULATED' } vi.mocked(getLatestInboundFee).mockResolvedValue(fee as never) vi.mocked(calculateInboundFee).mockResolvedValue(fee as never) vi.mocked(confirmInboundFee).mockResolvedValue({ ...fee, feeStatus: 'CONFIRMED' } as never) vi.mocked(sendInboundFee).mockResolvedValue({ ...fee, feeStatus: 'SENT' } as never) const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = savedOuterMeasurement() vm.measurementSectionModes.outer = 'ACTUAL' vm.measurementSectionModes.actual = 'ACTUAL' await vm.openFeeConfirmDialog() expect(vm.feeConfirmRows.find(row => row.source === '实际测量数据')?.volume).toBeCloseTo(3.6894) expect(vm.feeConfirmRows.find(row => row.source === '外箱规数据')?.volume).toBeCloseTo(3.894) expect(vm.feeConfirmVolume).toBeCloseTo(3.894) expect(vm.feeConfirmWeight).toBeCloseTo(570.5) expect(previewInboundFee).toHaveBeenCalledWith('6001', '7001', { selectedWeightKg: 570.5, selectedVolumeCbm: 3.894, selectedUnitPrice: null, weightSource: 'ACTUAL', volumeSource: 'OUTER', }) expect(calculateInboundFee).not.toHaveBeenCalled() expect(getLatestInboundFee).not.toHaveBeenCalled() vm.feeConfirmVolume = confirmedVolume await vm.refreshFeeConfirmPreview() await vm.executeConfirmAndSendFee() expect(calculateInboundFee).toHaveBeenCalledWith('6001', '7001', { selectedWeightKg: 570.5, selectedVolumeCbm: confirmedVolume, weightSource: 'ACTUAL', volumeSource: 'OUTER', }, 'fee-calculate') expect(confirmInboundFee).toHaveBeenCalledWith('6001', '7001', 'fee-confirm') expect(sendInboundFee).toHaveBeenCalledWith('6001', '7001', 'fee-send') wrapper.unmount() }) it('refreshes the amount from current outer values instead of an older fee snapshot', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ unitPrice: 13.6, chargeableQuantity: 616.1298, totalAmount: 8379.37 } as never) const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = savedOuterMeasurement() await vm.openFeeConfirmDialog() expect(vm.feeConfirmVolume).toBe(3.894) expect(vm.feeConfirmReference?.chargeableQuantity).toBe(650.298) expect(vm.feeConfirmEstimatedTotal).toBe(8844.05) expect(getLatestInboundFee).not.toHaveBeenCalled() expect(calculateInboundFee).not.toHaveBeenCalled() wrapper.unmount() }) it('keeps saved outer totals when customer rules change without resaving the measurement', async () => { const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = { actualPackageCount: 10, actualGrossWeightKg: 100, actualVolumeCbm: 0.81, items: [{ id: '9001', itemNo: 'B1', packageCount: 10, lengthCm: 50, widthCm: 40, heightCm: 30, outerLengthCm: null, outerWidthCm: null, outerHeightCm: null, calculatedOuterLengthCm: 54, calculatedOuterWidthCm: 44, calculatedOuterHeightCm: 34, unitVolumeCbm: 0.081, totalVolumeCbm: 0.81, volumeWeightKg: 135.27, coefficientMode: 'ADD', calculationRuleVersion: 'CUSTOMER_DIMENSION_LARGE_V1' }], } vm.measurementDimensionConfig = { defaultCalculationMode: 'ADD', rules: [ [30, 20, 20], [40, 30, 30], [50, 40, 40], [60, 50, 50], ].map(([innerLengthCm, innerWidthCm, innerHeightCm]) => ({ innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm: 3 })) } vm.measurementSectionModes.outer = 'ADD' await vm.openFeeConfirmDialog() expect(vm.feeConfirmVolume).toBeCloseTo(0.81) expect(previewInboundFee).toHaveBeenCalledWith('6001', '7001', { selectedWeightKg: 100, selectedVolumeCbm: 0.81, selectedUnitPrice: null, weightSource: 'ACTUAL', volumeSource: 'OUTER', }) wrapper.unmount() }) it('keeps the price-table reference visible when manual pricing refreshes the amount and requires a reason before sending', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = savedOuterMeasurement() await vm.openFeeConfirmDialog() await flushPromises() const referencePriceText = () => wrapper.findAll('tr') .find(row => row.find('th').text() === '价格表参考单价')!.get('td').text() expect(referencePriceText()).toBe('13.6 CNY / KG') vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ totalAmount: 9104.17 })) vm.feeConfirmUnitPrice = 14 await flushPromises() expect(referencePriceText()).toBe('13.6 CNY / KG') expect(vm.feeConfirmEstimatedTotal).toBeNull() expect(vm.feeConfirmPriceChanged).toBe(true) await vi.advanceTimersByTimeAsync(250) expect(referencePriceText()).toBe('13.6 CNY / KG') expect(vm.feeConfirmUnitPrice).toBe(14) expect(vm.feeConfirmEstimatedTotal).toBe(9104.17) expect(previewInboundFee).toHaveBeenLastCalledWith('6001', '7001', { selectedWeightKg: 570.5, selectedVolumeCbm: 3.894, selectedUnitPrice: 14, weightSource: 'ACTUAL', volumeSource: 'OUTER', }) await vm.executeConfirmAndSendFee() expect(calculateInboundFee).not.toHaveBeenCalled() const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, additionalAmount: 0, rateCode: 'RATE', feeVersion: 2, feeStatus: 'CALCULATED' } vi.mocked(calculateInboundFee).mockResolvedValue(fee as never) vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...fee, unitPrice: 14, feeVersion: 3 } as never) vi.mocked(confirmInboundFee).mockResolvedValue({ ...fee, unitPrice: 14, feeStatus: 'CONFIRMED' } as never) vi.mocked(sendInboundFee).mockResolvedValue({ ...fee, unitPrice: 14, feeStatus: 'SENT' } as never) vm.feeConfirmPriceReason = '客户协商调价' await vm.executeConfirmAndSendFee() expect(adjustPublishedInboundFee).toHaveBeenCalledWith('6001', '7001', { expectedFeeVersion: 2, chargeableQuantity: 650.298, unitPrice: 14, additionalAmount: 0, reason: '客户协商调价', toleranceConfirmed: true, }, 'fee-adjust') expect(sendInboundFee).toHaveBeenCalledWith('6001', '7001', 'fee-send') wrapper.unmount() }) it('stops requiring an adjustment reason immediately when the manual price is restored to the price-table reference', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = savedOuterMeasurement() await vm.openFeeConfirmDialog() vm.feeConfirmUnitPrice = 14 await vi.advanceTimersByTimeAsync(250) expect(vm.feeConfirmPriceChanged).toBe(true) vm.feeConfirmUnitPrice = 13.6 expect(vm.feeConfirmPriceChanged).toBe(false) await vi.advanceTimersByTimeAsync(250) expect(vm.feeConfirmPriceChanged).toBe(false) expect(vm.feeConfirmEstimatedTotal).toBe(8844.05) wrapper.unmount() }) it('ignores stale preview responses and retains the manually entered unit price', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = savedOuterMeasurement() await vm.openFeeConfirmDialog() vm.feeConfirmUnitPrice = 16 let resolveOld!: (value: InboundFeePreview) => void vi.mocked(previewInboundFee).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) vm.feeConfirmVolume = 4 expect(vm.feeConfirmEstimatedTotal).toBeNull() await vi.advanceTimersByTimeAsync(250) vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ actualVolumeCbm: 4.25, chargeableQuantity: 709.75, totalAmount: 11356 })) vm.feeConfirmVolume = 4.25 await vi.advanceTimersByTimeAsync(250) expect(vm.feeConfirmReference?.actualVolumeCbm).toBe(4.25) expect(vm.feeConfirmUnitPrice).toBe(16) expect(vm.feeConfirmEstimatedTotal).toBe(11356) expect(previewInboundFee).toHaveBeenLastCalledWith('6001', '7001', { selectedWeightKg: 570.5, selectedVolumeCbm: 4.25, selectedUnitPrice: 16, weightSource: 'ACTUAL', volumeSource: 'OUTER', }) resolveOld(feePreview({ actualVolumeCbm: 4, chargeableQuantity: 668, totalAmount: 9084.8 })) await flushPromises() expect(vm.feeConfirmReference?.actualVolumeCbm).toBe(4.25) expect(vm.feeConfirmEstimatedTotal).toBe(11356) wrapper.unmount() }) it('closes billing preview and ignores its result after switching orders', async () => { const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm & { detail: Record; feeConfirmOpen: boolean } vm.measurementDetail = savedOuterMeasurement() let resolvePreview!: (value: InboundFeePreview) => void vi.mocked(previewInboundFee).mockImplementationOnce(() => new Promise(resolve => { resolvePreview = resolve })) const pending = vm.openFeeConfirmDialog() vm.detail = { ...detail, summary: { ...detail.summary, inboundId: '7002' } } expect(vm.feeConfirmOpen).toBe(false) resolvePreview(feePreview()) await pending expect(vm.feeConfirmReference).toBeNull() wrapper.unmount() }) it('does not send when current fee preview fails', async () => { vi.mocked(previewInboundFee).mockRejectedValueOnce(new ApiResponseError('预览服务暂不可用', 'A0500', 200)) const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = savedOuterMeasurement() await vm.openFeeConfirmDialog() vm.feeConfirmUnitPrice = 13.6 await vm.executeConfirmAndSendFee() expect(vm.feeConfirmReference).toBeNull() expect(vm.feeConfirmEstimatedTotal).toBeNull() expect(calculateInboundFee).not.toHaveBeenCalled() expect(sendInboundFee).not.toHaveBeenCalled() wrapper.unmount() }) it('previews manual pricing with server quantity when no price is matched', async () => { vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ priceMatched: false, unitPrice: null, totalAmount: null })) const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = savedOuterMeasurement() await vm.openFeeConfirmDialog() expect(vm.feeConfirmEstimatedTotal).toBeNull() vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ priceMatched: false, unitPrice: null, totalAmount: 6502.98 })) vm.feeConfirmUnitPrice = 10 await vm.refreshFeeConfirmPreview() expect(vm.feeConfirmEstimatedTotal).toBe(6502.98) wrapper.unmount() }) it('uses server money rounding after a manual unit price change', async () => { const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm vm.measurementDetail = savedOuterMeasurement() await vm.openFeeConfirmDialog() vi.mocked(previewInboundFee).mockResolvedValueOnce(feePreview({ chargeableQuantity: 1, unitPrice: 2, totalAmount: 1.01 })) vm.feeConfirmUnitPrice = 1.005 expect(vm.feeConfirmEstimatedTotal).toBeNull() await vm.refreshFeeConfirmPreview() expect(vm.feeConfirmUnitPrice).toBe(1.005) expect(vm.feeConfirmEstimatedTotal).toBe(1.01) wrapper.unmount() }) it('rejects a partially missing outer snapshot instead of billing the remaining rows', async () => { const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm const saved = savedOuterMeasurement() vm.measurementDetail = { ...saved, items: [saved.items[0], { ...saved.items[0], id: '9002', calculatedOuterLengthCm: null }] } await vm.openFeeConfirmDialog() expect(previewInboundFee).not.toHaveBeenCalled() wrapper.unmount() }) it('saves cleared warehouse dimensions as null and keeps them blank after server readback', async () => { const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as FeeConfirmVm & { actualMeasurementRows: Record[] outerMeasurementRows: Record[] saveWarehouseOuterDimensions(row: Record): Promise } const previous = { ...savedOuterMeasurement(), measurementId: '8001', version: 3, customerFeeConfirmed: false } vm.measurementDetail = previous const cleared = { ...previous, version: 4, items: previous.items.map(row => ({ ...row, outerLengthCm: null, outerWidthCm: null, outerHeightCm: null, calculatedOuterLengthCm: 62, calculatedOuterWidthCm: 52, calculatedOuterHeightCm: 52, unitVolumeCbm: 0.1677, totalVolumeCbm: 3.6894, volumeWeightKg: 616.13, calculationRuleVersion: 'ACTUAL_DIMENSION_V1', })) } vi.mocked(updateMeasurementOuterDimensions).mockResolvedValue({ measurementId: '8001', version: 4, status: 'SUBMITTED', itemId: '9001' }) vi.mocked(getInboundMeasurement).mockResolvedValue(cleared as never) const row = vm.actualMeasurementRows[0]! row.outerLengthCm = null row.outerWidthCm = undefined row.outerHeightCm = '' await vm.saveWarehouseOuterDimensions(row) expect(updateMeasurementOuterDimensions).toHaveBeenCalledWith('6001', '7001', '9001', { measurementId: '8001', version: 3, outerLengthCm: null, outerWidthCm: null, outerHeightCm: null, }) expect(vm.actualMeasurementRows[0]).toMatchObject({ outerLengthCm: null, outerWidthCm: null, outerHeightCm: null }) expect(vm.outerMeasurementRows[0]).toMatchObject({ lengthCm: 62, widthCm: 52, heightCm: 52, totalVolumeCbm: 3.6894 }) wrapper.unmount() }) it('downloads one preview for cargo rows sharing the same product image', async () => { vi.mocked(getPlatformUnifiedOrder).mockResolvedValue({ ...detail, order: { ...detail.order, cargoes: [1, 2, 3, 4].map((itemNo) => ({ itemNo, productImageFileId: '9901' })), }, } as never) mountDetail() await flushPromises() expect(downloadPlatformProductImage).toHaveBeenCalledTimes(1) expect(downloadPlatformProductImage).toHaveBeenCalledWith('6001', '9901') }) it('shows a compact empty state in order detail when no downstream relationship exists', async () => { const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('拆分履约'))!.trigger('click') await flushPromises() expect(getLoadingAssociationSummary).toHaveBeenCalledWith('NYEXX2608310001') expect(getOverseasInboundCodeRelationship).toHaveBeenCalledWith('NYEXX2608310001') expect(wrapper.text()).toContain('暂无后续履约数据') expect(wrapper.text()).not.toContain('新增关联') }) it('renders actual loading and overseas business identifiers inside order detail', async () => { vi.mocked(getLoadingAssociationSummary).mockResolvedValue({ inboundId: '7001', inboundCode: 'NYEXX2608310001', loadingPlanId: '9001', loadingPlanNo: 'LP-260831-001', containerBookingId: '9002', cabinetNo: 'CAB-260831-001', containerNo: 'MSCU1234567', loadingStatus: 'LOADING', taskId: '9003', pdaTaskStatus: 'IN_PROGRESS', coLoadGroupId: null, coLoadGroupNo: null, items: [], }) vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValue({ inboundCode: 'NYEXX2608310001', found: true, customer: null, overseasWarehouse: { warehouseId: '9004', warehouseCode: 'LAX', warehouseName: '洛杉矶海外仓' }, overseasReceivingStatus: 'RECEIVING', taillegOrders: [{ orderNo: 'LL-260831-001' }], inboundPlans: [{ planNo: 'IP-260831-001' }], palletCodes: ['PLT-LAX-001'], inventory: [{ skuCode: 'SKU-001', packageCount: 2 }], }) const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('拆分履约'))!.trigger('click') await flushPromises() expect(wrapper.text()).toContain('LP-260831-001') expect(wrapper.text()).toContain('MSCU1234567') expect(wrapper.text()).toContain('LL-260831-001') expect(wrapper.text()).toContain('IP-260831-001') expect(wrapper.text()).toContain('PLT-LAX-001') expect(wrapper.text()).not.toContain('9001') expect(wrapper.text()).not.toContain('9004') }) it('loads server hold capabilities and history only after opening the other tab', async () => { const wrapper = mountDetail() await flushPromises() expect(getPlatformInboundHold).not.toHaveBeenCalled() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() expect(getPlatformInboundHold).toHaveBeenCalledWith('6001', '7001') expect(wrapper.text()).toContain('未扣货') const loaded = (wrapper.vm as unknown as { holdDetail: { history: Array<{ reason: string; evidenceFileId: string }> } }).holdDetail expect(loaded.history).toEqual([expect.objectContaining({ reason: '货款已核对', evidenceFileId: '9901' })]) expect(wrapper.findAll('button').map((button) => button.text())).toContain('扣货') }) it('submits the required reason and server version, then refreshes detail and list', async () => { const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { holdReason: string; openHoldDialog(action: 'HOLD'): void; submitHoldAction(): Promise } vm.openHoldDialog('HOLD') vm.holdReason = ' 等待客户补款 ' await vm.submitHoldAction() expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 3, reason: '等待客户补款' }) expect(getPlatformUnifiedOrder).toHaveBeenCalledTimes(2) expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1) }) it('exposes loading, empty and error states for the lazy hold request', async () => { let resolveHold!: (value: Awaited>) => void vi.mocked(getPlatformInboundHold).mockImplementationOnce(() => new Promise((resolve) => { resolveHold = resolve })) const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') expect((wrapper.vm as unknown as { holdLoading: boolean }).holdLoading).toBe(true) resolveHold({ inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3, canHold: true, canRelease: false, actionBlockReason: null, history: [], }) await flushPromises() const loadedVm = wrapper.vm as unknown as { holdLoading: boolean; holdDetail: { history: unknown[] } } expect(loadedVm.holdLoading).toBe(false) expect(loadedVm.holdDetail.history).toEqual([]) expect(wrapper.text()).toContain('扣货历史') vi.mocked(getPlatformInboundHold).mockRejectedValueOnce(new Error('网络断开')) await (wrapper.vm as unknown as { loadHoldDetail(force: boolean): Promise }).loadHoldDetail(true) expect(wrapper.text()).toContain('扣货状态与历史加载失败') expect((wrapper.vm as unknown as { holdError: { message: string } }).holdError.message).toBe('扣货状态与历史加载失败') }) it('rejects blank and over-limit reasons while accepting exactly 1000 characters', async () => { const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { holdReason: string openHoldDialog(action: 'HOLD'): void submitHoldAction(): Promise } vm.openHoldDialog('HOLD') vm.holdReason = ' ' await vm.submitHoldAction() vm.holdReason = 'a'.repeat(1001) await vm.submitHoldAction() expect(holdPlatformInbound).not.toHaveBeenCalled() vm.holdReason = 'a'.repeat(1000) await vm.submitHoldAction() expect(holdPlatformInbound).toHaveBeenCalledOnce() expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 3, reason: 'a'.repeat(1000), }) }) it('prevents duplicate submissions while the hold request is pending', async () => { let resolveCommand!: (value: Awaited>) => void vi.mocked(holdPlatformInbound).mockImplementationOnce(() => new Promise((resolve) => { resolveCommand = resolve })) const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { holdReason: string holdSaving: boolean openHoldDialog(action: 'HOLD'): void submitHoldAction(): Promise } vm.openHoldDialog('HOLD') vm.holdReason = '等待客户补款' const first = vm.submitHoldAction() const duplicate = vm.submitHoldAction() await duplicate expect(vm.holdSaving).toBe(true) expect(holdPlatformInbound).toHaveBeenCalledOnce() resolveCommand({ inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 4, canHold: false, canRelease: true, actionBlockReason: null, history: [], }) await first expect(vm.holdSaving).toBe(false) }) it('keeps the dialog and reason when a stale-version submission fails', async () => { vi.mocked(holdPlatformInbound).mockRejectedValueOnce(Object.assign(new Error('扣货状态已变化,请刷新后重试'), { code: 'A0409' })) const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { holdReason: string holdDialogOpen: boolean openHoldDialog(action: 'HOLD'): void submitHoldAction(): Promise } vm.openHoldDialog('HOLD') vm.holdReason = '等待客户补款' await vm.submitHoldAction() expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 3, reason: '等待客户补款' }) expect(vm.holdDialogOpen).toBe(true) expect(vm.holdReason).toBe('等待客户补款') expect(getPlatformUnifiedOrder).toHaveBeenCalledOnce() expect(listPlatformUnifiedOrders).not.toHaveBeenCalled() }) it('does not submit the command when evidence upload fails and preserves the form', async () => { vi.mocked(uploadPlatformInboundHoldEvidence).mockRejectedValueOnce(new Error('文件服务不可用')) const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() const evidence = new File(['evidence'], '扣货说明.pdf', { type: 'application/pdf' }) const vm = wrapper.vm as unknown as { holdReason: string holdDialogOpen: boolean holdEvidenceFile: File | null holdEvidenceError: { message: string } | null openHoldDialog(action: 'HOLD'): void selectHoldEvidenceFile(upload: { raw: File }): void submitHoldAction(): Promise } vm.openHoldDialog('HOLD') vm.holdReason = '等待客户补款' vm.selectHoldEvidenceFile({ raw: evidence }) await vm.submitHoldAction() expect(uploadPlatformInboundHoldEvidence).toHaveBeenCalledWith('6001', '7001', evidence) expect(holdPlatformInbound).not.toHaveBeenCalled() expect(vm.holdDialogOpen).toBe(true) expect(vm.holdReason).toBe('等待客户补款') expect(vm.holdEvidenceFile).toBe(evidence) expect(vm.holdEvidenceError?.message).toBe('关联文件上传失败,请稍后重试') }) it('uploads optional evidence once and submits its long identifier', async () => { const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() const evidence = new File(['evidence'], '扣货说明.pdf', { type: 'application/pdf' }) const vm = wrapper.vm as unknown as { holdReason: string openHoldDialog(action: 'HOLD'): void selectHoldEvidenceFile(upload: { raw: File }): void submitHoldAction(): Promise } vm.openHoldDialog('HOLD') vm.holdReason = '等待客户补款' vm.selectHoldEvidenceFile({ raw: evidence }) await vm.submitHoldAction() expect(uploadPlatformInboundHoldEvidence).toHaveBeenCalledOnce() expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 3, reason: '等待客户补款', evidenceFileId: '9007199254740999', }) }) it('rejects unsafe evidence extensions, empty files and files over 10MB', async () => { const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { holdEvidenceFile: File | null holdEvidenceError: { code: string } | null openHoldDialog(action: 'HOLD'): void selectHoldEvidenceFile(upload: { raw: File }): void } vm.openHoldDialog('HOLD') vm.selectHoldEvidenceFile({ raw: new File(['run'], '恶意程序.exe') }) expect(vm.holdEvidenceFile).toBeNull() expect(vm.holdEvidenceError?.code).toBe('FILE_TYPE_INVALID') vm.selectHoldEvidenceFile({ raw: new File([], '空文件.txt', { type: 'text/plain' }) }) expect(vm.holdEvidenceError?.code).toBe('FILE_SIZE_INVALID') const oversized = new File(['x'], '过大文件.png', { type: 'image/png' }) Object.defineProperty(oversized, 'size', { value: 10 * 1024 * 1024 + 1 }) vm.selectHoldEvidenceFile({ raw: oversized }) expect(vm.holdEvidenceError?.code).toBe('FILE_SIZE_INVALID') expect(uploadPlatformInboundHoldEvidence).not.toHaveBeenCalled() }) it('downloads associated evidence once while a repeated click is pending', async () => { let resolveDownload!: (value: Blob) => void vi.mocked(downloadPlatformInboundHoldEvidence).mockImplementationOnce(() => new Promise((resolve) => { resolveDownload = resolve })) Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:hold-evidence') }) Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) const wrapper = mountDetail() await flushPromises() const vm = wrapper.vm as unknown as { downloadHoldEvidence(fileId: string): Promise; holdDownloadingFileId: string } const first = vm.downloadHoldEvidence('9007199254740999') const duplicate = vm.downloadHoldEvidence('9007199254740999') await duplicate expect(downloadPlatformInboundHoldEvidence).toHaveBeenCalledOnce() expect(vm.holdDownloadingFileId).toBe('9007199254740999') resolveDownload(new Blob(['evidence'], { type: 'application/pdf' })) await first expect(downloadPlatformInboundHoldEvidence).toHaveBeenCalledWith('6001', '7001', '9007199254740999') expect(click).toHaveBeenCalledOnce() expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:hold-evidence') expect(vm.holdDownloadingFileId).toBe('') }) it('submits release with the current server version and refreshes detail and list', async () => { vi.mocked(getPlatformInboundHold).mockResolvedValueOnce({ inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 8, canHold: false, canRelease: true, actionBlockReason: null, history: [], }) const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() const vm = wrapper.vm as unknown as { holdReason: string openHoldDialog(action: 'RELEASE'): void submitHoldAction(): Promise } vm.openHoldDialog('RELEASE') vm.holdReason = '货款已核对' await vm.submitHoldAction() expect(releasePlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 8, reason: '货款已核对' }) expect(getPlatformUnifiedOrder).toHaveBeenCalledTimes(2) expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1) }) it('stays read-only without the independent hold permission', async () => { canPermission.mockImplementation((...permissions: string[]) => permissions[0] !== 'platform:firstleg:inbound:hold') const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() expect(getPlatformInboundHold).toHaveBeenCalledWith('6001', '7001') expect(wrapper.text()).toContain('未扣货') expect(wrapper.findAll('button').map((button) => button.text())).not.toEqual(expect.arrayContaining(['扣货', '解除扣货'])) }) it('stays read-only and does not request hold detail before an inbound exists', async () => { vi.mocked(getPlatformUnifiedOrder).mockResolvedValue({ ...detail, summary: { ...detail.summary, inboundId: null, inboundCode: null, holdFlag: null, currentHoldReason: null } } as never) const wrapper = mountDetail() await flushPromises() await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') await flushPromises() expect(getPlatformInboundHold).not.toHaveBeenCalled() expect(wrapper.text()).toContain('订单尚未生成入库单,扣货状态仅可查看') expect(wrapper.findAll('button').map((button) => button.text())).not.toEqual(expect.arrayContaining(['扣货', '解除扣货'])) }) })