diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index eca4898c..fcba101b 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -18,7 +18,7 @@ vi.mock('@/shared/api/http', () => ({ unwrapApiResponse: (response: { data: T }) => response.data, })) -const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelGroupCode: 'EXX', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: 9007199254740999, unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' } +const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelGroupCode: 'EXX', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: 9007199254740999, unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '68', maxWeightKg: '19500' } const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING', canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', @@ -41,13 +41,13 @@ describe('platform container loading API', () => { it('normalizes paged container ids and numeric metadata', async () => { vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [{ container, workbenchStage: 'PRE_LOADING', statusSummary: { loadingStatus: 'WAIT_LOADING' }, allowedActions: ['EDIT_CONTAINER'], actualFacts: {}, terminalDrayage: null }], page: '1', size: '20', total: '1', pages: '1' } }) const result = await listContainers({ page: 1, size: 20 }) - expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), terminalTruckingCompanyId: String(container.terminalTruckingCompanyId), terminalTruckingCompany: '测试拖车供应商', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: String(container.pickupCodeUpdatedBy), unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 76 }) + expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), terminalTruckingCompanyId: String(container.terminalTruckingCompanyId), terminalTruckingCompany: '测试拖车供应商', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: String(container.pickupCodeUpdatedBy), unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 68 }) expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/domestic', { params: { page: 1, size: 20, scope: 'ACTIVE' } }) }) it('uses the full-edit request shape without retaining the retired container transport endpoint', async () => { vi.mocked(http.put).mockResolvedValue({ code: '200', data: container }) - const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null } + const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null } await updateContainer('7', 2, data) expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data }) expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/api/platformContainerLoading.ts'), 'utf8')).not.toContain('/transport') @@ -85,10 +85,10 @@ describe('platform container loading API', () => { it('uses the frozen simulation create and ocean booking link contracts', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } }) vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } }) - await createContainerSimulation({ channelGroupCode: 'EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) + await createContainerSimulation({ channelGroupCode: 'EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 }) await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 }) await unlinkContainerOceanBooking('7', 4, '空柜改配') - expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelGroupCode: 'EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelGroupCode: 'EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 }) expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 }) expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } }) }) @@ -182,7 +182,7 @@ describe('platform container loading API', () => { it('queries workbench containers with the same global filters and excludes the retired booking parameter', async () => { vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { - records: [{ bookingId: 9007199254740993n, bookingNo: 'BK-1', cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HC', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }], + records: [{ bookingId: 9007199254740993n, bookingNo: 'BK-1', cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HQ', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }], page: '1', size: '200', total: '1', pages: '1', } }) diff --git a/src/modules/container-loading/components/ContainerSimulationCreate.vue b/src/modules/container-loading/components/ContainerSimulationCreate.vue index 235fc793..e9ec050e 100644 --- a/src/modules/container-loading/components/ContainerSimulationCreate.vue +++ b/src/modules/container-loading/components/ContainerSimulationCreate.vue @@ -9,7 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' +import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' import { createContainerSimulation } from '../api/platformContainerLoading' import type { ContainerSimulationCreateRequest } from '../types' import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups' @@ -30,7 +30,7 @@ const simulationError = ref(null) const simulationChannels = ref([]) const simulationChannelsLoading = ref(false) const simulationFormRef = ref() -const simulationForm = reactive({ channelGroupCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) +const simulationForm = reactive({ channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500 }) const simulationChannelGroups = computed(() => { const activeCodes = new Set(simulationChannels.value.map((channel) => channel.channelCode)) return SMART_LOADING_CHANNEL_GROUPS.filter((group) => group.channelCodes.some((code) => activeCodes.has(code))) @@ -42,7 +42,7 @@ const simulationRules: FormRules = { } async function openSimulation() { - Object.assign(simulationForm, { channelGroupCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) + Object.assign(simulationForm, { channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500 }) simulationChannels.value = [] simulationError.value = null simulationOpen.value = true @@ -101,7 +101,7 @@ async function saveSimulation() { - + {{ containerSpecification(simulationForm.containerType)?.description }} diff --git a/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts b/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts index 9055d3f7..21ad88d8 100644 --- a/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts +++ b/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts @@ -12,17 +12,17 @@ vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: v const container = { id: '9001', cabinetNo: 'CAB-1', bookingNo: 'CB-1', containerNo: null, planId: '7001', planStatus: 'EDITING', sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, - oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', + oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ', shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, - capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, + capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 7, } satisfies ContainerBookingResource const candidate = { - oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HC', capacityCbm: 76, - maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1, + oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68, + maxWeightKg: 19500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1, customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班', } @@ -35,8 +35,8 @@ describe('OceanBookingLinkDialog', () => { it('loads matching released-booking requirements when opened', async () => { const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } }) await flushPromises() - expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HC', page: 1, size: 200 }) - expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HC × 1') + expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 }) + expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HQ × 1') wrapper.unmount() }) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index d682b42a..da10b405 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -71,8 +71,8 @@ describe('ContainersView', () => { vi.mocked(getTerminalDrayageHistory).mockResolvedValue([]) vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] }) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] }) - vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HC', 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: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, 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', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HC', 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: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) + vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, 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: 19500, terminalTruckingCompanyId: null, 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', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, 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: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) }) @@ -292,10 +292,10 @@ describe('ContainersView', () => { it('uses stable view labels when cabinetNo is missing', async () => { const base = { - id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', planStatus: 'WAIT_LOADING' as const, version: 0, + id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', - destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, + destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } @@ -321,10 +321,10 @@ describe('ContainersView', () => { it('routes each plan status to its owning workflow without reopening pushed drafts', async () => { const base = { - id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HC', version: 0, + id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', - destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, + destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } @@ -561,7 +561,7 @@ describe('ContainersView', () => { link.click() await flushPromises() expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true) - expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HC', page: 1, size: 200 }) + expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 }) } finally { wrapper.unmount() } @@ -586,7 +586,7 @@ describe('ContainersView', () => { expect(source).not.toContain('新增货柜') expect(simulationSource).toContain('新增模拟柜') expect(simulationSource).toContain('createContainerSimulation({ ...simulationForm })') - expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000") + expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500") expect(source).toContain('CONTAINER_SPECIFICATIONS') expect(simulationSource).toContain('selectSimulationContainerType') expect(simulationSource).toContain('服务端会生成稳定的柜次号') @@ -594,7 +594,10 @@ describe('ContainersView', () => { expect(source).not.toContain('v-model="form.cabinetNo"') expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()') expect(source).not.toContain('openCreate') - expect(source).not.toContain("['20GP','40GP','40HQ','45HQ']") + expect(source).not.toContain('20GP') + expect(source).not.toContain('40HC') + expect(source).not.toContain('40NR') + expect(source).not.toContain('45HC') expect(source).not.toContain('v-model="form.capacityCbm"') expect(source).not.toContain('v-model="form.maxWeightKg"') }) @@ -603,7 +606,7 @@ describe('ContainersView', () => { vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null, sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓', - channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HC', planStatus: 'EDITING', + channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'EDITING', capacityCbm: 86, maxWeightKg: 29000, version: 7, }], page: 1, size: 20, total: 1, pages: 1 } as never) vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 0 }) @@ -617,7 +620,7 @@ describe('ContainersView', () => { .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() await flushPromises() - expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HC', page: 1, size: 200 }) + expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 }) expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: unknown[] }).candidates).toEqual([]) expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/OceanBookingLinkDialog.vue'), 'utf8')) .toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求') @@ -628,7 +631,7 @@ describe('ContainersView', () => { it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => { vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{ oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', requirementId: '45315', - containerType: '40HC', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡', + containerType: '40HQ', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡', }], page: 1, size: 200, total: 1, pages: 1 } as never) const router = createTestRouter() await router.push('/'); await router.isReady() @@ -639,7 +642,7 @@ describe('ContainersView', () => { Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() await flushPromises() - const label = `OB-OPTIONAL-CUSTOMER · 40HC × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡` + const label = `OB-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡` expect(document.body.textContent).toContain(label) expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label) wrapper.unmount() @@ -650,7 +653,7 @@ describe('ContainersView', () => { vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null, sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓', - channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HC', planStatus: 'WAIT_LOADING', + channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'WAIT_LOADING', capacityCbm: 86, maxWeightKg: 29000, version: 7, }], page: 1, size: 20, total: 1, pages: 1 } as never) vi.mocked(listOceanBookingLinkCandidates).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve })) @@ -668,11 +671,11 @@ describe('ContainersView', () => { linkAction.click() expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1) - expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HC', page: 1, size: 200 }) + expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 }) expect(getOceanBooking).not.toHaveBeenCalled() - resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HC', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 }) + resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 }) await flushPromises() - expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HC × 1') + expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HQ × 1') expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315') wrapper.unmount() }) @@ -701,9 +704,9 @@ describe('ContainersView', () => { await flushPromises() Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() - resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', requirementId: '404', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) + resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) await flushPromises() - resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', requirementId: '202', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) + resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) await flushPromises() expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2) @@ -715,7 +718,7 @@ describe('ContainersView', () => { it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => { vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) - vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never) + vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never) vi.mocked(updateContainer).mockResolvedValue({} as never) const router = createTestRouter() await router.push('/'); await router.isReady() @@ -735,7 +738,7 @@ describe('ContainersView', () => { it('clears the trucking supplier id and name together', async () => { vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) - vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never) + vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never) vi.mocked(updateContainer).mockResolvedValue({} as never) const router = createTestRouter() await router.push('/'); await router.isReady() @@ -757,7 +760,7 @@ describe('ContainersView', () => { }) it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => { - vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never) + vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never) vi.mocked(updateContainer).mockClear() const router = createTestRouter() await router.push('/'); await router.isReady() diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index f96c9a21..7dc39e5b 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -18,7 +18,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' -import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' +import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading' import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue' @@ -165,10 +165,10 @@ let businessOptionsPromise: Promise | null = null function emptyForm(): ContainerForm { return { id: '', version: 0, containerNo: null, cabinetNo: '', warehouseId: '', warehouseName: null, - billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', shippingCompanyId: null, + billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: DEFAULT_CONTAINER_TYPE, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, - capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, + capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 8750bc6b..01bd5161 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -65,10 +65,10 @@ vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', - warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1, + warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: '31', shippingCompanyName: '美森轮船', departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, - destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000, + destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } @@ -205,14 +205,14 @@ describe('GlobalSmartLoadingView', () => { containerNo: null, channelName: '美森正班海卡包税', warehouseName: '义乌国内仓', - containerType: '40HC', + containerType: '40HQ', }], page: 1, size: 100, total: 1, pages: 1, }) - mockContainerSearch([{ ...container, cabinetNo: '2088475410134773762', containerNo: null, channelName: '美森正班海卡包税', warehouseName: '义乌国内仓', containerType: '40HC' }]) + mockContainerSearch([{ ...container, cabinetNo: '2088475410134773762', containerNo: null, channelName: '美森正班海卡包税', warehouseName: '义乌国内仓', containerType: '40HQ' }]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -223,7 +223,7 @@ describe('GlobalSmartLoadingView', () => { expect(option.text()).not.toContain('开始装柜前录入集装箱号') expect(option.text()).toContain('渠道:美森正班') expect(option.text()).toContain('仓库:义乌国内仓') - expect(option.text()).toContain('柜型:40HC') + expect(option.text()).toContain('柜型:40HQ') expect(option.text()).not.toContain('2088475410134773762') expect(wrapper.text()).not.toContain('真实柜号') }) diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index d91846d8..4cca0ed5 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -19,10 +19,10 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', - warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HC', planStatus: 'EDITING' as const, version: 1, + warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, - destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000, + destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, } diff --git a/src/modules/dashboard/pages/DashboardView.test.ts b/src/modules/dashboard/pages/DashboardView.test.ts index a982bd9a..ad3e702b 100644 --- a/src/modules/dashboard/pages/DashboardView.test.ts +++ b/src/modules/dashboard/pages/DashboardView.test.ts @@ -27,7 +27,9 @@ describe('DashboardView', () => { expect(wrapper.text()).toContain('核心指标来自实时业务数据') expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00') expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4) - expect(wrapper.findAll('.density-fields dt').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数']) + expect(wrapper.findAll('.dashboard-metric p').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数']) + expect(wrapper.find('.density-fields').exists()).toBe(false) + expect(wrapper.findAll('.dashboard-metric-mark')).toHaveLength(4) expect(wrapper.text()).toContain('12 票') expect(wrapper.text()).toContain('4.25 TEU') expect(wrapper.text()).toContain('8 家') @@ -46,7 +48,7 @@ describe('DashboardView', () => { const wrapper = mount(DashboardView) await flushPromises() expect(wrapper.text()).toContain('部分核心指标加载失败') - expect(wrapper.get('.dashboard-metric dd').text()).toContain('—') + expect(wrapper.get('.dashboard-metric strong').text()).toContain('—') expect(wrapper.text()).toContain('4.25 TEU') await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click') await flushPromises() @@ -55,7 +57,7 @@ describe('DashboardView', () => { expect(wrapper.text()).toContain('12 票') }) - it('uses the same density-enabled root when entering and exiting fullscreen', async () => { + it('uses the same visualization root when entering and exiting fullscreen', async () => { const wrapper = mount(DashboardView) await flushPromises() let fullscreen: Element | null = null @@ -67,7 +69,7 @@ describe('DashboardView', () => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen }) Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exit }) try { - expect(wrapper.classes()).toContain('admin-density') + expect(wrapper.classes()).not.toContain('admin-density') const button = wrapper.get('button[aria-pressed]') await button.trigger('click') await flushPromises() diff --git a/src/modules/dashboard/pages/DashboardView.vue b/src/modules/dashboard/pages/DashboardView.vue index ec461aaa..a78d58a6 100644 --- a/src/modules/dashboard/pages/DashboardView.vue +++ b/src/modules/dashboard/pages/DashboardView.vue @@ -1,6 +1,4 @@ diff --git a/src/modules/loading-lists/api/platformLoadingLists.test.ts b/src/modules/loading-lists/api/platformLoadingLists.test.ts index 14a8a716..fd6a5f27 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.test.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.test.ts @@ -23,7 +23,7 @@ const preloadSummary = { warehouseName: '义乌仓', vesselName: 'MATSON', voyageNo: '001E', - containerType: '40HC', + containerType: '40HQ', status: 'LOADING', skuCount: '2', totalPackageCount: '10', diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue index 903f6c32..387551fe 100644 --- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue @@ -264,7 +264,6 @@ function formatNumber(value?: number | null, digits = 3) { .detail-section__heading h2 { margin: 0; font-size: 15px; } .detail-section__heading h2 span { margin-left: var(--admin-space-2, 6px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; } .detail-section__heading p { margin: 2px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); line-height: 1.35; } -.detail-section :deep(.data-table__toolbar) { min-height: 52px; padding: 7px var(--admin-space-5, 12px); } .detail-section :deep(.el-table) { width: 100%; } .detail-section :deep(.el-table th.el-table__cell) { padding: 7px 0; } .detail-section :deep(.el-table td.el-table__cell) { padding: var(--admin-space-2, 6px) 0; } diff --git a/src/modules/loading-lists/pages/PreloadPlanDetailView.vue b/src/modules/loading-lists/pages/PreloadPlanDetailView.vue index 18afb1d9..cf3cf835 100644 --- a/src/modules/loading-lists/pages/PreloadPlanDetailView.vue +++ b/src/modules/loading-lists/pages/PreloadPlanDetailView.vue @@ -278,7 +278,7 @@ watch([planId, warehouseId], () => void loadDetail())
- + {{ detail.summary.preloadNo }} {{ detail.summary.cabinetNo || '—' }} diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts index e029f824..9c3819a8 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts @@ -7,8 +7,8 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({ id: 123, bookingNo: 'OB123', status, version, customerId: 456, customerName: '客户', shipperName: '发货企业', consigneeName: '收货企业', containerCount: '2', }, parties: [], - containerRequirements: [{ id: 9007199254740994, oceanBookingId: 123, containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }], - containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, warehouseId: 9007199254740996, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', customerId: 456, customerName: '客户' }], + containerRequirements: [{ id: 9007199254740994, oceanBookingId: 123, containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }], + containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 19500, warehouseId: 9007199254740996, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', customerId: 456, customerName: '客户' }], documents: [{ id: 9007199254740997, oceanBookingId: 123, fileId: 9007199254740998, documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: '128', contentType: 'application/pdf', valid: true, uploadedBy: 7, uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }], histories: [], }) @@ -25,22 +25,22 @@ describe('ocean bookings api', () => { }) it('uses the frozen single-query link candidate contract and normalizes long ids', async () => { - vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HC', capacityCbm: '76', maxWeightKg: '28000', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } }) - const query = { warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HC', page: 1, size: 200 } + vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HQ', capacityCbm: '68', maxWeightKg: '19500', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } }) + const query = { warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 } const result = await listOceanBookingLinkCandidates(query) expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/link-candidates', { params: query }) - expect(result.records[0]).toMatchObject({ oceanBookingId: '9007199254740992', requirementId: '9007199254740994', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1 }) + expect(result.records[0]).toMatchObject({ oceanBookingId: '9007199254740992', requirementId: '9007199254740994', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1 }) }) it('uses version zero for create and extracts the id from the returned Detail', async () => { const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() }) - const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }] } }) + const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, parties: [], containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }] } }) const sentPayload = post.mock.calls[0]?.[1] as { expectedVersion: number; data: Record } expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null } }) expect(sentPayload).not.toMatchObject({ data: { customerId: '', customerName: '' } }) - expect(sentPayload.data).toMatchObject({ containerRequirements: [{ containerType: '40HC', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }] }) + expect(sentPayload.data).toMatchObject({ containerRequirements: [{ containerType: '40HQ', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }] }) expect(sentPayload.data).not.toHaveProperty('containers') expect(sentPayload.data).not.toHaveProperty('warehouseName') expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) @@ -56,7 +56,7 @@ describe('ocean bookings api', () => { const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(4) }) const remove = vi.spyOn(http, 'delete').mockResolvedValue({ code: '200', message: 'ok', data: null }) const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(5, 'RELEASED') }) - const data = { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [] } + const data = { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, parties: [], containerRequirements: [] } await updateOceanBookingDraft('123', { expectedVersion: 3, data }) await deleteOceanBookingDraft('123', 4) await releaseOceanBooking('123', { expectedVersion: 4 }) diff --git a/src/modules/ocean-bookings/containerRequirements.test.ts b/src/modules/ocean-bookings/containerRequirements.test.ts index 9a556411..4363467a 100644 --- a/src/modules/ocean-bookings/containerRequirements.test.ts +++ b/src/modules/ocean-bookings/containerRequirements.test.ts @@ -4,9 +4,9 @@ import { containerRequirementError, createBlankContainerRequirement, selectConta describe('ocean booking container requirements', () => { it('starts with one requirement and applies the fixed platform specification', () => { const requirement = createBlankContainerRequirement() - expect(requirement).toEqual({ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }) - selectContainerRequirementType(requirement, '20GP') - expect(requirement).toMatchObject({ containerType: '20GP', capacityCbm: 33, maxWeightKg: 28000 }) + expect(requirement).toEqual({ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }) + selectContainerRequirementType(requirement, '40GP') + expect(requirement).toMatchObject({ containerType: '40GP', capacityCbm: 58, maxWeightKg: 19500 }) }) it('requires positive quantities and fixed specifications', () => { diff --git a/src/modules/ocean-bookings/containerRequirements.ts b/src/modules/ocean-bookings/containerRequirements.ts index 06c30b3f..73a55b1a 100644 --- a/src/modules/ocean-bookings/containerRequirements.ts +++ b/src/modules/ocean-bookings/containerRequirements.ts @@ -1,8 +1,8 @@ import type { OceanBookingContainerRequirement } from './types' -import { applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' +import { applyContainerSpecification, containerSpecification, DEFAULT_CONTAINER_TYPE } from '@/shared/domain/containerSpecifications' export function createBlankContainerRequirement(): OceanBookingContainerRequirement { - return { containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 } + return { containerType: DEFAULT_CONTAINER_TYPE, containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 } } export function selectContainerRequirementType(requirement: OceanBookingContainerRequirement, containerType: string) { diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts index f70510f3..fa788e53 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts @@ -41,7 +41,7 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading shippingCompanyId: '10', shippingCompanyName: '测试船公司', carrierCode: 'MATSON' as const, bookingAgentId: '20', bookingAgentName: '订舱代理', vesselName: 'TEST VESSEL', voyageNo: 'V001', departurePortCode: 'CNSHA', departurePortName: '上海', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'ONT8', serviceTerm: 'CY-CY', freightTerm: 'PREPAID', + placeOfDelivery: 'ONT8', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: null, createdAt: null, updatedAt: null, orderNo: null, carrierName: null, bookingAgent: null, pol: '上海', pod: '洛杉矶', mblShipper: null, mblConsignee: null, soNo: null, cutoffAt: null, @@ -55,7 +55,7 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading { partyType: 'CONSIGNEE', partyName: '收货人' }, { partyType: 'NOTIFY_PARTY', partyName: '通知人' }, ], - containerRequirements: [{ requirementId: '31', containerType: '40HC', containerKind: 'FCL', quantity: 2, remainingQuantity: 2, capacityCbm: 76, maxWeightKg: 28000 }], + containerRequirements: [{ requirementId: '31', containerType: '40HQ', containerKind: 'FCL', quantity: 2, remainingQuantity: 2, capacityCbm: 68, maxWeightKg: 19500 }], containers: [], documents: [{ id: '41', oceanBookingId: '123', fileId: '51', documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: 128, contentType: 'application/pdf', valid: true, uploadedBy: '7', uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }], histories: [], statusHistory: [], diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index b059682d..49cefbdb 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -94,7 +94,7 @@ function blankParties(): OceanBookingParty[] { } function blankData(): OceanBookingDraftData { return { - customerId: null, customerName: null, channelCode: '', warehouseId: '', serviceTerm: null, freightTerm: null, parties: blankParties(), + customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(), billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, shippingCompanyId: null, shippingCompanyName: null, carrierCode: null, containerRequirements: [createBlankContainerRequirement()], @@ -155,8 +155,6 @@ function draftPayload(): OceanBookingDraftData { customerName: customerId ? form.data.customerName : null, channelCode: form.data.channelCode?.trim() || null, warehouseId: form.data.warehouseId?.trim() || null, - serviceTerm: form.data.serviceTerm?.trim() || null, - freightTerm: form.data.freightTerm?.trim() || null, billOfLadingNo: form.data.billOfLadingNo.trim(), vesselName: form.data.vesselName.trim(), voyageNo: form.data.voyageNo.trim(), @@ -243,7 +241,7 @@ function applyDetail(value: OceanBookingDetail) { bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName, departurePortCode: value.departurePortCode, departurePortName: value.departurePortName, destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName, - placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm, + placeOfDelivery: value.placeOfDelivery, remark: value.remark, parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(), containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()], @@ -307,7 +305,6 @@ function releaseValidationError() { [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'], [form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'], [form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'], - [form.data.serviceTerm, 'Service Term'], [form.data.freightTerm, 'Freight Term'], [form.data.placeOfDelivery, '交货地点'], [form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'], ] @@ -419,12 +416,8 @@ onMounted(() => { void load(); void loadOptions() })
-
-

条款与柜型需求

-
- - -
+
+

柜型需求

diff --git a/src/modules/ocean-bookings/types.ts b/src/modules/ocean-bookings/types.ts index 2d56fbe2..526ba594 100644 --- a/src/modules/ocean-bookings/types.ts +++ b/src/modules/ocean-bookings/types.ts @@ -1,3 +1,5 @@ +import type { ContainerType } from '@/shared/domain/containerSpecifications' + export type OceanBookingStatus = 'DRAFT' | 'RELEASED' export type OceanBookingPrintType = 'SHIPPING_INSTRUCTION' | 'BILL_OF_LADING_CONFIRMATION' export type OceanBookingDocumentType = 'BOOKING_CONFIRMATION' | 'BILL_OF_LADING' @@ -21,7 +23,7 @@ export interface OceanBookingDocument { } export interface OceanBookingDocumentMutation { document: OceanBookingDocument; bookingVersion: number } export interface OceanBookingDocumentInvalidation { documentId: string; bookingVersion: number } -export type OceanBookingContainerType = '20GP' | '40GP' | '40HC' | '40NR' | '45HC' +export type OceanBookingContainerType = ContainerType export type OceanBookingCarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER' export interface OceanBookingAgentOption { @@ -58,7 +60,7 @@ export interface OceanBookingDraftData { estimatedSailingAt: string | null; estimatedArrivalAt: string | null shippingCompanyId?: string | null; shippingCompanyName?: string | null; carrierCode?: OceanBookingCarrierCode | null; bookingAgentId?: string | null; bookingAgentName?: string | null departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null - placeOfDelivery?: string | null; serviceTerm: string | null; freightTerm: string | null + placeOfDelivery?: string | null remark?: string | null } export interface OceanBookingResource { @@ -70,7 +72,7 @@ export interface OceanBookingResource { shippingCompanyId: string | null; shippingCompanyName: string | null; carrierCode: OceanBookingCarrierCode | null; bookingAgentId: string | null; bookingAgentName: string | null vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName: string | null destinationPortCode: string | null; destinationPortName: string | null; placeOfDelivery: string | null - serviceTerm: string | null; freightTerm: string | null; estimatedSailingAt: string | null; estimatedArrivalAt: string | null + estimatedSailingAt: string | null; estimatedArrivalAt: string | null remark: string | null createdAt: string | null; updatedAt: string | null orderNo: string | null; carrierName: string | null; bookingAgent: string | null; pol: string | null; pod: string | null diff --git a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts index ccb9be01..c4a65bca 100644 --- a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts +++ b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts @@ -46,7 +46,7 @@ const purchase: OverseasPurchaseDetail = { } const truck: LocalTransportOrder = { id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' } const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] } -const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' } +const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' } const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] } function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia } diff --git a/src/modules/overseas-business/pages/TransportOrderFormView.vue b/src/modules/overseas-business/pages/TransportOrderFormView.vue index 43f76f3f..bfc13416 100644 --- a/src/modules/overseas-business/pages/TransportOrderFormView.vue +++ b/src/modules/overseas-business/pages/TransportOrderFormView.vue @@ -13,6 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' +import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '@/shared/domain/containerSpecifications' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness' import { overseasBusinessPermissions } from '../permissions' @@ -34,7 +35,7 @@ interface TransportFormModel { requestedDate: string | null; remark: string | null; status: FormStatus; version: number; idempotencyKey: string } function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` } -const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: '20GP', palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() }) +const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() }) const form = reactive(initial()); const loading = ref(false); const saving = ref(false) const loadError = ref(null); const submitError = ref(null) const customers = ref([]); const customersLoading = ref(false); const customersError = ref('') @@ -114,7 +115,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
{{ item.label }}是否
{{ item.label }}
-
+
取消{{ editing ? '保存修改' : '提交订单' }}
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue index f9baa238..299cfeee 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue @@ -237,6 +237,5 @@ onMounted(load) .detail-surface { min-width: 0; min-height: 0; margin-top: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; } .info-panel, .table-panel { min-width: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; } .info-panel h2, .table-panel h2 { margin: 0; padding: 0 0 var(--admin-space-3); border-bottom: 0; color: var(--cc-text); font-size: var(--admin-font-section); } -.table-panel :deep(.data-table__toolbar) { min-height: 0; } diff --git a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts index d9e6336c..ea25044f 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts @@ -4,11 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' import TruckAppointmentForm from './TruckAppointmentForm.vue' import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types' +import { listSuppliers } from '@/modules/suppliers' import { getOceanBooking } from '@/modules/ocean-bookings' import { listTruckAppointmentCandidates } from '../api/platformTrucking' vi.mock('@/modules/suppliers', () => ({ - listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }), + listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE', contactName: '张调度', phone: '13800000001' }, { id: 'carrier-2', name: '第二车队', category: 'TRUCKING', status: 'ACTIVE', contactName: '李调度', phone: '13800000002' }] }), })) vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), @@ -156,15 +157,61 @@ describe('TruckAppointmentForm booking-only behavior', () => { wrapper.unmount() }) - it('offers only RMB for domestic trucking payables', async () => { - const wrapper = mount(TruckAppointmentForm, { - props: { modelValue: { ...model, payableAmount: '88.00', currency: 'RMB' } }, - global: { plugins: [ElementPlus] }, - }) + it('starts with five main fields and optional collapsed dispatch information', async () => { + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) await flushPromises() - const currencySelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择币种')! - expect(currencySelect.props('modelValue')).toBe('RMB') - expect(currencySelect.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['RMB']) + expect(wrapper.findAll('.appointment-grid > .el-form-item').filter(item => !item.element.closest('details'))).toHaveLength(5) + expect((wrapper.get('details').element as HTMLDetailsElement).open).toBe(false) + for (const label of ['门店名称', '司机身份证号', '毛重(kg)', '体积(m³)', '在途分钟', '托盘数量', '查看附件引用']) expect(wrapper.text()).not.toContain(label) + expect(wrapper.findAllComponents({ name: 'ElSelect' })).toHaveLength(2) + expect(wrapper.text()).toContain('元') + expect(wrapper.text()).toContain('保存预约') + wrapper.unmount() + }) + + it('copies supplier contacts and replaces or clears them when switching suppliers', async () => { + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }) + const supplier = wrapper.findAllComponents({ name: 'ElSelect' })[1]! + supplier.vm.$emit('update:modelValue', 'carrier-1') + await flushPromises() + expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-1', fleetContact: '张调度', fleetPhone: '13800000001', currency: 'RMB' }) + expect(wrapper.get('[aria-label="车队联系方式"]').text()).toContain('张调度') + supplier.vm.$emit('update:modelValue', 'carrier-2') + await flushPromises() + expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-2', fleetContact: '李调度', fleetPhone: '13800000002' }) + supplier.vm.$emit('update:modelValue', '') + await flushPromises() + expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: null, carrierName: null, fleetContact: null, fleetPhone: null }) + wrapper.unmount() + }) + + it('preserves saved contact overrides and supplementary values when editing', async () => { + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', fleetContact: '本次调度', fleetPhone: '13900000000', storeName: '目的门店', grossWeightKg: '120.00' }, resource: resource('appointment-1', 'booking-1') }, global: { plugins: [ElementPlus] } }) + await flushPromises() + wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'carrier-1') + await flushPromises() + expect(wrapper.get('[aria-label="车队联系方式"]').text()).toContain('本次调度') + expect(wrapper.text()).toContain('其他已存资料') + expect(wrapper.findAllComponents({ name: 'ElInput' }).some(input => input.props('modelValue') === '目的门店')).toBe(true) + wrapper.unmount() + }) + + it('keeps amount validation at two decimal places on the displayed field', async () => { + const valid = { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', plannedLoadingAt: '2026-09-19T09:00:00', payableAmount: '100.00' } + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: valid }, global: { plugins: [ElementPlus] } }) + await flushPromises() + await wrapper.setProps({ modelValue: { ...valid, billOfLadingNo: 'BL-1', payableAmount: '100.001' } }) + await flushPromises() + expect(wrapper.findAllComponents({ name: 'ElInput' }).find(input => input.props('placeholder') === '请输入应付拖车费')?.props('modelValue')).toBe('100.001') + const rule = wrapper.findComponent({ name: 'ElForm' }).props('rules').payableAmount[0] + const reject = vi.fn() + rule.validator(null, '100.001', reject) + expect(reject).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('应付拖车费') })) + const accept = vi.fn() + rule.validator(null, '100.00', accept) + expect(accept).toHaveBeenCalledWith(undefined) wrapper.unmount() }) }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.test.ts b/src/modules/trucking/components/TruckAppointmentForm.test.ts index 031f3ab8..15a29951 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.test.ts @@ -33,15 +33,12 @@ describe('TruckAppointmentForm interaction contract', () => { it('freezes warehouse and bill of lading snapshots without retaining customer data', () => { expect(source).toContain('draft.warehouseId = item.warehouseId') expect(source).toContain('draft.billOfLadingNo = item.billOfLadingNo') - expect(source).toContain('已从放舱订舱冻结资料带入') - expect(source).toContain(':model-value="draft.billOfLadingNo || \'\'" disabled') expect(source).not.toContain('customerId') expect(source).not.toContain('customerName') expect(source).not.toContain('label="客户"') }) it('keeps server-side validation authoritative and preserves recoverable option errors', () => { - expect(source).toContain('最终资格仍由服务端校验') expect(source).toContain('所选订舱缺少有效的订舱确认文件或提单文件') expect(source).toContain('所选订舱缺少有效提单号') expect(source).toContain("booking.status !== 'RELEASED'") @@ -72,12 +69,11 @@ describe('TruckAppointmentForm interaction contract', () => { expect(source).not.toContain('柜次') expect(source).not.toContain('装柜计划') expect(source).not.toContain('集装箱号') - expect(source).toContain('货柜资料在货柜与预装流程维护') }) it('keeps the form usable at 390px', () => { - expect(source).toContain('@media (max-width: 480px)') - expect(source).toContain('min-height: 40px') + expect(source).toContain('@media (max-width:480px)') + expect(source).toContain('min-height:40px') }) it('suppresses duplicate submits and truncates long booking or container labels', () => { @@ -86,21 +82,19 @@ describe('TruckAppointmentForm interaction contract', () => { expect(source).toContain('text-overflow:ellipsis') }) - it('uses payable-only fees with a fixed dynamic currency selector', () => { + it('shows fixed yuan beside the amount without an unnecessary currency selector', () => { expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB']") - expect(source).toContain('') - expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"') - expect(source).toContain('placeholder="请选择币种"') + expect(source).toContain('') + expect(source).not.toContain('placeholder="请选择币种"') expect(source).not.toContain('receivableAmount') - expect(source).not.toContain('应收拖车费') + expect(source).toContain('aria-label="订舱带入信息"') + expect(source).not.toContain('label="提单号"') }) - it('keeps the 1920 desktop contract compact without horizontal form overflow', () => { - expect(source).toContain('@media (min-width: 1200px)') - expect(source).toContain('max-width:25%; flex:0 0 25%') - expect(source).toContain('class="relation-column"') - expect(source).toContain('class="wide-column"') - expect(source).toContain('max-width:50%; flex-basis:50%') + it('keeps the compact appointment fields in two columns without horizontal overflow', () => { + expect(source).toContain('grid-template-columns:repeat(2,minmax(0,1fr))') + expect(source).toContain('max-width:1040px') + expect(source).toContain('grid-column:1 / -1') expect(source).not.toContain('overflow-x:auto') }) }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index 4e4c29e6..da18140b 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -3,7 +3,7 @@ import '@/styles/adminDensity.css' import { computed, onMounted, reactive, ref, watch } from 'vue' import type { FormInstance, FormRules } from 'element-plus' import { Eye, Save } from '@lucide/vue' -import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types' +import { normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types' import { listTruckAppointmentCandidates } from '../api/platformTrucking' import { listSuppliers, type Supplier } from '@/modules/suppliers' import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings' @@ -14,18 +14,19 @@ const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>() const formRef = ref() const relationWarning = ref('') +const dispatchDetailsOpen = ref(false) const bookingContainersLoading = ref(false) const bookingContainersError = ref(null) const bookingDocuments = ref([]) const relationLocked = computed(() => Boolean(props.resource)) -const defaults: Partial = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 } +const defaults: Partial = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00' } function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } } const draft = reactive(normalizeDraft(props.modelValue)) watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true }) watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true }) -const nonNegative = (label: string, integer = false) => ({ validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => { +const nonNegative = (label: string, integer = false, precision = 3) => ({ validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => { if (value === null || value === undefined || value === '') return callback() - const text = String(value).trim(); const valid = integer ? /^\d+$/.test(text) : /^(?:\d+)(?:\.\d{1,3})?$/.test(text) + const text = String(value).trim(); const valid = integer ? /^\d+$/.test(text) : new RegExp(`^\\d+(?:\\.\\d{1,${precision}})?$`).test(text) callback(valid ? undefined : new Error(`${label}请输入${integer ? '非负整数' : '非负数字'}`)) }, trigger: ['blur', 'change'] }) const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => { @@ -39,11 +40,11 @@ const rules: FormRules = { if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号')) return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件')) }, trigger: 'change' }], - carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }], - plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }], + carrierId: [{ required: true, message: '请选择拖车供应商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }], + plannedLoadingAt: [{ required: true, message: '请选择预约装柜时间', trigger: 'change' }], fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')], packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)], - grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], + grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费', false, 2)], } const carriers = ref([]) const candidates = ref([]) @@ -76,7 +77,17 @@ const selectedBookingFallback = computed(() => { if (!id || !relationLocked.value || String(snapshot?.oceanBookingId) !== String(id)) return null return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` } }) -function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null } +function selectCarrier(carrierId: string) { + if (String(draft.carrierId || '') === carrierId) return + const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId) + draft.carrierId = carrier?.id || null + draft.carrierName = carrier?.name || null + draft.fleetContact = carrier?.contactName || null + draft.fleetPhone = carrier?.phone || null +} +function toggleDispatchDetails(event: Event) { + dispatchDetailsOpen.value = (event.target as HTMLDetailsElement).open +} async function loadCurrentBookingContext(bookingId: string) { const sequence = ++bookingLoadSequence bookingContainersLoading.value = true @@ -125,7 +136,7 @@ async function loadOptions() { if (sequence !== optionsLoadSequence) return if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE') if (results[1].status === 'fulfilled') candidates.value = results[1].value.records - const failed = ['承运商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected') + const failed = ['拖车供应商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected') if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败` optionsLoading.value = false if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId)) @@ -139,8 +150,14 @@ async function loadOptions() { } } } -async function validate() { return formRef.value?.validate() } -async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') } +async function validate() { + try { return await formRef.value?.validate() } + catch (fields) { + if (fields && typeof fields === 'object' && ('fleetPhone' in fields || 'driverPhone' in fields)) dispatchDetailsOpen.value = true + return false + } +} +async function submitForm() { if (props.submitting) return; if (await validate()) emit('submit') } defineExpose({ validate, loadOptions }) onMounted(() => { void loadOptions() }) watch(() => props.readonly, (readonly) => { if (!readonly && !carriers.value.length) void loadOptions() }) @@ -148,38 +165,63 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v diff --git a/src/modules/trucking/pages/TruckAppointmentCreateView.vue b/src/modules/trucking/pages/TruckAppointmentCreateView.vue index 3334d083..ca95e276 100644 --- a/src/modules/trucking/pages/TruckAppointmentCreateView.vue +++ b/src/modules/trucking/pages/TruckAppointmentCreateView.vue @@ -10,10 +10,9 @@ import DetailPageLayout from '@/shared/components/DetailPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import TruckAppointmentForm from '../components/TruckAppointmentForm.vue' -import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue' import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking' import { truckAppointmentStatusLabel, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types' -const route = useRoute(); const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref(null) +const route = useRoute(); const router = useRouter(); const saving = ref(false); const error = ref(null) const initialOceanBookingId = String(route.query.oceanBookingId || '').trim() const form = reactive({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' }) const existingAppointment = ref(null) @@ -74,8 +73,7 @@ defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, exi {{ existingCanEdit ? '继续编辑' : '查看拖车单' }} -
- +
diff --git a/src/modules/trucking/pages/TruckAppointmentsView.test.ts b/src/modules/trucking/pages/TruckAppointmentsView.test.ts index 7065ddb2..24ecb2a8 100644 --- a/src/modules/trucking/pages/TruckAppointmentsView.test.ts +++ b/src/modules/trucking/pages/TruckAppointmentsView.test.ts @@ -193,9 +193,10 @@ describe('TruckAppointmentsView', () => { expect(source).toContain(':total="total" stripe preference-key="trucking-appointments"') }) - it('uses compact status/type filters and a wider composite keyword field', () => { + it('uses unified container types and a wider composite keyword field', () => { expect(source).toContain("key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司', width: 280") expect(source).toContain("key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160") expect(source).toContain("key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', width: 160") + expect(source).toContain('CONTAINER_SPECIFICATIONS.map') }) }) diff --git a/src/modules/trucking/pages/TruckAppointmentsView.vue b/src/modules/trucking/pages/TruckAppointmentsView.vue index 793d47a1..c15c7865 100644 --- a/src/modules/trucking/pages/TruckAppointmentsView.vue +++ b/src/modules/trucking/pages/TruckAppointmentsView.vue @@ -11,6 +11,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import AdminPage from '@/shared/components/AdminPage.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' +import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listTruckAppointments } from '../api/platformTrucking' import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types' @@ -21,9 +22,7 @@ const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '' }); const fields = computed(() => [ { key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司', width: 280 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) }, - { key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', width: 160, options: [ - { value: '20GP', label: '20GP' }, { value: '40GP', label: '40GP' }, { value: '40HC', label: '40HC' }, - ] }, + { key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', width: 160, options: CONTAINER_SPECIFICATIONS.map(({ type }) => ({ value: type, label: type })) }, ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions', hideable: false, movable: false }, diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index ded21282..a873d320 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -2818,7 +2818,6 @@ onBeforeUnmount(() => { .measurement-section-title:has([aria-expanded="false"]) { border-bottom: 1px solid var(--cc-border); } .fulfillment-facts + .fulfillment-facts { border-top: 0; } @media (min-width: 961px) { - .table-card :deep(.data-table__toolbar.is-compact) { min-height: 44px; padding: var(--admin-space-2, 6px) var(--admin-space-5, 12px); } .table-card :deep(.data-table__pagination) { min-height: 44px; padding: var(--admin-space-2, 6px) var(--admin-space-5, 12px); } } diff --git a/src/shared/components/DataTable.vue b/src/shared/components/DataTable.vue index 246e9bdf..fd23c201 100644 --- a/src/shared/components/DataTable.vue +++ b/src/shared/components/DataTable.vue @@ -383,8 +383,8 @@ function setPage(value: number) { .data-table :deep(.el-table th.el-table__cell) { font-size: var(--cc-list-table-font-size); font-weight: var(--cc-list-table-header-font-weight); } .data-table :deep(.el-table .el-button) { font-size: var(--cc-list-action-font-size); } .data-table :deep(.el-table__empty-text) { font-size: var(--cc-list-table-font-size); } -.data-table__toolbar { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); } -.data-table__toolbar.is-compact { min-height: 56px; justify-content: flex-end; padding: 8px 14px; } +.data-table__toolbar { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); padding: var(--admin-space-2, 6px) var(--admin-space-5, 12px); border-bottom: 1px solid var(--cc-border); } +.data-table__toolbar.is-compact { justify-content: flex-end; } .data-table__toolbar-start { min-width: 0; flex: 1; } .data-table__toolbar-start :deep(h2) { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; } .data-table__toolbar-start :deep(p) { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } @@ -406,8 +406,6 @@ function setPage(value: number) { .data-table :deep(.data-table__system-cell .el-button + .el-dropdown) { margin-left: var(--cc-list-action-sibling-margin, 8px); } @media (min-width: 1920px) { - .data-table__toolbar { min-height: 64px; gap: 12px; padding: 10px 14px; } - .data-table__toolbar.is-compact { min-height: 50px; padding: 6px 12px; } .data-table__toolbar-actions, .data-table__business-actions, .data-table__utility-actions, diff --git a/src/shared/domain/containerSpecifications.test.ts b/src/shared/domain/containerSpecifications.test.ts new file mode 100644 index 00000000..12dd231f --- /dev/null +++ b/src/shared/domain/containerSpecifications.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, containerSpecification } from './containerSpecifications' + +describe('container specifications', () => { + it('uses the three confirmed container types and defaults to 40HQ', () => { + expect(CONTAINER_SPECIFICATIONS.map((item) => item.type)).toEqual(['40GP', '40HQ', '45HQ']) + expect(DEFAULT_CONTAINER_TYPE).toBe('40HQ') + }) + + it('keeps practical loading ranges and the common cargo weight limit', () => { + expect(CONTAINER_SPECIFICATIONS.map(({ type, recommendedMinCbm, capacityCbm, maxWeightKg }) => ( + { type, recommendedMinCbm, capacityCbm, maxWeightKg } + ))).toEqual([ + { type: '40GP', recommendedMinCbm: 56, capacityCbm: 58, maxWeightKg: 19500 }, + { type: '40HQ', recommendedMinCbm: 65, capacityCbm: 68, maxWeightKg: 19500 }, + { type: '45HQ', recommendedMinCbm: 82, capacityCbm: 86, maxWeightKg: 19500 }, + ]) + expect(containerSpecification('40hc')).toBeNull() + }) +}) diff --git a/src/shared/domain/containerSpecifications.ts b/src/shared/domain/containerSpecifications.ts index 1a4925ba..db0a5136 100644 --- a/src/shared/domain/containerSpecifications.ts +++ b/src/shared/domain/containerSpecifications.ts @@ -1,12 +1,13 @@ export const CONTAINER_SPECIFICATIONS = [ - { type: '20GP', capacityCbm: 33, maxWeightKg: 28000 }, - { type: '40GP', capacityCbm: 67, maxWeightKg: 28000 }, - { type: '40HC', capacityCbm: 76, maxWeightKg: 28000 }, - { type: '40NR', capacityCbm: 67, maxWeightKg: 28000 }, - { type: '45HC', capacityCbm: 86, maxWeightKg: 28000 }, + { type: '40GP', recommendedMinCbm: 56, capacityCbm: 58, maxWeightKg: 19500, description: '美线电商较少使用,通常优先选择 40HQ' }, + { type: '40HQ', recommendedMinCbm: 65, capacityCbm: 68, maxWeightKg: 19500, description: '轻泡货首选,适合家居、玩具、日用品' }, + { type: '45HQ', recommendedMinCbm: 82, capacityCbm: 86, maxWeightKg: 19500, description: '抛货量大时考虑,底盘和海外仓预约资源较少' }, ] as const +export const DEFAULT_CONTAINER_TYPE = '40HQ' as const + export type ContainerSpecification = (typeof CONTAINER_SPECIFICATIONS)[number] +export type ContainerType = ContainerSpecification['type'] export function containerSpecification(containerType: string | null | undefined): ContainerSpecification | null { const normalized = String(containerType || '').trim().toUpperCase() diff --git a/src/styles/adminDensity.css b/src/styles/adminDensity.css index 033d9b70..14bdd2e4 100644 --- a/src/styles/adminDensity.css +++ b/src/styles/adminDensity.css @@ -22,7 +22,6 @@ .admin-density .el-table .cell, .admin-density-dialog .el-table .cell { line-height: 22px; } .admin-density .el-table .el-button--small, .admin-density-dialog .el-table .el-button--small { height: var(--admin-action-height, 28px); padding: var(--admin-space-1, 4px) var(--admin-space-3, 8px); } .admin-density .el-table__body tr.el-table__row--striped td.el-table__cell { background: var(--admin-row-stripe); } -.admin-density .data-table .data-table__toolbar { min-height: 44px; padding: var(--admin-space-2, 6px) var(--admin-space-5, 12px); } .admin-density .data-table .data-table__pagination { min-height: 48px; padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); } .admin-density .el-empty { padding: var(--admin-space-5, 12px) 0; } .admin-density .el-empty__image { display: none; } diff --git a/src/styles/adminDensity.test.ts b/src/styles/adminDensity.test.ts index e21bfdba..0c0cac4e 100644 --- a/src/styles/adminDensity.test.ts +++ b/src/styles/adminDensity.test.ts @@ -74,7 +74,7 @@ const notificationConsumers = [ 'notifications/pages/MessageTemplatesView.vue', 'notifications/components/MessageComposerDialog.vue', 'notifications/components/TemplateEditorDialog.vue', ] -const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue', 'dashboard/pages/DashboardView.vue'] +const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue'] const consumers = [ ...notificationConsumers, ...systemConsumers, @@ -150,6 +150,13 @@ const cssRoot = (css: string) => { } describe('approved Admin density ownership', () => { + it('keeps dashboard visualizations outside detail-field table styling', () => { + const dashboard = read('modules/dashboard/pages/DashboardView.vue') + expect(dashboard).toContain('class="dashboard-metrics"') + expect(dashboard).toContain('class="dashboard-metric-mark"') + expect(dashboard).not.toContain('density-fields') + }) + it('opts application-shell confirmations in even before a business page loads', () => { const entries = ['app/layouts/AppLayout.vue', 'app/components/WorkspaceTabs.vue', 'app/router/index.ts', 'main.ts'] let count = 0 @@ -459,6 +466,24 @@ describe('approved Admin density ownership', () => { }) }) + it('keeps desktop table toolbar dimensions owned by DataTable without cascade overrides', () => { + const tableCss = parse(read('shared/components/DataTable.vue')).descriptor.styles.map(style => style.content).join('\n') + const dimensions: Record = { padding: [], 'min-height': [] } + cssRoot(tableCss).walkRules(rule => { + if (!rule.selectors.includes('.data-table__toolbar') && !rule.selectors.includes('.data-table__toolbar.is-compact')) return + // Preserve the existing mobile layout; the shared desktop dimensions have one owner. + if (rule.parent?.type === 'atrule' && /max-width/.test(rule.parent.params)) return + rule.walkDecls(decl => { if (dimensions[decl.prop]) dimensions[decl.prop].push(decl.value) }) + }) + expect(dimensions).toEqual({ padding: ['var(--admin-space-2, 6px) var(--admin-space-5, 12px)'], 'min-height': ['44px'] }) + for (const { file, css } of [{ file: 'adminDensity.css', css: shared }, ...styles]) cssRoot(css).walkRules(rule => { + if (!/\.data-table__toolbar(?:\b|\.)/.test(rule.selector)) return + rule.walkDecls(decl => { + expect(decl.prop, `${file}: toolbar dimensions belong in DataTable`).not.toMatch(/^(padding(?:-.+)?|height|min-height)$/) + }) + }) + }) + it('keeps conditional shared consumers opt-in instead of enabling other menus', () => { expect(read('shared/components/DataTable.vue')).toContain('margin-left: var(--cc-list-action-sibling-margin, 8px)') expect(shared).toContain('.density-actions { --cc-list-action-sibling-margin: 0px;')
操作柜型箱种数量容量限重剩余可关联