优化
This commit is contained in:
@@ -18,7 +18,7 @@ vi.mock('@/shared/api/http', () => ({
|
||||
unwrapApiResponse: <T>(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',
|
||||
} })
|
||||
|
||||
|
||||
@@ -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<ApiErrorDetails | null>(null)
|
||||
const simulationChannels = ref<OceanLogisticsChannel[]>([])
|
||||
const simulationChannelsLoading = ref(false)
|
||||
const simulationFormRef = ref<FormInstance>()
|
||||
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelGroupCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
const simulationForm = reactive<ContainerSimulationCreateRequest>({ 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<ContainerSimulationCreateRequest> = {
|
||||
}
|
||||
|
||||
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() {
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item>
|
||||
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
||||
<el-form-item label="固定规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /></el-form-item>
|
||||
<el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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<HTMLElement>('.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<HTMLElement>('.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()
|
||||
|
||||
@@ -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<void> | 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,
|
||||
}
|
||||
|
||||
@@ -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('真实柜号')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { ElButton } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
@@ -32,15 +30,16 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits =
|
||||
}
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计' },
|
||||
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' },
|
||||
{
|
||||
label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU',
|
||||
note: operationalMetrics.value
|
||||
? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}`
|
||||
: '已开船且尚未到仓',
|
||||
tone: 'green',
|
||||
},
|
||||
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用' },
|
||||
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成' },
|
||||
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple' },
|
||||
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' },
|
||||
])
|
||||
|
||||
async function toggleFullscreen() {
|
||||
@@ -78,57 +77,73 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="dashboardRef" class="dashboard-view admin-density">
|
||||
<section ref="dashboardRef" class="dashboard-view">
|
||||
<AdminPage as="div" spacing="normal">
|
||||
<section class="dashboard-context" aria-label="数据看板说明">
|
||||
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<el-button class="dashboard-control" :disabled="metricsLoading" @click="loadMetrics">
|
||||
<button type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
<RefreshCw :size="16" aria-hidden="true" />
|
||||
刷新数据
|
||||
</el-button>
|
||||
<el-button class="dashboard-control" :aria-pressed="isFullscreen" @click="toggleFullscreen">
|
||||
</button>
|
||||
<button type="button" class="dashboard-outline-button" :aria-pressed="isFullscreen" @click="toggleFullscreen">
|
||||
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
|
||||
<Expand v-else :size="16" aria-hidden="true" />
|
||||
{{ isFullscreen ? '退出全屏' : '全屏展示' }}
|
||||
</el-button>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dl class="dashboard-metrics density-fields" aria-label="核心业务指标">
|
||||
<div v-for="metric in metrics" :key="metric.label" class="dashboard-metric">
|
||||
<dt>{{ metric.label }}</dt>
|
||||
<dd>
|
||||
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
|
||||
<span class="dashboard-metric-note">{{ metric.note }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="dashboard-metrics" aria-label="核心业务指标">
|
||||
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
|
||||
<span class="dashboard-metric-mark" aria-hidden="true" />
|
||||
<p>{{ metric.label }}</p>
|
||||
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
|
||||
<span>{{ metric.note }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</AdminPage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-view { min-width: 0; color: var(--cc-text); }
|
||||
.dashboard-view:fullscreen { overflow: auto; padding: var(--admin-space-7); background: var(--cc-page, #f4f6f8); }
|
||||
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--admin-space-6); }
|
||||
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.55; }
|
||||
.dashboard-head-actions { display: flex; align-items: center; gap: var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); white-space: nowrap; }
|
||||
.dashboard-head-actions .el-button + .el-button { margin-left: 0; }
|
||||
.dashboard-metrics .dashboard-metric dd { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-2); }
|
||||
.dashboard-metric strong { font-size: var(--admin-font-title); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.dashboard-metric strong small, .dashboard-metric-note { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: normal; }
|
||||
.dashboard-view { --dashboard-panel: var(--cc-surface, #fff); display: block; min-width: 0; color: var(--cc-text, #172033); }
|
||||
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: var(--cc-page, #f4f6f8); }
|
||||
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.55; }
|
||||
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
||||
.dashboard-outline-button { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
||||
.dashboard-outline-button:hover:not(:disabled) { border-color: var(--cc-primary); color: var(--cc-primary); }
|
||||
.dashboard-outline-button:disabled { cursor: wait; opacity: .65; }
|
||||
.dashboard-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
.dashboard-metric { position: relative; min-width: 0; overflow: hidden; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--dashboard-panel); }
|
||||
.dashboard-metric-mark { position: absolute; top: -38px; right: -30px; width: 110px; height: 110px; border-radius: 50%; background: #edf1ff; }
|
||||
.dashboard-metric.is-green .dashboard-metric-mark { background: #e8f7ee; }
|
||||
.dashboard-metric.is-purple .dashboard-metric-mark { background: #f1eaff; }
|
||||
.dashboard-metric.is-rose .dashboard-metric-mark { background: #fdeaf1; }
|
||||
.dashboard-metric p { position: relative; margin: 0 0 25px; color: #5f6573; font-size: 13px; font-weight: 700; }
|
||||
.dashboard-metric strong { position: relative; display: block; overflow-wrap: anywhere; font-size: 31px; font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: -.02em; }
|
||||
.dashboard-metric strong small { color: #525866; font-size: 12px; font-weight: 600; letter-spacing: 0; }
|
||||
.dashboard-metric > span:last-child { position: relative; display: block; margin-top: 17px; color: #8a91a0; font-size: 11px; }
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard-metrics.density-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.dashboard-context { align-items: flex-start; flex-direction: column; }
|
||||
.dashboard-head-actions { align-items: flex-start; flex-direction: column; gap: 9px; white-space: normal; }
|
||||
.dashboard-metrics.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.dashboard-metrics.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dashboard-metrics .dashboard-metric { grid-template-columns: minmax(0, 1fr); }
|
||||
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.dashboard-metric { min-height: 116px; padding: 13px; }
|
||||
.dashboard-metric-mark { top: -30px; right: -24px; width: 80px; height: 80px; }
|
||||
.dashboard-metric p { margin-bottom: 12px; font-size: 11px; }
|
||||
.dashboard-metric strong { font-size: 23px; }
|
||||
.dashboard-metric strong small { font-size: 10px; }
|
||||
.dashboard-metric > span:last-child { margin-top: 10px; font-size: 10px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-outline-button { transition: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,7 @@ const preloadSummary = {
|
||||
warehouseName: '义乌仓',
|
||||
vesselName: 'MATSON',
|
||||
voyageNo: '001E',
|
||||
containerType: '40HC',
|
||||
containerType: '40HQ',
|
||||
status: 'LOADING',
|
||||
skuCount: '2',
|
||||
totalPackageCount: '10',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -278,7 +278,7 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
|
||||
<div class="confirm-content">
|
||||
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="真实集装箱号可在提柜或预装详情补录,缺号不阻断此前流程;PDA 确认装柜完成前由服务端执行最终门禁。系统会原子确认本计划包含的全部入库预装票据,并由服务端按真实条件创建唯一 PDA 任务;当前页面不会伪造 PDA 成功。" />
|
||||
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
|
||||
<el-descriptions v-if="detail" :column="1" border>
|
||||
<el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
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 })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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() })
|
||||
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="booking-group" aria-label="条款与柜型需求">
|
||||
<h3>条款与柜型需求</h3><div class="booking-group__body">
|
||||
<div class="booking-terms">
|
||||
<el-form-item label="主要运输条款 Service Term" prop="serviceTerm"><el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term" /></el-form-item>
|
||||
<el-form-item label="运费条款 Freight Term" prop="freightTerm"><el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></el-form-item>
|
||||
</div>
|
||||
<section class="booking-group" aria-label="柜型需求">
|
||||
<h3>柜型需求</h3><div class="booking-group__body">
|
||||
<div class="container-table-wrap" aria-label="柜型需求">
|
||||
<table class="container-table density-table">
|
||||
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
|
||||
@@ -114,7 +115,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
|
||||
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
|
||||
<FormSection class="density-form-section" v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType" clearable><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
|
||||
</DetailPageLayout></AdminPage>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('<template #append>{{ draft.currency }}</template>')
|
||||
expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"')
|
||||
expect(source).toContain('placeholder="请选择币种"')
|
||||
expect(source).toContain('<template #append>元</template>')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<FormInstance>()
|
||||
const relationWarning = ref('')
|
||||
const dispatchDetailsOpen = ref(false)
|
||||
const bookingContainersLoading = ref(false)
|
||||
const bookingContainersError = ref<ApiErrorDetails | null>(null)
|
||||
const bookingDocuments = ref<OceanBookingDocument[]>([])
|
||||
const relationLocked = computed(() => Boolean(props.resource))
|
||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
|
||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00' }
|
||||
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
|
||||
const draft = reactive<SaveTruckAppointmentRequest>(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<Supplier[]>([])
|
||||
const candidates = ref<TruckAppointmentCandidate[]>([])
|
||||
@@ -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
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form admin-density density-form">
|
||||
<div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,货柜资料在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form admin-density density-form" :class="{ 'truck-form--create': !resource }">
|
||||
<div class="relation-note">
|
||||
<span>选择订舱和拖车供应商,约定装柜时间即可保存预约。</span>
|
||||
<div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div>
|
||||
</div>
|
||||
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24"><el-divider content-position="left">订舱(必填)</el-divider></el-col>
|
||||
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择可创建拖车单的已放舱订舱" :loading="optionsLoading" no-data-text="暂无可创建拖车单的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo || '缺失'}`" :value="item.id" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择承运商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前承运商(需重新选择)'" :value="String(draft.carrierId)" disabled /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="提单号"><el-input :model-value="draft.billOfLadingNo || ''" disabled placeholder="选择订舱后自动带入" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱冻结资料带入</div></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-input :model-value="selectedReleasedBooking?.warehouseName || resource?.warehouseName || ''" disabled placeholder="选择订舱后自动带入" /></el-form-item></el-col>
|
||||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" placeholder="请输入车队联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" placeholder="请输入司机姓名" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" placeholder="请输入司机联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
|
||||
<div class="appointment-grid">
|
||||
<el-form-item label="订舱" prop="oceanBookingId">
|
||||
<el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可创建拖车单的已放舱订舱" @update:model-value="selectBooking(String($event || ''))">
|
||||
<el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled />
|
||||
<el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo || '缺失'}`" :value="item.id" />
|
||||
</el-select>
|
||||
<dl v-if="draft.oceanBookingId" class="selection-summary" aria-label="订舱带入信息">
|
||||
<div><dt>仓库</dt><dd>{{ selectedReleasedBooking?.warehouseName || resource?.warehouseName || '—' }}</dd></div>
|
||||
<div><dt>提单号</dt><dd>{{ draft.billOfLadingNo || '—' }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div>
|
||||
</el-form-item>
|
||||
<el-form-item label="拖车供应商" prop="carrierId">
|
||||
<el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择拖车供应商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" @update:model-value="selectCarrier(String($event || ''))">
|
||||
<el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前供应商(需重新选择)'" :value="String(draft.carrierId)" disabled />
|
||||
<el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" />
|
||||
</el-select>
|
||||
<dl v-if="draft.carrierId" class="selection-summary" aria-label="车队联系方式">
|
||||
<div><dt>联系人</dt><dd>{{ draft.fleetContact || '—' }}</dd></div>
|
||||
<div><dt>电话</dt><dd>{{ draft.fleetPhone || '—' }}</dd></div>
|
||||
</dl>
|
||||
</el-form-item>
|
||||
<div v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError" class="appointment-grid__wide readiness-alerts">
|
||||
<el-alert v-if="!draft.billOfLadingNo?.trim()" title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="!bookingDocumentReady" title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
|
||||
</div>
|
||||
<el-form-item label="预约装柜时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约装柜时间" /></el-form-item>
|
||||
<el-form-item label="拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" inputmode="decimal" placeholder="请输入应付拖车费"><template #append>元</template></el-input><div class="field-hint">人民币;离仓完成后计入应付。</div></el-form-item>
|
||||
<el-form-item class="appointment-grid__wide" label="备注(选填)"><el-input v-model="draft.remark" type="textarea" :rows="2" maxlength="1000" placeholder="例如到仓时间要求、装柜注意事项" /></el-form-item>
|
||||
</div>
|
||||
<details class="dispatch-details" :open="dispatchDetailsOpen" @toggle="toggleDispatchDetails">
|
||||
<summary>派车信息<span>选填,司机和车辆确定后可补充</span></summary>
|
||||
<div class="appointment-grid dispatch-grid">
|
||||
<el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" maxlength="64" placeholder="选择供应商后带入,可修改" /></el-form-item>
|
||||
<el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" maxlength="32" placeholder="选择供应商后带入,可修改" /></el-form-item>
|
||||
<el-form-item label="司机"><el-input v-model="draft.driverName" maxlength="64" placeholder="司机姓名" /></el-form-item>
|
||||
<el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" maxlength="32" placeholder="司机联系电话" /></el-form-item>
|
||||
<el-form-item label="车牌号"><el-input v-model="draft.vehicleNo" maxlength="32" placeholder="车牌或车头号" /></el-form-item>
|
||||
</div>
|
||||
</details>
|
||||
<details v-if="resource" class="dispatch-details">
|
||||
<summary>其他已存资料</summary>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机身份证号"><el-input v-model="draft.driverIdCard" placeholder="请输入司机身份证号" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" placeholder="请输入车牌或车头号" /></el-form-item></el-col>
|
||||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="实际装箱时间"><el-input :model-value="resource.actualLoadingAt || ''" disabled /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="件数" prop="packageCount"><el-input v-model="draft.packageCount" inputmode="numeric" placeholder="请输入件数" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)" prop="grossWeightKg"><el-input v-model="draft.grossWeightKg" inputmode="decimal" placeholder="请输入毛重" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input v-model="draft.volumeCbm" inputmode="decimal" placeholder="请输入体积" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">费用信息</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>{{ draft.currency }}</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="币种" prop="currency"><el-select v-model="draft.currency" aria-label="币种" placeholder="请选择币种" style="width:100%"><el-option v-for="currency in TRUCK_APPOINTMENT_CURRENCIES" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">门店信息与备注</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col>
|
||||
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col>
|
||||
@@ -187,34 +229,43 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col>
|
||||
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />保存</el-button></div>
|
||||
</el-row>
|
||||
</details>
|
||||
<div class="form-actions"><el-button v-if="resource" :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />{{ resource ? '保存' : '保存预约' }}</el-button></div>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-form { padding-bottom: var(--admin-space-1); }
|
||||
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); margin:0 0 var(--admin-space-3); padding:var(--admin-space-2) var(--admin-space-3) var(--admin-space-2) var(--admin-space-4); border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.relation-note > span { min-width:0; }
|
||||
.relation-note__actions { display:flex; flex:0 0 auto; align-items:center; }
|
||||
.relation-note__actions :deep(.el-button) { margin-left:0; }
|
||||
.relation-alert { margin-bottom: var(--admin-space-5); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); flex-wrap: wrap; }
|
||||
:deep(.el-divider) { margin: var(--admin-space-5) 0 var(--admin-space-4); }
|
||||
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: var(--admin-font-section); font-weight: 600; }
|
||||
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
|
||||
:deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:var(--admin-space-1);color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:20px }.field-error{color:var(--el-color-danger)}
|
||||
@media (min-width: 1200px) {
|
||||
.truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; }
|
||||
.truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; }
|
||||
.truck-form { min-width:0; padding-bottom:var(--admin-space-1); }
|
||||
.truck-form--create { max-width:1040px; margin-inline:auto; }
|
||||
.relation-note { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4); margin-bottom:var(--admin-space-5); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.relation-note__actions { display:flex; flex:0 0 auto; }
|
||||
.relation-alert { margin-bottom:var(--admin-space-5); }
|
||||
.appointment-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:var(--admin-space-6); }
|
||||
.appointment-grid__wide { grid-column:1 / -1; }
|
||||
:deep(.el-form-item), :deep(.el-form-item__content) { min-width:0; }
|
||||
:deep(.el-select), :deep(.el-date-editor), :deep(.el-input), :deep(.el-textarea) { width:100%; max-width:100%; }
|
||||
:deep(.el-select__selected-item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.selection-summary { display:flex; flex-wrap:wrap; gap:var(--admin-space-1) var(--admin-space-5); width:100%; margin:var(--admin-space-2) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.selection-summary div { display:flex; min-width:0; gap:var(--admin-space-2); }
|
||||
.selection-summary dt { flex-shrink:0; }
|
||||
.selection-summary dd { margin:0; color:var(--el-text-color-regular); overflow-wrap:anywhere; }
|
||||
.field-error,.field-hint { width:100%; display:flex; flex-wrap:wrap; gap:var(--admin-space-2); margin-top:var(--admin-space-1); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.field-error { color:var(--el-color-danger); }
|
||||
.readiness-alerts:empty { display:none; }
|
||||
.readiness-alerts :deep(.el-alert) { margin-bottom:var(--admin-space-4); }
|
||||
.dispatch-details { border-top:1px solid var(--el-border-color-lighter); }
|
||||
.dispatch-details summary { padding:var(--admin-space-4) 0; cursor:pointer; font-size:var(--admin-font-body); font-weight:600; color:var(--el-text-color-primary); }
|
||||
.dispatch-details summary span { margin-left:var(--admin-space-3); color:var(--cc-muted); font-size:var(--admin-font-caption); font-weight:400; }
|
||||
.dispatch-details summary:focus-visible { outline:2px solid var(--cc-primary); outline-offset:2px; }
|
||||
.dispatch-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.form-actions { display:flex; justify-content:flex-end; gap:var(--admin-space-3); padding-top:var(--admin-space-4); border-top:1px solid var(--el-border-color-lighter); }
|
||||
@media (max-width:720px) {
|
||||
.appointment-grid,.dispatch-grid { grid-template-columns:minmax(0,1fr); }
|
||||
.dispatch-details summary span { display:block; margin-left:var(--admin-space-4); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.truck-form { padding-inline: 2px; }
|
||||
.relation-note { align-items:flex-start; flex-direction:column; }
|
||||
.relation-note__actions { width:100%; }
|
||||
.form-actions { position: sticky; bottom: 0; z-index: 2; margin: 8px -2px -4px; padding: 10px 2px; background: var(--el-bg-color); border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.form-actions :deep(.el-button) { flex: 1; min-height: 40px; }
|
||||
@media (max-width:480px) {
|
||||
.form-actions { position:sticky; bottom:0; background:var(--el-bg-color); padding-bottom:var(--admin-space-3); z-index:2; }
|
||||
.form-actions :deep(.el-button) { flex:1; min-height:40px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<ApiErrorDetails | null>(null)
|
||||
const route = useRoute(); const router = useRouter(); const saving = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const initialOceanBookingId = String(route.query.oceanBookingId || '').trim()
|
||||
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
|
||||
const existingAppointment = ref<TruckAppointmentResource | null>(null)
|
||||
@@ -74,8 +73,7 @@ defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, exi
|
||||
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || '—' }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
|
||||
<LoadErrorState v-if="existingLookupError" title="已有拖车单查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.oceanBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"></TruckAppointmentForm></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit"></TruckAppointmentForm></section>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<QueryField[]>(() => [
|
||||
{ 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 },
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<string, string[]> = { 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;')
|
||||
|
||||
Reference in New Issue
Block a user