优化
This commit is contained in:
@@ -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)"')
|
||||
|
||||
Reference in New Issue
Block a user