diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue index 4afed29c..afa7446e 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -188,7 +188,7 @@ onMounted(load)
-
+
diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 6970882d..a7653528 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -1,10 +1,10 @@ -import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus' +import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect, ElUpload } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ContainerDetailView from './ContainerDetailView.vue' -import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading' +import { getContainer, getContainerOverview, linkContainerOceanBooking, getContainerClearance, uploadContainerClearance, uploadContainerDocument, completeContainerClearance } from '../api/platformContainerLoading' import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings' import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages' import { listSuppliers } from '@/modules/suppliers' @@ -59,7 +59,7 @@ function accessibleText(wrapper: { element: Element } | undefined) { describe('ContainerDetailView empty return', () => { it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => { vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never) - vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }]) + vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }]) const wrapper = await mountDetail() const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent expect(value).toBe('2026-10-23(默认推算)') @@ -100,7 +100,8 @@ describe('ContainerDetailView empty return', () => { vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' }) const wrapper = await mountDetail() expect(getDirectDeliveries).not.toHaveBeenCalled() - await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises() + expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false) + await (wrapper.vm as unknown as { openDirect(): Promise }).openDirect(); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))! await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises() dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled() @@ -123,7 +124,8 @@ describe('ContainerDetailView empty return', () => { canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm') vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }]) const wrapper = await mountDetail() - await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises() + expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false) + await (wrapper.vm as unknown as { openDirect(): Promise }).openDirect(); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))! await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises() expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读') @@ -206,17 +208,20 @@ describe('ContainerDetailView empty return', () => { granted.value = true await flushPromises() expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1) - expect(wrapper.text()).toContain('已确认离仓') + expect(wrapper.find('[aria-label="国内出仓门禁"]').exists()).toBe(false) + expect((wrapper.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED') expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁') expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓') }) it('reloads authoritative departure state once on each newly opened domestic detail', async () => { const first = await mountDetail('DOMESTIC') - expect(first.text()).toContain('已确认离仓') + expect(first.find('[aria-label="国内出仓门禁"]').exists()).toBe(false) + expect((first.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED') first.unmount() const reopened = await mountDetail('DOMESTIC') - expect(reopened.text()).toContain('已确认离仓') + expect(reopened.find('[aria-label="国内出仓门禁"]').exists()).toBe(false) + expect((reopened.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED') expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2) }) @@ -437,3 +442,54 @@ describe('domestic container loading list and booking link', () => { expect(routerPush).not.toHaveBeenCalled() }) }) + + +describe('destination document automatic upload', () => { + const uploaded = { id: 'doc-1', fileId: 'file-1', fileName: '资料.pdf', version: 1, completedAt: null } + beforeEach(() => { + canAccess.mockReset().mockReturnValue(true) + routeState.query = {} + vi.mocked(getContainer).mockReset().mockResolvedValue({ ...base, outboundConfirmedAt: '2026-10-20T08:00:00', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS'] } as never) + vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) + vi.mocked(getContainerClearance).mockReset().mockResolvedValue(null as never) + vi.mocked(uploadContainerDocument).mockReset().mockResolvedValue(uploaded as never) + vi.mocked(uploadContainerClearance).mockReset().mockResolvedValue(uploaded as never) + vi.mocked(completeContainerClearance).mockReset() + }) + it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('saves %s on selection without completing clearance', async type => { + const wrapper = await mountDetail() + await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type) + await flushPromises() + const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())! + expect(dialog.props('showConfirm')).toBe(false) + const file = new File(['data'], '资料.pdf', { type: 'application/pdf' }) + const select = dialog.findComponent(ElUpload).props('onChange')! + select({ raw: file } as never, []) + select({ raw: file } as never, []) + await flushPromises() + const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance + expect(upload).toHaveBeenCalledTimes(1) + expect(upload).toHaveBeenCalledWith(...(type === 'DELIVERY_ORDER' ? ['9001', type, file, 0, expect.any(String)] : ['9001', file, 0, expect.any(String)])) + expect(completeContainerClearance).not.toHaveBeenCalled() + expect(dialog.props('modelValue')).toBe(false) + }) + it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('retains a failed %s upload and retries the same file', async type => { + const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance + vi.mocked(upload).mockRejectedValueOnce(new Error('网络中断')) + const wrapper = await mountDetail() + await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type) + await flushPromises() + const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())! + const file = new File(['data'], '资料.pdf', { type: 'application/pdf' }) + dialog.findComponent(ElUpload).props('onChange')!({ raw: file } as never, []) + await flushPromises() + expect(dialog.props('modelValue')).toBe(true) + expect(dialog.text()).toContain('上传失败') + await dialog.findAll('button').find(button => button.text() === '重试上传')!.trigger('click') + await flushPromises() + expect(upload).toHaveBeenCalledTimes(2) + expect(vi.mocked(upload).mock.calls[1]).toEqual(vi.mocked(upload).mock.calls[0]) + expect(dialog.props('modelValue')).toBe(false) + expect(completeContainerClearance).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 989caa49..970034d1 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -19,10 +19,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules' import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists' import { - confirmContainerOutboundDispatch, getContainerOutboundDispatch, type ContainerOutboundDispatchDetail, - type ContainerOutboundGateItem, } from '@/modules/outbound-dispatch' import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue' import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue' @@ -244,7 +242,6 @@ async function saveDirect() { } const canListCustoms = computed(() => access.can('platform:firstleg:customs:list')) const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view')) -const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm')) const canViewPreload = computed(() => access.can('platform:firstleg:preload:list')) const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list')) const canViewCurrentLoadingList = computed(() => { @@ -274,6 +271,7 @@ const pickupCodeSaving = ref(false) const clearanceOpen = ref(false) const clearanceDocument = ref(null) const clearanceFile = ref(null) +const clearanceUploadKey = ref(0) const clearanceLoading = ref(false) const clearanceSaving = ref(false) const clearanceError = ref(null) @@ -282,10 +280,8 @@ const exceptionReason = ref('') const exceptionSaving = ref(false) const exceptionError = ref(null) const outboundLoading = ref(false) -const outboundConfirming = ref(false) const outboundDetail = ref(null) const outboundError = ref(null) -const showPassedGates = ref(false) const carrierRefreshKey = ref(0) const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail() const emptyReturnOpen = ref(false) @@ -337,32 +333,6 @@ const emptyReturnWarningText = computed(() => { return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}` }) const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常') -type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] } -const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE']) -const outboundGateRows = computed(() => { - const grouped = new Map() - for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) { - for (const item of inbound.gate.items || []) { - if (referenceOnlyGateCodes.has(item.code)) continue - const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name]) - const existing = grouped.get(key) - if (existing) { - if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode) - } else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] }) - } - } - return [...grouped.values()] -}) -const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed)) -const outboundGateSummary = computed(() => ({ - total: outboundGateRows.value.length, - passed: outboundGateRows.value.filter((row) => row.passed).length, - blocked: outboundGateRows.value.filter((row) => !row.passed).length, -})) -const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value - && hasAction('CONFIRM_OUTBOUND') - && outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH' - && outboundGateSummary.value.blocked === 0) const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() => !outboundDetail.value ? 'UNKNOWN' : outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES') @@ -469,7 +439,7 @@ watch( async function loadOutboundDispatch() { if (!containerId.value || !canViewOutboundDispatch.value) return - outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false + outboundLoading.value = true; outboundError.value = null try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) } catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') } finally { outboundLoading.value = false } @@ -488,37 +458,6 @@ async function refreshAfterCustoms() { try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ } } -async function confirmDeparture() { - const aggregate = outboundDetail.value?.aggregate - if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return - outboundConfirming.value = true - try { - await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' }) - } catch { outboundConfirming.value = false; return } - outboundError.value = null - try { - outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, { - expectedVersion: aggregate.version, - remark: '货柜详情确认国内出仓', - }) - carrierRefreshKey.value += 1 - ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新') - try { overview.value = await getContainerOverview(containerId.value) } - catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') } - } catch (cause) { - const details = getApiErrorDetails(cause, '确认国内出仓失败') - if (details.code === 'A0409') await loadOutboundDispatch() - outboundError.value = details - } finally { outboundConfirming.value = false } -} - -function outboundStatusText(status?: string | null) { - return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record)[status || ''] || status || '—' -} -function truckReferenceText(status?: string | null) { - return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record)[status || ''] || status || '未关联' -} - function openDocument(type: string) { if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return } if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return @@ -526,9 +465,14 @@ function openDocument(type: string) { documentType.value = type as UploadDocumentType documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true } -function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null } +function selectDocumentFile(file: UploadFile) { + if (documentSaving.value) return + documentFile.value = file.raw || null; documentError.value = null + if (documentFile.value) void saveDocument() +} function removeDocumentFile() { documentFile.value = null } async function saveDocument() { + if (documentSaving.value || !canManageDestinationDocuments.value) return if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return } documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null try { @@ -601,14 +545,20 @@ async function saveException() { } async function openClearance() { + clearanceUploadKey.value += 1 clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true try { clearanceDocument.value = await getContainerClearance(containerId.value) } catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details } finally { clearanceLoading.value = false } } -function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null } +function selectClearanceFile(file: UploadFile) { + if (clearanceSaving.value || clearanceLoading.value) return + clearanceFile.value = file.raw || null; clearanceError.value = null + if (clearanceFile.value) void uploadClearance() +} function removeClearanceFile() { clearanceFile.value = null } async function uploadClearance() { + if (clearanceSaving.value || clearanceLoading.value || !canManageDestinationDocuments.value || clearanceDocument.value?.completedAt) return if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return } clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null try { @@ -710,26 +660,10 @@ onMounted(async () => {

装柜作业与国内出仓

-
-

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}确认离仓
- -
- -
-

提柜、卸柜与还空

提柜安排与执行直送卸货清单确认还柜
+

提柜、卸柜与还空

提柜安排与执行确认还柜
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ expectedArrivalText }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
@@ -753,9 +687,9 @@ onMounted(async () => { -
选择文件
+
选择文件
- + diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index a442fa83..ef42b939 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -449,3 +449,17 @@ | 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 | | 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 | + + +## 灰度界面精简(2026-10-26) + +依据:本轮用户截图 1—7 及后续五项确认;Admin 基于 main `952abd56` 的 `hotfix-gray-bugfix`。已有入库短收改动保留。 + +| 页面 | 操作 | 确认布局与实现 | 验证 | +|---|---|---|---| +| 尾程订单 | 入库计划 | 保留原居中、可全屏弹框;移除顶部说明,托数使用紧凑输入框,说明靠近输入并自然换行;保存生成入库单及预计到仓锁定规则不变 | 模板类型检查通过;1920×1080、100%待人工截图确认 | +| 尾程订单 | 修改节点 | 保留原居中弹框;去掉黄色提示及分区背景,只显示国内报关完成、国外清关完成;读取并原样回传隐藏节点已有值 | 两项编辑、隐藏节点保留、零版本提交回归通过;待人工截图确认 | +| 排车列表 | 调度审核图片 | 保持三类图片横向并排;分区顶部对齐,多图按可用宽度并排;缩略图保持完整比例,长文件名省略且可悬停查看,保留大图预览 | 附件组件19项、审核布局29项通过;待人工截图确认 | +| 清关提柜详情 | DO/目的港清关附件 | 保留上传弹框,选中文件即上传保存;上传中防重复,失败保留文件可重试;目的港清关完成仍是独立业务动作 | 自动上传、防重复、失败重试共4项回归通过;待人工截图确认 | + +续修验证:修正与当前开船阶段、服务端到仓投影和默认白色分区不一致的旧测试,新增未开船订单只读回归;补齐尾程费用表单的可编辑表格标记、统一订单状态按钮的共享高度变量。Admin 7个文件265项定向检查全部通过,Vue类型检查通过。PDA 381项全量测试全部通过(失败重试按现行规则使用新键与当前输入);此前 H5 与 App 资源构建通过。自动检查不代替真实服务、PDA真机或人工视觉验收。 diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue index fa15063e..38b88c75 100644 --- a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue +++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue @@ -125,7 +125,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
-

{{ metadata[id]?.fileName || '附件资料待核实' }}

+

{{ metadata[id]?.fileName || '附件资料待核实' }}

替换移除引用
@@ -153,8 +153,9 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR .evidence-input { display: none; } .dispatch-evidence--photos { min-width: 0; } .evidence-photo-card { margin: 0; text-align: center; } -.evidence-photo-list { display: grid; gap: var(--admin-space-3); } -.evidence-photo-name { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; } +.evidence-photo-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); align-items: start; gap: var(--admin-space-3); } +.evidence-photo-item { min-width: 0; } +.evidence-photo-name { margin: var(--admin-space-2) 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .evidence-photo-actions { justify-content: center; } .dispatch-evidence--photos > .evidence-upload-button { width: 100%; } .evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); } diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index 093ebb9f..a92f55e9 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -303,7 +303,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation], diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 60524e88..02557bde 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -157,10 +157,10 @@ async function mountView() { return wrapper } -async function openPending() { +async function openAfterDeparture() { const wrapper = await mountView() - const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前') - await pendingTab!.trigger('click') + const departedTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船后') + await departedTab!.trigger('click') await flushPromises() return wrapper } @@ -281,9 +281,13 @@ describe('LastMileOrdersView pending order and container conversion', () => { await flushPromises() expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'FIRST_LEG', sailingStage: stage, detained: false })) expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(false) - await button(wrapper, '导出订单')!.trigger('click') - await flushPromises() - expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0]) + if (stage === 'BEFORE_DEPARTURE') { + expect(button(wrapper, '导出订单')).toBeUndefined() + } else { + await button(wrapper, '导出订单')!.trigger('click') + await flushPromises() + expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0]) + } } await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '全部')!.trigger('click') await flushPromises() @@ -332,6 +336,18 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(button(wrapper, '高级筛选')).toBeUndefined() }) + it.each([null, 'ESTIMATED_DEPARTURE'])('keeps a first-leg order without actual departure read-only: %s', async containerNode => { + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'before-1', customerId, orderNo: 'LM-BEFORE', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG', containerNode }], page: 1, size: 10, total: 1, pages: 1 }) + const wrapper = await mountView() + const actions = wrapper.get('.el-table__body-wrapper .row-actions') + expect(actions.findAll('button').map(button => button.text().trim())).toEqual(['查看']) + await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]]) + expect((wrapper.vm as unknown as { selectedRows: object[] }).selectedRows).toEqual([expect.objectContaining({ orderTypeCode: 'FIRST_LEG', containerNode: containerNode || '—' })]) + expect(wrapper.find('.data-table__business-actions').exists()).toBe(false) + expect(createManualInboundPlan).not.toHaveBeenCalled() + expect(transferLastMileOrders).not.toHaveBeenCalled() + }) + it('keeps the original carton count and adds only the signed difference in the list', async () => { canAccess.mockReturnValue(false) vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ @@ -354,7 +370,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { [['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库', '修改节点'], false], ])('only collapses row actions above three for %j', async (allowed, labels, overflow) => { canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action)) - vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE' }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() const actions = wrapper.get('.el-table__body-wrapper .row-actions') expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels) @@ -480,7 +496,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { ])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => { canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ - id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false, + id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: false, }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() @@ -493,7 +509,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps release hold available for an already detained order', async () => { canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ - id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', detained: true, + id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: true, }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() @@ -506,7 +522,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('removes generation and hold actions after an inbound plan has been submitted', async () => { canAccess.mockReturnValue(true) vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ - id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', detained: false, + id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: false, }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() @@ -519,8 +535,8 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true) }) - it('keeps the original pending order table and does not render a view switch or container table', async () => { - const wrapper = await openPending() + it('keeps after-departure order actions in the existing table without a container view switch', async () => { + const wrapper = await openAfterDeparture() expect(wrapper.text()).toContain('尾程单号') expect(wrapper.text()).toContain('海外仓') expect(wrapper.text()).toContain('转仓') @@ -529,7 +545,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(wrapper.text()).not.toContain('按订单') expect(wrapper.text()).not.toContain('待分配整柜') expect(wrapper.text()).not.toContain('柜子工作台') - expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE' })) + expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'AFTER_DEPARTURE' })) expect(listContainerPlanProgress).not.toHaveBeenCalled() }) @@ -586,7 +602,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '入库')!.trigger('click') await flushPromises() @@ -606,7 +622,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '入库')!.trigger('click') await flushPromises() @@ -628,7 +644,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('blocks a held order from generating its inbound plan', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow({ detained: true, orderTypeCode: 'ONE_CLICK_DROPSHIP', @@ -649,7 +665,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { [{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'], ])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => { vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival]) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openArrivalPlan(wrapper, arrival.estimatedWarehouseArrivalAt ? selectedRow() : selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })) expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId]) @@ -670,7 +686,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { { arrivals: [{ ...arrivalTime, version: -1 }] }, ])('blocks manual planning when the authoritative cabinet arrival is missing or inconsistent: $arrivals', async ({ arrivals }) => { vi.mocked(listContainerArrivalTimes).mockResolvedValue(arrivals) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openArrivalPlan(wrapper) const confirm = [...document.body.querySelectorAll('button')] @@ -681,7 +697,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('shows arrival read failures and does not reuse the list timestamp', async () => { vi.mocked(listContainerArrivalTimes).mockRejectedValue(new ApiResponseError('到仓来源暂不可读', 'A0503', 503)) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openArrivalPlan(wrapper) const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))! expect(dialog.text()).toContain('到仓来源暂不可读') @@ -694,7 +710,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps confirmation blocked during arrival loading and ignores an older opening of the same order', async () => { let resolveOld!: (value: typeof arrivalTime[]) => void vi.mocked(listContainerArrivalTimes).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve })) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openArrivalPlan(wrapper) const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))! expect(dialog.props('confirmDisabled')).toBe(true) @@ -723,7 +739,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }], }], } as never) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })]) await button(wrapper, '入库')!.trigger('click') await flushPromises() @@ -741,7 +757,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) expect(listContainerPlanProgress).toHaveBeenCalledTimes(1) expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined }) @@ -754,7 +770,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('selects one cabinet and sends one long-id convert-container request without using current-page row counts', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) await chooseContainer(wrapper) const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement @@ -780,7 +796,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError( '柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`, )) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) await chooseContainer(wrapper) const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement @@ -799,7 +815,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { if (id === bookingId) resolveFirst = resolve else resolveSecond = resolve }) as never) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) const selects = wrapper.findAllComponents({ name: 'ElSelect' }) const containerSelect = selects[selects.length - 1] @@ -821,7 +837,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { vi.mocked(convertLastMileContainerToInboundPlans) .mockRejectedValueOnce(new Error('network uncertain')) .mockResolvedValueOnce([{ planNo: 'LIP-RETRY', pallets: [{ id: 'PALLET-R', palletNo: 'PLT-RETRY' }] }] as never) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) await chooseContainer(wrapper) const findConfirm = () => [...document.body.querySelectorAll('button')] @@ -904,7 +920,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { .mockResolvedValueOnce(resourceCommittedPreview) .mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined }) vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper); await chooseContainer(wrapper) cabinetDialog(wrapper).vm.$emit('update:modelValue', false); await flushPromises() await openContainerDialog(wrapper); await chooseContainer(wrapper) @@ -928,7 +944,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps ordinary zero-pending previews without resourceRetry disabled in both UI and handler', async () => { vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined }) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) expect(cabinetDialog(wrapper).props('confirmText')).toBe('确认生成') cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() @@ -937,7 +953,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('uses the visible missing-container label in the preview tooltip without exposing the booking ID', async () => { vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...preview, containerNo: null } as never) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) const identity = document.body.querySelector('.container-preview__identity')! expect(identity.textContent).toBe('未录入货柜号') expect(identity.getAttribute('title')).toBe('未录入货柜号') @@ -946,7 +962,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps server readiness blocks effective even for a resource retry', async () => { vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, ready: false, blockedOrderCount: 1 }) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() @@ -955,7 +971,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('guards resource retry at the handler when the caller lacks create permission', async () => { canAccess.mockImplementation(code => code !== 'platform:overseas-warehouse:inbound-order:create') vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() const view = wrapper.vm as unknown as { openContainerConversion(): void } view.openContainerConversion(); await flushPromises(); await chooseContainer(wrapper) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) @@ -968,7 +984,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { let resolveOptions!: (value: Awaited>) => void vi.mocked(getContainerInboundPlanPreview).mockReturnValue(new Promise(resolve => { resolvePreview = resolve })) vi.mocked(listContainerPlanProgress).mockReturnValue(new Promise(resolve => { resolveOptions = resolve })) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() @@ -984,7 +1000,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never)) vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview) vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError('资源确认失败', 'S0503', 503)) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() expect(cabinetDialog(wrapper).props('modelValue')).toBe(true) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false) @@ -1206,7 +1222,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })]) await button(wrapper, '转仓')!.trigger('click') await flushPromises() @@ -1230,7 +1246,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('clears all selected warehouse facts when the FBA candidate is cleared', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '转仓')!.trigger('click') await flushPromises() @@ -1246,7 +1262,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('renders an explicit empty state when there are no active FBA candidates', async () => { vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([]) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '转仓')!.trigger('click') await flushPromises() @@ -1258,7 +1274,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError( 'FBA candidate failed', 'A0503', 200, undefined, 'request-fba', 'trace-fba', )) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '转仓')!.trigger('click') await flushPromises() @@ -1328,21 +1344,22 @@ describe('LastMileOrdersView pending order and container conversion', () => { await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises() expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-')) }) - it('renders all eight node times and submits the complete set with zero version', async () => { + it('edits only customs nodes while preserving hidden node facts and zero version', async () => { + vi.mocked(getLastMileOrderManualNodes).mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [{ nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }], version: 0 }) const wrapper = await mountView() const row = selectedRow() await (wrapper.vm as unknown as { openManualNodes(row: object): Promise }).openManualNodes(row) await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('修改节点 ·'))! - expect(dialog.findAll('.manual-nodes-editor tbody tr')).toHaveLength(8) + expect(dialog.findAll('.manual-nodes-editor tbody tr').map(row => row.find('th').text())).toEqual(['国内报关完成', '国外清关完成']) const dates = dialog.findAllComponents({ name: 'ElDatePicker' }) - expect(dates).toHaveLength(8) + expect(dates).toHaveLength(2) dates[0]!.vm.$emit('update:modelValue', '2026-10-20T08:00:00') await dialog.get('button[aria-label="展开全屏"]').trigger('click') await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises() expect(updateLastMileOrderManualNodes).toHaveBeenCalledWith(row.id, 0, [ { nodeCode: 'DOMESTIC_CUSTOMS_COMPLETED', nodeAt: '2026-10-20T08:00:00' }, - { nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: null }, + { nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }, { nodeCode: 'ESTIMATED_ARRIVAL', nodeAt: null }, { nodeCode: 'OVERSEAS_CUSTOMS_COMPLETED', nodeAt: null }, { nodeCode: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', nodeAt: null }, { nodeCode: 'PICKUP_APPOINTED', nodeAt: null }, { nodeCode: 'UNLOADED', nodeAt: null }, ], expect.stringContaining('last-mile-manual-nodes-')) @@ -1354,7 +1371,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { groups: Array.from({ length: 13 }, (_, index) => ({ ...group, destinationWarehouseCode: 'FBA-' + index, orderCount: 1, pendingOrderCount: 1, pendingPalletCount: 1, sourcePalletCount: 1, supplementPalletCount: 0, orders: [{ ...group.orders[0]!, orderId: 'order-' + index, businessOrderNo: '业务单-' + index, sourcePalletCount: 1, targetPalletCount: 1, supplementPalletCount: 0 }] })), }) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper); await chooseContainer(wrapper) const dialog = cabinetDialog(wrapper) expect(dialog.findAll('[aria-label="目的地分组明细"]')).toHaveLength(13) @@ -1367,7 +1384,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps transfer drafts locked during submission and editable after rejection', async () => { let reject!: (reason: unknown) => void vi.mocked(transferLastMileOrders).mockReturnValueOnce(new Promise((_, no) => { reject = no })) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '转仓')!.trigger('click'); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('转仓 ·'))! diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 5789eb10..8d891e50 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -143,6 +143,7 @@ const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [ { code: 'PICKUP_APPOINTED', label: '预约提柜' }, { code: 'UNLOADED', label: '卸柜' }, ] +const visibleManualNodeDefinitions = manualNodeDefinitions.filter(({ code }) => ['DOMESTIC_CUSTOMS_COMPLETED', 'OVERSEAS_CUSTOMS_COMPLETED'].includes(code)) const manualNodeValues = reactive>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record) const transferOpen = ref(false) const transferWarehouseLoading = ref(false) @@ -1321,7 +1322,6 @@ onMounted(() => { void load() })
- @@ -1333,7 +1333,7 @@ onMounted(() => { void load() })

计划设置

{{ customerName(form.customerId) || editing.customerName || '—' }}
- + @@ -1351,8 +1351,7 @@ onMounted(() => { void load() })
{{ inboundPlanForm.palletCount }}一件代发固定按头程已封托托盘数整体入库,不可增减。按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。
{{ inboundPlanForm.palletCount }}一件代发固定按头程已封托托盘数整体入库,不可增减。按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。
仅作为现场可选提示,不强制工人合托。
- -

人工节点

节点节点时间
{{ node.label }}
+

人工节点

节点节点时间
{{ node.label }}

退回重做

@@ -1373,7 +1372,9 @@ onMounted(() => { void load() }) .inbound-plan-arrival { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); } .inbound-plan-form :deep(.el-form-item__content) { min-width: 0; } .inbound-plan-form :deep(.el-select) { width: 100%; } -.inbound-plan-pallet-note { flex-basis: 100%; margin-top: var(--admin-space-2); } +.inbound-plan-pallet-field { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-2) var(--admin-space-3); min-width: 0; } +.inbound-plan-pallet-field :deep(.el-input-number) { width: 140px; flex: 0 0 140px; } +.inbound-plan-pallet-note { margin: 0; line-height: 1.5; } .manual-nodes-editor th { width: 180px; } .relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; } .last-mile-toolbar { min-width: 0; } diff --git a/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts b/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts index 83af0c7f..7e2ae58b 100644 --- a/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts +++ b/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts @@ -68,7 +68,7 @@ describe('original container schedule', () => { wrapper.unmount() }) it('missing original ownership blocks initialization and late read cannot populate another container', async () => { - const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).toContain('不能维护船期'); first.wrapper.unmount() + const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).not.toContain('不能维护船期'); expect(first.wrapper.find('button').attributes('disabled')).toBeDefined(); first.wrapper.unmount() let resolve!: (value: ReturnType) => void vi.mocked(getPortSchedule).mockReturnValue(new Promise(done => { resolve = done })) const { wrapper, vm } = setup(); const pending = vm.begin() diff --git a/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue b/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue index 21776d17..855ae172 100644 --- a/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue +++ b/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue @@ -105,8 +105,8 @@ onBeforeUnmount(() => { generation++ })