diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index 72b2e00c..24844581 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -13,7 +13,7 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| | 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 | -| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` | +| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` | | 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 | | 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` | diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 33f1eddc..4c1e38d2 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -287,6 +287,40 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(payload).not.toHaveProperty('plannedWarehouseName') }) + it('locks one-click dropship manual plans to the frozen source pallet count', async () => { + vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ + ...preview, + sourcePalletCount: 1, + groups: [{ + ...preview.groups[0], destinationType: 'ONE_CLICK_DROPSHIP', sourcePalletCount: 1, + orders: [{ + ...preview.groups[0].orders[0], orderType: 'ONE_CLICK_DROPSHIP', + destinationType: 'ONE_CLICK_DROPSHIP', sourcePalletCount: 1, + targetPalletCount: 1, supplementPalletCount: 0, + }], + }], + } as never) + const wrapper = await openPending() + await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })]) + await button(wrapper, '生成入库计划')!.trigger('click') + await flushPromises() + + const palletInput = wrapper.find('.command-form').findComponent({ name: 'ElInputNumber' }) + expect(palletInput.props('disabled')).toBe(true) + expect(palletInput.props('modelValue')).toBe(1) + expect(document.body.textContent).toContain('固定按头程已封托托盘数整体入库') + + wrapper.find('.command-form').findComponent({ name: 'ElDatePicker' }) + .vm.$emit('update:modelValue', '2026-09-08') + await wrapper.vm.$nextTick() + ;([...document.body.querySelectorAll('button')] + .find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement).click() + await flushPromises() + + expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ palletCount: 1 }), + expect.stringContaining('manual-inbound-plan-')) + }) + it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => { const wrapper = await openPending() await openContainerDialog(wrapper) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index e29935a3..4e7bfe1f 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -49,6 +49,13 @@ const inboundPlanWarehouseLabel = computed(() => [actionTarget.value?.overseasWa .filter((value) => value && value !== '—') .join(' · ')) const inboundPlanMinimumPalletCount = ref(1) +const inboundPlanSourcePalletCount = ref(null) +const inboundPlanPreviewLoading = ref(false) +const inboundPlanPreviewError = ref('') +const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP') +const inboundPlanSubmitDisabled = computed(() => inboundPlanIsDropship.value + && (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null + || inboundPlanSourcePalletCount.value <= 0)) const inboundPlanForm = reactive({ plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' }) const holdOpen = ref(false) const holdForm = reactive({ held: true, chargeable: true, reason: '' }) @@ -481,6 +488,8 @@ function openInboundPlan(source?: object) { const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1 inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum, typeof target.pallets === 'number' ? target.pallets : 0) + inboundPlanSourcePalletCount.value = null + inboundPlanPreviewError.value = '' Object.assign(inboundPlanForm, { plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' }) actionError.value = null inboundPlanOpen.value = true @@ -488,20 +497,39 @@ function openInboundPlan(source?: object) { } async function refreshManualPalletMinimum(target: LastMileOrderListItem) { if (!target.sourceContainerBookingId) return + inboundPlanPreviewLoading.value = true + inboundPlanPreviewError.value = '' try { const preview = await getContainerInboundPlanPreview(target.sourceContainerBookingId) if (actionTarget.value?.id !== target.id || !inboundPlanOpen.value) return const order = preview.groups.flatMap((group) => group.orders).find((item) => item.orderId === target.id) + if (target.orderTypeCode === 'ONE_CLICK_DROPSHIP') { + const sourcePalletCount = Number(order?.sourcePalletCount || 0) + if (sourcePalletCount <= 0) throw new Error('一件代发订单缺少头程封托托盘快照') + inboundPlanSourcePalletCount.value = sourcePalletCount + inboundPlanMinimumPalletCount.value = sourcePalletCount + inboundPlanForm.palletCount = sourcePalletCount + return + } const suggested = Number(order?.suggestedPalletCount || 0) inboundPlanMinimumPalletCount.value = Math.max(inboundPlanMinimumPalletCount.value, suggested) inboundPlanForm.palletCount = Math.max(inboundPlanForm.palletCount, inboundPlanMinimumPalletCount.value) - } catch { - // The backend repeats the same calculation and remains authoritative on submit. + } catch (error) { + if (actionTarget.value?.id === target.id && inboundPlanOpen.value) { + inboundPlanPreviewError.value = getApiErrorDetails(error, '柜级托盘预览加载失败,请重试').message + } + } finally { + if (actionTarget.value?.id === target.id) inboundPlanPreviewLoading.value = false } } async function submitInboundPlan() { const row = actionTarget.value const frozenInboundCode = String(row?.inboundCode || '').trim() + if (inboundPlanIsDropship.value && (inboundPlanSourcePalletCount.value == null + || inboundPlanForm.palletCount !== inboundPlanSourcePalletCount.value)) { + ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划') + return + } if (!row || !row.overseasWarehouseId || !inboundPlanForm.plannedArrivalDate || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) { ElMessage.warning('当前柜须已分配接柜仓,并完整填写预计到仓日期和托数') return @@ -719,6 +747,7 @@ function blockerText(blockers: string[]) { PLAN_ALREADY_STARTED: '已有计划已开始作业,不能再补生成托盘', PLAN_PALLET_BINDING_INCOMPLETE: '已有计划只绑定了部分真实托盘,请联系管理员处理', DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照', + DROPSHIP_PALLET_COUNT_MISMATCH: '一件代发计划托数与头程已封托托盘数不一致,请重新走单', PALLET_COUNT_NOT_READY: '计划托数暂未就绪', } return [...new Set(blockers.map((code) => labels[code] || '计划数据暂未就绪,请刷新或联系管理员'))].join(';') || '—' @@ -926,10 +955,11 @@ onMounted(() => { void load() }) - + + -
接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。仅作为现场可选提示,不强制工人合托。
+
接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。一件代发固定按头程已封托托盘数整体入库,不可增减。按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。仅作为现场可选提示,不强制工人合托。