diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md index e00781c4..84931dc2 100644 --- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md +++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md @@ -14,8 +14,8 @@ | FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 | | FBA 预约详情/编辑 | `GET /fba-appointments/{appointmentNo}?customerId=...`;`PUT` 同 path | PUT body:`customerId,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | GET 同 list;PUT 同 appointment;失败保留弹框和错误元数据 | | 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 | -| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?` | 同 inbound list;仅发 `PLAN/RECEIVED` | -| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId,draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;不调用编辑、取消、depart、PDA 或派送 | +| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?`;退回候选返回`sourceType=RETURN_RECEIPT,returnReceiptId,returnReceiptNo`,箱数为实际剩余可拣量 | 同 inbound list;已实收阶段包含真实退回来源,展示业务凭据号;草稿恢复失败保留选择并允许重试 | +| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId`或`sourceType=RETURN_RECEIPT,returnReceiptId`,及`draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],returnReceiptIds?:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;任何已选来源未恢复时禁止提交,不能过滤后静默少发;不调用编辑、取消、depart、PDA 或派送 | | 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户;列表/详情客户只显示 `customerName`,海外仓只显示 `overseasWarehouseName`,空值为 `—` | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship;不以客户 ID、本地客户候选、国内来源仓或目的仓猜名称 | | 库存位置/进销存 | `GET /inventory/balances/{balanceId}/positions`;`GET /inventory/ledger` | position:`palletNo?,batchNo?,inventoryStatus?`;ledger 含 `page,size,skuCode` 与业务单/仓库/时间筛选 | 同 inventory list;`balanceId` 编码;只展示后端事实 | | 公共海外收货仓候选(国内 HTTP) | `GET /platform/v1/warehouse-admin` | `tenantId=0,page=1,size=200,capabilityCode=RECEIVING,warehouseType=OVERSEAS,status=ACTIVE` | 国内 Warehouse 是唯一仓库主数据源;托盘、库存和换标复用公共候选并保留字符串 ID,不请求海外仓库根路径 | diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index 34e2087b..ebb63711 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -80,3 +80,9 @@ |---|---|---|---|---| | 排车列表 | 转派送费用确认 | 用户已确认逐票原费用覆盖;复用新版原型12374行1120px三列居中弹框 | DeliveryFeeCoveragePanel逐票显示原费和服务勾选,新增费用仅针对未覆盖部分;不新增抽屉或路由 | 交互测试通过,1920×1080真实页面待验收 | | 派送详情 | 费用只读 | 沿现有费用页签 | 展示确认时原费版本与服务,原金额不计作本次新增 | 本地展示测试通过,真实页面待验收 | + +### 退回货物重新预排(2026-10-03) + +| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 | +|---|---|---|---|---| +| 智能排车 | 退回来源选择、恢复草稿、预排 | 当前车辆异常退回与重派目标;沿`SmartDispatchDialog.vue`既有居中排车弹框 | 展示退回实收凭据号,按真实退回来源选用;草稿来源未全部恢复时保留选择、提示重试并阻止漏提交 | 组件交互覆盖恢复失败及重试后混合来源提交;1920×1080真实页面待验收 | diff --git a/src/modules/overseas-warehouse/api/dispatches.test.ts b/src/modules/overseas-warehouse/api/dispatches.test.ts index 32312b93..711a70e2 100644 --- a/src/modules/overseas-warehouse/api/dispatches.test.ts +++ b/src/modules/overseas-warehouse/api/dispatches.test.ts @@ -5,6 +5,21 @@ import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContai vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } })) describe('dispatch overseas API', () => { + it('locks and submits actual return receipts without pretending they are plan pallets', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: null }) + await lockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return', 'container-1') + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', { + sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return', draftContainerId: 'container-1', + }) + await unlockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return') + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/unlock', { + sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return', + }) + await preArrangeDispatch({ planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return' }) + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { + planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return', + }) + }) it('reads external freight from the scoped dispatch without a supplied customer', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] } }) expect(await getDispatchExternalFreight('DP/1')).toEqual({ snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] }) diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts index a99aa22d..4d27f0a4 100644 --- a/src/modules/overseas-warehouse/api/dispatches.ts +++ b/src/modules/overseas-warehouse/api/dispatches.ts @@ -12,6 +12,9 @@ export interface DispatchQuery { } export interface DispatchPalletCandidateDto { + sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT' + returnReceiptId?: string | null + returnReceiptNo?: string | null planId?: string | null planPalletId?: string | null warehousePalletId?: string | null @@ -60,6 +63,7 @@ export interface DispatchDraftDto { draftToken: string; containers?: DispatchDra export interface PreArrangeDispatchPayload { planPalletIds: string[] + returnReceiptIds?: string[] fbaAppointmentNo?: string fbaAppointmentStatus?: string vehicleAppointmentStatus?: string @@ -198,9 +202,14 @@ export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) { return unwrapApiResponse(response) } -export async function lockDispatchDraftPallet(planPalletId: string, draftToken: string, draftContainerId?: string) { +function draftSource(selection: string) { + return selection.startsWith('RETURN:') + ? { sourceType: 'RETURN_RECEIPT', returnReceiptId: selection.slice(7) } + : { planPalletId: selection } +} +export async function lockDispatchDraftPallet(selection: string, draftToken: string, draftContainerId?: string) { const response = await http.post, ApiEnvelope>( - '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId, draftToken, ...(draftContainerId ? { draftContainerId } : {}) }, + '/platform/v1/last-leg/dispatches/draft-locks/lock', { ...draftSource(selection), draftToken, ...(draftContainerId ? { draftContainerId } : {}) }, ) return unwrapApiResponse(response) } @@ -218,9 +227,9 @@ export async function deleteDispatchDraftContainer(draftToken: string, container return unwrapApiResponse(response) } -export async function unlockDispatchDraftPallet(planPalletId: string, draftToken: string) { +export async function unlockDispatchDraftPallet(selection: string, draftToken: string) { const response = await http.post, ApiEnvelope>( - '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId, draftToken }, + '/platform/v1/last-leg/dispatches/draft-locks/unlock', { ...draftSource(selection), draftToken }, ) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts new file mode 100644 index 00000000..e151d12e --- /dev/null +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts @@ -0,0 +1,58 @@ +import { flushPromises, shallowMount } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { beforeEach, expect, it, vi } from 'vitest' +import { ElMessage } from 'element-plus' +import SmartDispatchDialog from './SmartDispatchDialog.vue' +import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, type DispatchPalletCandidateDto } from '../api/dispatches' + +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('../api/dispatches', () => ({ + listDispatchCandidates: vi.fn(), listDispatchDrafts: vi.fn(), preArrangeDispatch: vi.fn(), + createDispatchDraftContainer: vi.fn(), deleteDispatchDraftContainer: vi.fn(), + lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), +})) + +const shell = defineComponent({ setup(_, { slots }) { return () => h('section', [slots.default?.(), slots.footer?.()]) } }) +const button = defineComponent({ props: { disabled: Boolean }, emits: ['click'], setup(props, { slots, emit }) { + return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) +} }) +const retry = defineComponent({ emits: ['retry'], setup(_, { emit }) { + return () => h('button', { 'data-test': 'retry', onClick: () => emit('retry') }, '重试加载') +} }) +const plan: DispatchPalletCandidateDto = { orderId: '1', orderNo: 'LM-A', planPalletId: '11', sourceType: 'INBOUND_PLAN', + warehousePalletId: '101', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-A', packageCount: 2, sourceStage: 'RECEIVED' } +const returned: DispatchPalletCandidateDto = { orderId: '2', orderNo: 'LM-B', returnReceiptId: '22', returnReceiptNo: 'RSR-B', sourceType: 'RETURN_RECEIPT', + warehousePalletId: '102', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-B', packageCount: 2, sourceStage: 'RECEIVED' } + +beforeEach(() => vi.resetAllMocks()) + +it('retains every restored selection on candidate failure and submits both sources only after retry hydration', async () => { + vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [{ id: 'container', capacity: 30, palletIds: ['11', 'RETURN:22'] }] }]) + vi.mocked(listDispatchCandidates) + .mockResolvedValueOnce([plan]) + .mockRejectedValueOnce(new Error('恢复来源失败')) + .mockResolvedValueOnce([plan]) + .mockResolvedValueOnce([plan, returned]) + const wrapper = shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: { + stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true, + ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true, + ElTag: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true, + ElEmpty: true, ElRadio: true, ElRadioGroup: true }, + } }) + await flushPromises() + const submit = () => wrapper.findAll('button').find(node => node.text() === '预排车')! + await submit().trigger('click') + await flushPromises() + expect(preArrangeDispatch).not.toHaveBeenCalled() + expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留')) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + await wrapper.get('[data-test="retry"]').trigger('click') + await flushPromises() + await submit().trigger('click') + await flushPromises() + expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['11'], returnReceiptIds: ['22'], draftToken: 'original-draft' }) + expect(listDispatchCandidates).toHaveBeenLastCalledWith({ draftToken: 'original-draft' }) + expect(wrapper.emitted('created')).toHaveLength(1) + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue index 1d42c378..74f393a8 100644 --- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue @@ -84,6 +84,7 @@ const orderGroups = computed(() => { }) const activePallets = computed(() => orderGroups.value.find((item) => item.key === activeOrderKey.value)?.pallets || []) const assignedIds = computed(() => containers.value.flatMap((item) => item.palletIds)) +const unresolvedAssignedIds = computed(() => assignedIds.value.filter(id => !palletSnapshot(id))) const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)).filter((item): item is DispatchPalletCandidateDto => Boolean(item))) const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)) const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0)) @@ -98,6 +99,7 @@ const containerOverweight = (container: LoadingContainer) => { } function palletId(item: DispatchPalletCandidateDto) { + if (item.sourceType === 'RETURN_RECEIPT') return item.returnReceiptId ? `RETURN:${item.returnReceiptId}` : '' return String(item.planPalletId || item.warehousePalletId || '') } function text(value: unknown) { @@ -175,6 +177,8 @@ async function load() { draftToken: ensureDraftToken(), }) rememberPallets(candidates.value) + if (unresolvedAssignedIds.value.length) await hydrateDraftPallets() + if (unresolvedAssignedIds.value.length) throw new Error('草稿中部分已选来源尚未恢复,请重试加载;原选择已保留') activeOrderKey.value = orderGroups.value[0]?.key || '' } catch (error) { candidates.value = [] @@ -300,6 +304,10 @@ async function close(value = false) { } async function submit() { + if (loading.value || unresolvedAssignedIds.value.length) { + ElMessage.warning('草稿中部分已选来源尚未恢复,请重试加载后再预排;原选择已保留') + return + } if (!assignedIds.value.length) { ElMessage.warning('请至少移入一个托盘') return @@ -322,7 +330,8 @@ async function submit() { mutationError.value = null try { await preArrangeDispatch({ - planPalletIds: assignedIds.value, + planPalletIds: assigned.value.filter(item => item.sourceType !== 'RETURN_RECEIPT').map(palletId), + returnReceiptIds: assigned.value.filter(item => item.sourceType === 'RETURN_RECEIPT').map(item => String(item.returnReceiptId)), ...(relabelOrders.length ? { relabelNo: relabelOrders[0] } : { draftToken: ensureDraftToken() }), }) emit('update:modelValue', false) @@ -364,7 +373,6 @@ watch( palletIds: c.palletIds || [], } }) - await hydrateDraftPallets().catch(() => undefined) } await load() })() @@ -484,6 +492,7 @@ watch([warehouseTab, () => query.orderType], () => {
{{ text(item.palletNo) }}{{ Number(item.packageCount || 0) }}箱 · {{ formatWeightLb(weightLb(item)) }} + 退回实收:{{ text(item.returnReceiptNo) }}
{{ mark }} { const dialog = source('../components/SmartDispatchDialog.vue') expect(dialog).toContain('listDispatchCandidates') expect(dialog).toContain('preArrangeDispatch') - expect(dialog).toContain('planPalletIds: assignedIds.value') + expect(dialog).toContain("planPalletIds: assigned.value.filter(item => item.sourceType !== 'RETURN_RECEIPT')") + expect(dialog).toContain("returnReceiptIds: assigned.value.filter(item => item.sourceType === 'RETURN_RECEIPT')") expect(dialog).toContain('lockDispatchDraftPallet') expect(dialog).toContain('unlockDispatchDraftPallet') expect(dialog).not.toContain('releaseDispatchDraft')