From 4573b3df1ca1a8e429e3180587b47706f081492e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 11:12:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/ContainersView.test.ts | 14 ++++++++++++++ .../pages/ContainersView.vue | 19 ++++++++++++++++--- .../customs/api/platformCustoms.test.ts | 2 +- .../customs/pages/CustomsCasesView.test.ts | 9 +++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index f03259cc..8a44a043 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -8,11 +8,13 @@ import { resolve } from 'node:path' import ContainersView from './ContainersView.vue' import { listAllPlatformWarehouses } from '@/modules/warehouses' import { listPlatformPartners } from '@/modules/master-data' +import { listSuppliers } from '@/modules/suppliers' import { listOceanBookings } from '@/modules/ocean-bookings' import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading' vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() })) +vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() })) vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookings: vi.fn() })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) })) const accessCan = vi.hoisted(() => vi.fn()) @@ -34,6 +36,7 @@ describe('ContainersView', () => { accessCan.mockReset(); accessCan.mockReturnValue(true) vi.mocked(listAllPlatformWarehouses).mockResolvedValue([]) vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) + vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) @@ -247,3 +250,14 @@ describe('ContainersView', () => { expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())') }) }) + +describe('ContainersView trucking supplier contract', () => { + it('uses active TRUCKING suppliers without allowing free-text carrier names', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') + expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })") + expect(source).toContain("supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE'") + expect(source).toContain('selectTruckingSupplier') + expect(source).toContain('请选择有效的合作中拖车供应商') + expect(source).not.toContain('allow-create') + }) +}) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index f0eaed9e..e0bcc0d3 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -7,6 +7,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data' +import { listSuppliers, type Supplier } from '@/modules/suppliers' import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingDetail, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings' import { uploadLoadingDeclaration } from '@/modules/loading-lists' import AdminPage from '@/shared/components/AdminPage.vue' @@ -33,6 +34,7 @@ const customersLoading = ref(false) const warehouses = ref([]) const destinationWarehouses = ref([]) const partners = ref([]) +const truckingSuppliers = ref([]) const oceanBookings = ref([]) const loading = ref(false) const warehouseLoading = ref(false) @@ -88,6 +90,7 @@ const linkError = ref(null) const linkTarget = ref(null) const linkCandidateKey = ref('') const linkCandidates = ref>([]) +const selectedTruckingSupplierId = ref('') const unlinkOpen = ref(false) const unlinkReason = ref('') let listSequence = 0 @@ -109,6 +112,7 @@ const rules: FormRules = { cabinetNo: [{ required: true, message: '请输入柜次号', trigger: 'blur' }], warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }], containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }], + terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(!value || selectedTruckingSupplierId.value ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }], } const canView = computed(() => access.can('platform:firstleg:container:list')) @@ -124,7 +128,7 @@ const documentDialogTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKU const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常') const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING') const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER'))) -const truckingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.some((type) => ['CARRIER', 'SUPPLIER'].includes(type)))) +const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING')) const simulationRules: FormRules = { warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }], channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }], @@ -196,6 +200,7 @@ function containerViewLabel(row: ContainerBookingResource) { } function replaceForm(source: ContainerBookingResource | null) { Object.assign(form, emptyForm(), source || {}) + selectedTruckingSupplierId.value = truckingCompanies.value.find((supplier) => supplier.name === form.terminalTruckingCompany)?.id || '' } function payload(): ContainerSaveRequest { const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId) @@ -219,6 +224,11 @@ function payload(): ContainerSaveRequest { function partnerLabel(partner: PlatformPartnerResource) { return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}` } +function selectTruckingSupplier(supplierId: string) { + const supplier = truckingCompanies.value.find((item) => item.id === supplierId) + selectedTruckingSupplierId.value = supplier?.id || '' + form.terminalTruckingCompany = supplier?.name || null +} function portLabel(port: PortOption) { return port.name && port.code ? `${port.name}(${port.code})` : port.name || port.code || '未命名港口' } @@ -278,12 +288,15 @@ async function loadBusinessOptions() { businessOptionsLoading.value = true businessOptionsError.value = '' const errors: string[] = [] - const [partnerResult, bookingResult] = await Promise.allSettled([ + const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([ loadAllPartners(), + listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }), loadAllOceanBookings(), ]) if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message) + if (supplierResult.status === 'fulfilled') truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE') + else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message) if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message) businessOptionsError.value = errors.join(';') @@ -655,7 +668,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin - + diff --git a/src/modules/customs/api/platformCustoms.test.ts b/src/modules/customs/api/platformCustoms.test.ts index 8ac3c5eb..877b7c52 100644 --- a/src/modules/customs/api/platformCustoms.test.ts +++ b/src/modules/customs/api/platformCustoms.test.ts @@ -2,4 +2,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' import { createCustomsCase, listCustomsCases, submitCustomsCase } from './platformCustoms' vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } }) -describe('platformCustoms', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes case ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: '1' }], page: 1, size: 20, total: 1, pages: 1 } }); const page = await listCustomsCases({ page: 1, size: 20 }); expect(page.records[0]).toMatchObject({ id: '1', orderId: '2', inboundId: '3', checklistVersionId: '4', version: 1 }) }); it('writes with idempotency and version', async () => { const detail = { customsCase: { id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: 0 }, documents: [], supplements: [], submissionBatches: [], receipts: [], history: [] }; vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: detail }).mockResolvedValueOnce({ code: '200', data: {} }); await createCustomsCase({ customerId: '8', orderId: '2', inboundId: '3', checklistVersionId: '4', expectedVersion: 0 }); await submitCustomsCase('1', { customsAgentName: '代理', expectedVersion: 2 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 2 }) }) }) +describe('platformCustoms', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes case ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: '1' }], page: 1, size: 20, total: 1, pages: 1 } }); const page = await listCustomsCases({ page: 1, size: 20 }); expect(page.records[0]).toMatchObject({ id: '1', orderId: '2', inboundId: '3', checklistVersionId: '4', version: 1 }) }); it('writes with idempotency, supplier identity and version', async () => { const detail = { customsCase: { id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: 0 }, documents: [], supplements: [], submissionBatches: [], receipts: [], history: [] }; vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: detail }).mockResolvedValueOnce({ code: '200', data: {} }); await createCustomsCase({ customerId: '8', orderId: '2', inboundId: '3', checklistVersionId: '4', expectedVersion: 0 }); await submitCustomsCase('1', { customsAgentId: '81', customsAgentName: '代理', expectedVersion: 2 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ customsAgentId: '81', customsAgentName: '代理', expectedVersion: 2 }) }) }) diff --git a/src/modules/customs/pages/CustomsCasesView.test.ts b/src/modules/customs/pages/CustomsCasesView.test.ts index 430375b8..7547ab64 100644 --- a/src/modules/customs/pages/CustomsCasesView.test.ts +++ b/src/modules/customs/pages/CustomsCasesView.test.ts @@ -5,6 +5,15 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/Cu describe('CustomsCasesView', () => { it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") }) it('supports detail, history and agent submission', () => { expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') }) + it('selects an active customs supplier and submits its stable identity and name snapshot', () => { + expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'CUSTOMS', status: 'ACTIVE' })") + expect(source).toContain("supplier.category === 'CUSTOMS' && supplier.status === 'ACTIVE'") + expect(source).toContain(':model-value="submitForm.customsAgentId" filterable clearable') + expect(source).toContain("submitForm.customsAgentId = supplier?.id || ''") + expect(source).toContain("submitForm.customsAgentName = supplier?.name || ''") + expect(source).not.toContain('v-model="submitForm.customsAgentName"') + expect(source).toContain("batch.customsAgentName || '服务端未返回名称快照'") + }) it('localizes backend statuses and timeline actions throughout the page', () => { expect(source).toContain('customsCaseStatusOptions') expect(source).toContain(':status="customsStatusLabel(row.status)"')