diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index f1b5def6..cbcb13e1 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { http } from '@/shared/api/http' import { adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, @@ -7,7 +9,7 @@ import { getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getCustomerContainerTransitSummary, createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking, - updateContainer, updateContainerTransport, + updateContainer, } from './platformContainerLoading' vi.mock('@/shared/api/http', () => ({ @@ -42,14 +44,12 @@ describe('platform container loading API', () => { expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } }) }) - it('uses full-edit and transport whitelist request shapes', async () => { + it('uses the full-edit request shape without retaining the retired container transport endpoint', async () => { vi.mocked(http.put).mockResolvedValue({ code: '200', data: container }) - vi.mocked(http.patch).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 } await updateContainer('7', 2, data) - await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' }) expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data }) - expect(http.patch).toHaveBeenCalledWith('/platform/v1/containers/7/transport', { version: 3, containerNo: 'MSCU1', voyageNo: 'V8' }) + expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/api/platformContainerLoading.ts'), 'utf8')).not.toContain('/transport') }) it('uses the frozen simulation create and ocean booking link contracts', async () => { diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 13be4f90..91cffa06 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -202,11 +202,6 @@ export async function updateContainer(containerId: string, version: number, data return container(unwrapApiResponse(response)) } -export async function updateContainerTransport(containerId: string, version: number, payload: { containerNo: string | null; voyageNo: string | null }) { - const response = await http.patch, ApiEnvelope>(`${containerPath}/${encodeURIComponent(containerId)}/transport`, { ...payload, version }) - return container(unwrapApiResponse(response)) -} - export async function getContainerClearance(containerId: string) { const response = await http.get, ApiEnvelope>(`${containerPath}/${encodeURIComponent(containerId)}/clearance`) return document(unwrapApiResponse(response)) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue index 8f7603d0..76071b84 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -12,6 +12,7 @@ const props = defineProps<{ canManageCustoms: boolean canManageException: boolean documentBusy?: boolean + sections?: Array<'exception' | 'documents' | 'pda'> }>() const emit = defineEmits<{ @@ -58,7 +59,10 @@ const pdaProgress = computed(() => { if (!pda || pda.itemCount <= 0) return 0 return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100)) }) -const activePhotos = computed(() => (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath)) +const visibleSections = computed(() => new Set(props.sections || ['exception', 'documents', 'pda'])) +const activePhotos = computed(() => visibleSections.value.has('pda') + ? (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath) + : []) const photoStates = reactive>({}) let photoGeneration = 0 const workflowStatusLabels: Record = { @@ -110,12 +114,12 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() }) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index a3714b8d..2cab52c7 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -10,7 +10,7 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses' import { listPlatformPartners } from '@/modules/master-data' import { listSuppliers } from '@/modules/suppliers' import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings' -import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading' +import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' import AppDialog from '@/shared/components/AppDialog.vue' vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) @@ -20,7 +20,7 @@ vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOcean vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) })) const accessCan = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) -vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() })) +vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() })) function createTestRouter() { return createRouter({ history: createMemoryHistory(), routes: [ @@ -219,7 +219,7 @@ describe('ContainersView', () => { it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') - const labels = ['集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)'] + const labels = ['真实集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)'] let previous = -1 for (const label of labels) { const current = source.indexOf(`label="${label}"`, previous + 1) @@ -231,13 +231,45 @@ describe('ContainersView', () => { it('renders container business actions as buttons without changing the transport gate', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel') - expect(source).toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'") + expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)") + expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'") + expect(source).toContain('初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。') + expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)') + expect(source).not.toContain('containerNo: optional(form.containerNo)') + expect(source).not.toContain('updateContainerTransport') expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading") expect(source).not.toContain('canSmartLoading && row.containerNo') expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel') expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"') }) + it('normalizes the mandatory booking-link container number and keeps it as the only initial-entry write', async () => { + vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never) + const router = createTestRouter() + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + const vm = wrapper.vm as unknown as { + linkTarget: { id: string; version: number } + linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }> + linkCandidateKey: string + linkContainerNo: string + saveLink: () => Promise + } + vm.linkTarget = { id: '9001', version: 7 } + vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }] + vm.linkCandidateKey = '101:202' + vm.linkContainerNo = ' mscu1234567 ' + + await vm.saveLink() + + expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', { + oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 7, + }) + expect(vm.linkContainerNo).toBe('MSCU1234567') + wrapper.unmount() + }) + it('keeps document uploads on the routed detail page without duplicate list implementations', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8') @@ -363,7 +395,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', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never) + vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never) vi.mocked(updateContainer).mockResolvedValue({} as never) const router = createTestRouter() await router.push('/'); await router.isReady() @@ -383,7 +415,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', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never) + vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never) vi.mocked(updateContainer).mockResolvedValue({} as never) const router = createTestRouter() await router.push('/'); await router.isReady() @@ -405,7 +437,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', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never) + vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never) vi.mocked(updateContainer).mockClear() const router = createTestRouter() await router.push('/'); await router.isReady() diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 3aed3b30..6ad2be29 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -18,7 +18,7 @@ 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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading' +import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } @@ -97,7 +97,6 @@ const canCustomsManage = computed(() => access.can('platform:firstleg:customs:ma const canViewPreload = computed(() => access.can('platform:firstleg:preload:list')) const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list')) const editorTitle = computed(() => form.id ? '编辑' : '新增') -const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING') const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER'))) const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING')) const simulationRules: FormRules = { @@ -165,6 +164,7 @@ const columns: DataTableColumn[] = [ ] function optional(value: string | null) { return value?.trim() || null } +function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() } function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] } function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } @@ -202,7 +202,7 @@ function payload(): ContainerSaveRequest { const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId) const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value) return { - containerNo: optional(form.containerNo), + containerNo: optional(detail.value?.containerNo || null), warehouseId: form.warehouseId, warehouseName: selectedWarehouse?.warehouseName || optional(form.warehouseName), billOfLadingNo: optional(form.billOfLadingNo), vesselName: optional(form.vesselName), voyageNo: optional(form.voyageNo), @@ -393,7 +393,8 @@ async function openLink(source: object) { async function saveLink() { const target = linkTarget.value const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value) - const containerNo = linkContainerNo.value.trim() + const containerNo = normalizedContainerNo(linkContainerNo.value) + linkContainerNo.value = containerNo if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return } if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return } linkSaving.value = true @@ -438,7 +439,16 @@ function handleMoreAction(command: string, source: object) { async function openEdit(source: object) { const row = source as ContainerBookingResource editorError.value = null - try { detail.value = await getContainer(row.id); replaceForm(detail.value); editorOpen.value = true } + try { + const latest = await getContainer(row.id) + if (!['WAIT_LOADING', 'EDITING'].includes(latest.planStatus)) { + ElMessage.warning('当前阶段运输信息只读;真实集装箱号如需纠错,请进入预装清单') + return + } + detail.value = latest + replaceForm(latest) + editorOpen.value = true + } catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) } } async function save() { @@ -450,8 +460,7 @@ async function save() { saving.value = true editorError.value = null try { - if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) }) - else await updateContainer(form.id, form.version, payload()) + await updateContainer(form.id, form.version, payload()) ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows() } catch (error) { const details = getApiErrorDetails(error, '保存失败') @@ -510,7 +519,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
- + @@ -540,34 +549,33 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin - +

仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。

- + - - - 重新加载业务选项 - - + + 重新加载业务选项 + + 初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。 - - - + + + - - - - - + + + + + - - - - + + + +
diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue index f29d613d..fda3f2f2 100644 --- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue @@ -17,6 +17,8 @@ const props = defineProps<{ detail: LoadingDetail | null error: ApiErrorDetails | null canEdit: boolean + canEditTransport?: boolean + transportNote?: string confirmSaving?: boolean }>() @@ -125,7 +127,7 @@ function formatNumber(value?: number | null, digits = 3) {
@@ -156,6 +158,7 @@ function formatNumber(value?: number | null, digits = 3) { 暂不能确认预装: {{ blockingReasonText || '请刷新详情获取最新确认条件' }} +
真实集装箱号:{{ transportNote }}
{ it('keeps transport editing as a detail-page action', () => { expect(source).not.toContain('openEditFromRow(row)') expect(source).not.toContain('编辑运输信息') - expect(detailSource).toContain("{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}") - expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')") + expect(detailSource).toContain('纠正集装箱号 / 航次') + expect(detailSource).toContain('v-if="canEditTransport"') + expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')") }) it('refreshes the detail version on A0409 while preserving transport input', () => { diff --git a/src/modules/loading-lists/index.ts b/src/modules/loading-lists/index.ts index ba9e3ce3..9d0123cb 100644 --- a/src/modules/loading-lists/index.ts +++ b/src/modules/loading-lists/index.ts @@ -1,3 +1,4 @@ export * from './api/platformLoadingLists' export { loadingListRoutes } from './routes' export { loadingListStatusLabel } from './status' +export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue' diff --git a/src/modules/loading-lists/pages/PreloadPlanDetailView.test.ts b/src/modules/loading-lists/pages/PreloadPlanDetailView.test.ts index f56bdee4..fa75c615 100644 --- a/src/modules/loading-lists/pages/PreloadPlanDetailView.test.ts +++ b/src/modules/loading-lists/pages/PreloadPlanDetailView.test.ts @@ -49,11 +49,20 @@ describe('PreloadPlanDetailView prototype contract', () => { expect(source).toContain(':confirm-loading="remarkSaving"') }) - it('is the formal container-number entry and does not imply that preload directly opens PDA', () => { + it('keeps preload as a correction-only entry before PDA claim and does not imply that preload directly opens PDA', () => { expect(source).toContain("{ required: true, message: '请输入集装箱号'") - expect(source).toContain("detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'") - expect(source).toContain('成功后由服务端按真实条件创建唯一 PDA 任务') - expect(source).toContain('当前页面不会伪造 PDA 成功') + expect(source).toContain("summary.status === 'EDITING'") + expect(source).toContain("(!pdaStatus || pdaStatus === 'PENDING')") + expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。') + expect(source).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')") + expect(source).toContain("{ required: true, message: '请输入操作原因'") + expect(source).toContain("String(value || '').trim().toUpperCase()") + expect(source).toContain('operationReason: editForm.operationReason.trim()') + expect(detailSource).toContain('v-if="canEditTransport"') + expect(detailSource).not.toContain("preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'") + expect(source).toContain('由服务端按真实条件创建唯一 PDA 任务') + expect(source).toContain('真实集装箱号未录入时不能确认') + expect(source).toContain('PDA 领取或作业开始后集装箱号只读') }) it('uses a flat blocking note instead of repeated colored page alerts', () => { diff --git a/src/modules/loading-lists/pages/PreloadPlanDetailView.vue b/src/modules/loading-lists/pages/PreloadPlanDetailView.vue index 969133af..661a51a4 100644 --- a/src/modules/loading-lists/pages/PreloadPlanDetailView.vue +++ b/src/modules/loading-lists/pages/PreloadPlanDetailView.vue @@ -37,16 +37,34 @@ let loadSequence = 0 const planId = computed(() => String(route.params.planId || '')) const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '') const canEdit = computed(() => access.can('platform:firstleg:preload:edit')) -const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号') +const canCorrectTransport = computed(() => { + const summary = detail.value?.summary + const pdaStatus = summary?.readiness.pdaTaskStatus + return Boolean(canEdit.value && summary?.containerNo && summary.status === 'EDITING' + && summary.readiness.preloadPushed && (!pdaStatus || pdaStatus === 'PENDING')) +}) +const transportCorrectionNote = computed(() => { + const summary = detail.value?.summary + if (!summary?.containerNo) return '真实集装箱号尚未录入;请返回货柜管理,通过“关联已放舱订舱”完成初次录入。' + if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。' + if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。' + return '真实集装箱号仅可在 PDA 领取前纠错,保存时必须填写操作原因。' +}) +const editDialogTitle = computed(() => '纠正集装箱号 / 航次') const editRules: FormRules = { containerNo: [ { required: true, message: '请输入集装箱号', trigger: 'blur' }, { max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }, ], voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }], - operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }], + operationReason: [ + { required: true, message: '请输入操作原因', trigger: 'blur' }, + { max: 500, message: '操作原因不能超过 500 个字符', trigger: 'blur' }, + ], } +function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() } + async function loadDetail() { const sequence = ++loadSequence detail.value = null @@ -80,7 +98,7 @@ function goBack() { } function openEdit() { - if (!detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return + if (!detail.value || !canCorrectTransport.value) return editForm.containerNo = detail.value.summary.containerNo || '' editForm.voyageNo = detail.value.summary.voyageNo || '' editForm.operationReason = '' @@ -89,14 +107,15 @@ function openEdit() { } async function saveTransport() { - if (!detail.value || !(await editFormRef.value?.validate().catch(() => false))) return + if (!detail.value || !canCorrectTransport.value || !(await editFormRef.value?.validate().catch(() => false))) return + editForm.containerNo = normalizedContainerNo(editForm.containerNo) editSaving.value = true editError.value = null try { detail.value = await updatePreloadTransport(planId.value, warehouseId.value, { - containerNo: editForm.containerNo.trim(), + containerNo: editForm.containerNo, voyageNo: editForm.voyageNo.trim() || null, - operationReason: editForm.operationReason.trim() || null, + operationReason: editForm.operationReason.trim(), expectedVersion: detail.value.summary.version, }) editOpen.value = false @@ -196,14 +215,14 @@ watch([planId, warehouseId], () => void loadDetail())
- +
- + - + @@ -219,7 +238,7 @@ watch([planId, warehouseId], () => void loadDetail())
- + {{ detail.summary.preloadNo }} {{ detail.summary.cabinetNo || '—' }}