From 695db084e23266055bb7962fb9eef934bb9e4557 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 23:57:12 +0800 Subject: [PATCH] feat: preview container inbound plans --- .../overseas-warehouse/api/lastMileOrders.ts | 66 +++++++++ .../pages/LastMileOrdersView.vue | 139 +++++++++++++++--- .../containerPlanPreviewContract.test.ts | 28 ++++ 3 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 src/modules/overseas-warehouse/pages/containerPlanPreviewContract.test.ts diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 214a168e..53e11db4 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -212,6 +212,64 @@ export interface ConvertContainerInboundPlanPayload { sourceContainerBookingId: string } +export type ContainerInboundPlanPreviewReadiness = 'READY' | 'PLANNED' | 'BLOCKED' + +export interface ContainerInboundPlanPreviewOrderDto { + customerId: string + customerName: string | null + orderId: string + businessOrderNo: string + inboundCode: string | null + orderType: string + destinationType: 'FBA' | 'ONE_CLICK_DROPSHIP' | 'PRIVATE_ADDRESS' + destinationWarehouseId: string | null + destinationWarehouseCode: string | null + destinationWarehouseName: string | null + packageCount: number + volumeCbm: number + suggestedPalletCount: number | null + plannedPalletCount: number | null + existingPlanNo: string | null + planStatus: string | null + orderVersion: number + readiness: ContainerInboundPlanPreviewReadiness + blockers: string[] +} + +export interface ContainerInboundPlanPreviewGroupDto { + destinationType: 'FBA' | 'ONE_CLICK_DROPSHIP' | 'PRIVATE_ADDRESS' + destinationWarehouseId: string | null + destinationWarehouseCode: string | null + destinationWarehouseName: string | null + orderCount: number + pendingOrderCount: number + pendingPalletCount: number + existingPlannedPalletCount: number + suggestedPalletCount: number + plannedPalletCount: number + orders: ContainerInboundPlanPreviewOrderDto[] +} + +export interface ContainerInboundPlanPreviewDto { + sourceContainerBookingId: string + containerNo: string | null + overseasWarehouseId: string | null + overseasWarehouseCode: string | null + overseasWarehouseName: string | null + snapshotVersion: number + snapshotHash: string + totalOrderCount: number + readyOrderCount: number + blockedOrderCount: number + pendingOrderCount: number + pendingPalletCount: number + existingPlannedPalletCount: number + totalSuggestedPalletCount: number + totalPlannedPalletCount: number + ready: boolean + groups: ContainerInboundPlanPreviewGroupDto[] +} + export type ManualNodeCode = | 'DOMESTIC_CUSTOMS_COMPLETED' | 'ESTIMATED_DEPARTURE' @@ -394,6 +452,14 @@ export async function changeContainerReceivingWarehouse( return unwrapApiResponse(response) } +export async function getContainerInboundPlanPreview(sourceContainerBookingId: string) { + const response = await overseasHttp.get, ApiEnvelope>( + '/platform/v1/last-leg/inbound-plans/container-preview', + { params: { sourceContainerBookingId } }, + ) + return unwrapApiResponse(response) +} + export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) { const response = await overseasHttp.post, ApiEnvelope>( '/platform/v1/last-leg/inbound-plans/convert-container', payload, diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 67cdb785..2c4bcadc 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' -import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders' +import { changeContainerReceivingWarehouse, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders' import OrderReferenceText from '../components/OrderReferenceText.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' @@ -72,6 +72,9 @@ const containerOptions = ref([]) const containerOptionsLoading = ref(false) const containerLoadError = ref(null) const selectedContainerBookingId = ref('') +const containerPreview = ref(null) +const containerPreviewLoading = ref(false) +const containerPreviewError = ref('') const receivingWarehouseOpen = ref(false) const receivingWarehouseLoading = ref(false) const receivingWarehouseError = ref(null) @@ -543,6 +546,8 @@ function openContainerConversion() { selectedContainerBookingId.value = '' containerOptions.value = [] containerLoadError.value = null + containerPreview.value = null + containerPreviewError.value = '' actionError.value = null containerConvertOpen.value = true void loadContainerOptions() @@ -551,31 +556,69 @@ function openContainerConversionFor(container: ContainerPlanProgressSummaryDto) selectedContainerBookingId.value = container.sourceContainerBookingId containerOptions.value = [container] containerLoadError.value = null + containerPreview.value = null + containerPreviewError.value = '' actionError.value = null containerConvertOpen.value = true + void loadContainerPreview(container.sourceContainerBookingId) } -async function convertContainerInboundPlans() { - const container = selectedContainer.value - if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return } - if (!canGenerateContainerPlans(container)) { - actionError.value = { message: container.blockingReason || '所选柜当前不满足生成资格' } - return - } - actionLoading.value = true - actionError.value = null +async function loadContainerPreview(sourceContainerBookingId = selectedContainerBookingId.value) { + containerPreview.value = null + containerPreviewError.value = '' + if (!sourceContainerBookingId) return + containerPreviewLoading.value = true try { - const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: container.sourceContainerBookingId }) - containerConvertOpen.value = false - selectedContainerBookingId.value = '' - ElMessage.success(`已按柜生成 ${plans.length} 个独立入库计划`) - await load() + containerPreview.value = await getContainerInboundPlanPreview(sourceContainerBookingId) } catch (error) { - actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试') + containerPreviewError.value = getApiErrorDetails(error, '加载整柜入库计划预览失败,请重试').message } finally { - actionLoading.value = false + containerPreviewLoading.value = false } } +function previewGroupTitle(group: ContainerInboundPlanPreviewGroupDto) { + if (group.destinationType === 'FBA') return `${group.destinationWarehouseName || group.destinationWarehouseCode || '未配置目的仓'} FBA仓` + return group.destinationType === 'ONE_CLICK_DROPSHIP' ? '一件代发' : '私人地址' +} + +function destinationText(order: { destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null }) { + return [order.destinationWarehouseCode, order.destinationWarehouseName].filter(Boolean).join(' / ') || '—' +} + +function orderTypeText(orderType: string) { + const labels: Record = { + FIRST_LEG: '头程订单', + RELABEL_APPLICATION: '换标下单', + WAREHOUSE_TRANSFER: '仓储转运', + LOCAL_LOOSEBOARD: '本土散板', + RETURN_RELABEL: '退货换标', + ONE_CLICK_DROPSHIP: '一件代发', + } + return labels[orderType] || orderType +} + +function readinessText(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) { + return readiness === 'READY' ? '可生成' : readiness === 'PLANNED' ? '已有计划' : '已阻断' +} + +function readinessType(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) { + return readiness === 'READY' ? 'success' : readiness === 'PLANNED' ? 'info' : 'danger' +} + +function blockerText(blockers: string[]) { + const labels: Record = { + MISSING_INBOUND_CODE: '缺少入仓码', + ORDER_DETAINED: '订单已暂扣', + ORDER_STATUS_NOT_READY: '订单状态未就绪', + CONTAINER_NO_INCONSISTENT: '货柜号不一致', + RECEIVING_WAREHOUSE_INCONSISTENT: '收货仓不一致', + DESTINATION_NOT_READY: '目的地未就绪', + CARGO_FACT_INVALID: '货物数据无效', + DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照', + } + return blockers.map((code) => labels[code] || code).join(';') || '—' +} + function openDetail(source: object) { const row = source as LastMileOrderListItem void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } }) @@ -660,19 +703,52 @@ onMounted(() => { void load() }) - -

将为所选柜内所有待分配尾程订单分别生成独立入库计划。

+ +

先读取服务端全柜预览,确认分组、托数和阻断原因。当前仅开放预览,确认接口就绪前不会提交生成请求。

- - + + - + +
+
+
{{ containerPreview.containerNo || '未录入货柜号' }}
+
{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}
+
+
+
本次新增托盘码{{ containerPreview.pendingPalletCount }}
+
本次新增计划{{ containerPreview.pendingOrderCount }}
+
已有计划托盘{{ containerPreview.existingPlannedPalletCount }}
+
阻断订单{{ containerPreview.blockedOrderCount }}
+
+ + +
+
+
+ {{ previewGroupTitle(group) }}:{{ group.pendingPalletCount }}托 + 本次 {{ group.pendingOrderCount }} 票 / 已有 {{ group.existingPlannedPalletCount }} 托 / 共 {{ group.orderCount }} 票 +
+ + + + + + + + + + + +
+
+
@@ -717,7 +793,7 @@ onMounted(() => { void load() }) -
接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。
+
接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。仅作为现场可选提示,不强制工人合托。
@@ -747,6 +823,23 @@ onMounted(() => { void load() }) .manual-node-table__row :deep(.el-date-editor) { width: 100%; } .last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; } .container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; } +.container-convert-alert { margin-top: 12px; } +.container-preview { margin-top: 16px; } +.container-preview__context, .container-preview__group-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.container-preview__identity { color: var(--cc-text); font-size: 18px; font-weight: 700; } +.container-preview__warehouse, .container-preview__group-header span { color: var(--cc-muted); font-size: 13px; } +.container-preview__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 14px 0; } +.container-preview__metric { display: flex; min-height: 72px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); flex-direction: column; justify-content: center; } +.container-preview__metric span { color: var(--cc-muted); font-size: 13px; } +.container-preview__metric strong { margin-top: 4px; color: var(--cc-text); font-size: 24px; line-height: 1; } +.container-preview__metric--primary { border-color: var(--cc-primary); background: #f3f6ff; } +.container-preview__metric--primary strong { color: var(--cc-primary); } +.container-preview__metric--danger strong { color: var(--el-color-danger); } +.container-preview__scroll { max-height: 48vh; margin-top: 14px; padding-right: 6px; overflow-y: auto; } +.container-preview__group + .container-preview__group { margin-top: 16px; } +.container-preview__group-header { min-height: 42px; padding: 0 14px; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); } +.container-preview__group-header strong { min-width: 0; color: var(--cc-text); } +.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; } .container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .container-workbench { padding: 18px; } diff --git a/src/modules/overseas-warehouse/pages/containerPlanPreviewContract.test.ts b/src/modules/overseas-warehouse/pages/containerPlanPreviewContract.test.ts new file mode 100644 index 00000000..6ce5235f --- /dev/null +++ b/src/modules/overseas-warehouse/pages/containerPlanPreviewContract.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const apiSource = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/api/lastMileOrders.ts'), 'utf8') +const pageSource = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue'), 'utf8') + +describe('container inbound plan preview contract', () => { + it('uses the backend preview endpoint with the stable container booking id', () => { + expect(apiSource).toContain("'/platform/v1/last-leg/inbound-plans/container-preview'") + expect(apiSource).toContain('{ params: { sourceContainerBookingId } }') + }) + + it('renders backend groups, pallet totals, readiness and blockers without local grouping or sorting', () => { + expect(pageSource).toContain('containerPreview.pendingPalletCount') + expect(pageSource).toContain('containerPreview.existingPlannedPalletCount') + expect(pageSource).toContain('v-for="group in containerPreview.groups"') + expect(pageSource).toContain('readinessText(row.readiness)') + expect(pageSource).toContain('blockerText(row.blockers)') + expect(pageSource).not.toContain('containerPreview.groups.sort(') + }) + + it('requests one preview per selection and does not submit the legacy conversion command', () => { + expect(pageSource.match(/await getContainerInboundPlanPreview\(sourceContainerBookingId\)/g)).toHaveLength(1) + expect(pageSource).not.toContain('convertLastMileContainerToInboundPlans') + expect(pageSource).toContain(':confirm-disabled="true"') + }) +})