This commit is contained in:
2026-08-15 11:12:22 +08:00
parent fb165b6347
commit 4573b3df1c
4 changed files with 40 additions and 4 deletions
@@ -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')
})
})
@@ -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<PlatformWarehouseSummaryResource[]>([])
const destinationWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const truckingSuppliers = ref<Supplier[]>([])
const oceanBookings = ref<OceanBookingResource[]>([])
const loading = ref(false)
const warehouseLoading = ref(false)
@@ -88,6 +90,7 @@ const linkError = ref<ApiErrorDetails | null>(null)
const linkTarget = ref<ContainerBookingResource | null>(null)
const linkCandidateKey = ref('')
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; detail: OceanBookingDetail }>>([])
const selectedTruckingSupplierId = ref('')
const unlinkOpen = ref(false)
const unlinkReason = ref('')
let listSequence = 0
@@ -109,6 +112,7 @@ const rules: FormRules<ContainerForm> = {
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<ContainerSimulationCreateRequest> = {
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
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" :disabled="transportOnly" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="货柜容量(m³)"><el-input-number v-model="form.capacityCbm" :min="0.001" :precision="3" controls-position="right" placeholder="请输入货柜容量" :disabled="transportOnly" /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" placeholder="请输入重量上限" :disabled="transportOnly" /></el-form-item>
<el-form-item label="拖车公司"><el-select v-model="form.terminalTruckingCompany" filterable clearable allow-create default-first-option placeholder="请选择或输入拖车公司" :loading="businessOptionsLoading" loading-text="拖车公司加载中" no-data-text="暂无合作单位,可直接输入" :disabled="transportOnly"><el-option v-for="partner in truckingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="partner.partnerName" /></el-select></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" :disabled="transportOnly" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" :disabled="transportOnly" /></el-form-item>
@@ -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<typeof import('@/shared/api/http')>('@/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 }) }) })
@@ -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)"')