diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts
new file mode 100644
index 00000000..f00836f6
--- /dev/null
+++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts
@@ -0,0 +1,87 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ElementPlus from 'element-plus'
+import ContainerLastLegFeesPanel from './ContainerLastLegFeesPanel.vue'
+
+const mocks = vi.hoisted(() => ({ upload: vi.fn(), metadata: vi.fn(), download: vi.fn(), create: vi.fn(), fees: vi.fn() }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('@/modules/finance', () => ({ getFinancePaymentVoucher: mocks.metadata, downloadFinancePaymentVoucher: mocks.download }))
+vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [] }))
+vi.mock('../api/overseasContainerFees', () => ({
+ listOverseasContainerFees: mocks.fees, listOverseasContainerCustomers: async () => [{ customerId: '11', customerName: '客户甲' }],
+ createOverseasContainerFee: mocks.create, adjustOverseasContainerFee: vi.fn(), reviewOverseasContainerFee: vi.fn(), submitOverseasContainerFee: vi.fn(), voidOverseasContainerFee: vi.fn(),
+}))
+
+async function setup(readonly = false) {
+ const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', containerNo: 'CONT-99', readonly }, global: { plugins: [ElementPlus], stubs: {
+ AppDialog: { props: ['modelValue', 'title', 'confirmDisabled'], template: '' },
+ } } })
+ await flushPromises()
+ return wrapper
+}
+type FormState = { form: { customerId: string; settlementMode: string }; dialogOpen: boolean }
+async function upload(wrapper: Awaited>, name: string) {
+ const input = wrapper.get('input[type="file"]')
+ Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], name, { type: 'image/png' })] })
+ await input.trigger('change')
+ await flushPromises()
+}
+describe('尾程付款凭证', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.fees.mockResolvedValue([])
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
+ URL.createObjectURL = vi.fn(() => 'blob:voucher')
+ URL.revokeObjectURL = vi.fn()
+ mocks.metadata.mockResolvedValue({ fileId: '91', fileName: '付款.png', contentType: 'image/png' })
+ mocks.download.mockResolvedValue(new Blob(['image']))
+ })
+
+ it('显示上传文件名,允许再次上传替换和移除', async () => {
+ const wrapper = await setup()
+ await wrapper.get('button').trigger('click')
+ const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
+ state.form.settlementMode = 'DIRECT_PAID'
+ await flushPromises()
+ mocks.upload.mockResolvedValueOnce({ fileId: '91', fileName: '付款.png' }).mockResolvedValueOnce({ fileId: '92', fileName: '新凭证.png' })
+ await upload(wrapper, '付款.png')
+ expect(mocks.upload).toHaveBeenCalledWith('11', expect.any(File))
+ expect(wrapper.text()).toContain('付款.png')
+ await upload(wrapper, '新凭证.png')
+ expect(wrapper.text()).toContain('新凭证.png')
+ expect(wrapper.text()).not.toContain('付款.png')
+ await wrapper.findAll('button').find(b => b.text() === '移除')!.trigger('click')
+ expect(wrapper.text()).not.toContain('新凭证.png')
+ wrapper.unmount()
+ })
+
+ it('上传中禁止保存,客户切换后忽略旧客户上传结果', async () => {
+ const wrapper = await setup()
+ await wrapper.get('button').trigger('click')
+ const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
+ state.form.settlementMode = 'DIRECT_PAID'
+ await flushPromises()
+ let complete!: (file: { fileId: string; fileName: string }) => void
+ mocks.upload.mockReturnValue(new Promise(resolve => { complete = resolve }))
+ await upload(wrapper, '旧客户.png')
+ expect(wrapper.findAll('button').find(b => b.text() === '保存')!.attributes('disabled')).toBeDefined()
+ state.form.customerId = '12'
+ complete({ fileId: '91', fileName: '旧客户.png' })
+ await flushPromises()
+ expect(wrapper.text()).not.toContain('旧客户.png')
+ wrapper.unmount()
+ })
+
+ it('只读列表能查看已保存的待审核凭证,并在关闭时释放预览资源', async () => {
+ mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'PENDING', paymentVoucherFileIds: ['91'] }])
+ const wrapper = await setup(true)
+ await wrapper.findAll('button').find(b => b.text() === '付款凭证 1')!.trigger('click')
+ await flushPromises()
+ expect(mocks.metadata).toHaveBeenCalledWith('11', '91')
+ expect(mocks.download).toHaveBeenCalledWith('11', '91')
+ expect(wrapper.get('img').attributes('src')).toBe('blob:voucher')
+ expect(wrapper.get('a[download]').attributes('download')).toBe('付款.png')
+ wrapper.unmount()
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:voucher')
+ })
+})
diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue
index 2d9450c2..be5191b4 100644
--- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue
+++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue
@@ -1,6 +1,7 @@
+
+
+ 新增模拟柜
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/container-loading/index.ts b/src/modules/container-loading/index.ts
index d8c0bdbe..da4036f4 100644
--- a/src/modules/container-loading/index.ts
+++ b/src/modules/container-loading/index.ts
@@ -1,3 +1,4 @@
export * from './api/platformContainerLoading'
export * from './types'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
+export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts
index 0783ae76..846ee964 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.test.ts
+++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts
@@ -87,7 +87,6 @@ describe('ContainerDetailView customs integration', () => {
expect(lastLegFeesSource).toContain("canEdit.value && access.can('platform:supplier:list') ? listContainerFeeSupplierOptions(null) : Promise.resolve([])")
expect(lastLegFeesSource).toContain("sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER'")
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')")
expect(source).toContain("if (!overview.value?.loadingPlanId) return '当前货柜尚未关联装柜计划,暂无头程计费归属。'")
diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts
index 161e5db9..2433a23c 100644
--- a/src/modules/container-loading/pages/ContainersView.test.ts
+++ b/src/modules/container-loading/pages/ContainersView.test.ts
@@ -609,13 +609,15 @@ describe('ContainersView', () => {
it('creates an internal simulation without asking for real booking transport facts', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
+ const simulationSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerSimulationCreate.vue'), 'utf8')
+ expect(source).toContain('ContainerSimulationCreate')
expect(source).not.toContain('新增货柜')
- expect(source).toContain('新增模拟柜')
- expect(source).toContain('createContainerSimulation({ ...simulationForm })')
- expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000")
+ expect(simulationSource).toContain('新增模拟柜')
+ expect(simulationSource).toContain('createContainerSimulation({ ...simulationForm })')
+ expect(simulationSource).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
- expect(source).toContain('selectSimulationContainerType')
- expect(source).toContain('服务端会生成稳定的柜次号')
+ expect(simulationSource).toContain('selectSimulationContainerType')
+ expect(simulationSource).toContain('服务端会生成稳定的柜次号')
expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"')
expect(source).not.toContain('v-model="form.cabinetNo"')
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue
index a73165f7..df69f727 100644
--- a/src/modules/container-loading/pages/ContainersView.vue
+++ b/src/modules/container-loading/pages/ContainersView.vue
@@ -1,14 +1,15 @@