diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md index b977165e..c4140b6c 100644 --- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md +++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md @@ -4,9 +4,10 @@ | 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 | |---|---|---|---| -| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | +| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—`;整柜操作要求列表响应携带稳定 `sourceContainerBookingId` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | | 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 | | 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string,customerId:string`,客户作用域精确取自所选尾程行;缺失时前端失败关闭 | `...:inbound-order:create`;A0404/A0409 保留 code/requestId/traceId;不接补托/PDA | +| 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功刷新待分配列表;计划数与整柜订单数不一致时不提示成功;错误保留 code/requestId/traceId | | 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 | | 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 | | 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 仓勾选 | diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index 19b38bbc..dc63e82a 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { overseasHttp } from '@/shared/api/overseasHttp' -import { convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders' +import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders' vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) @@ -8,7 +8,7 @@ describe('last-mile order overseas API', () => { beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) it('loads all platform orders without a customer scope', async () => { - const record = { id: '190000000000000099', customerId: '190000000000000001', customerName: '华远贸易', orderNo: 'LMO-260827-0001' } + const record = { id: '190000000000000099', customerId: '190000000000000001', sourceContainerBookingId: '190000000000000088', customerName: '华远贸易', orderNo: 'LMO-260827-0001' } vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [record], page: '2', size: '20', total: '1', pages: '1' } }) await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [record], page: 2, size: 20, total: 1, pages: 1 }) expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { @@ -55,4 +55,13 @@ describe('last-mile order overseas API', () => { orderId: '190000000000000099', customerId: '190000000000000001', }) }) + + it('uses one atomic container request without looping individual conversions', async () => { + vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: [] }) + await expect(convertLastMileContainerToInboundPlans({ sourceContainerBookingId: '2092873463568597001' })).resolves.toEqual([]) + expect(overseasHttp.post).toHaveBeenCalledTimes(1) + expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', { + sourceContainerBookingId: '2092873463568597001', + }) + }) }) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 329a53fb..5142a982 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -1,5 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { InboundPlanDetailDto } from './inboundPlans' export interface OverseasPageResult { records: T[] @@ -41,6 +42,7 @@ export interface LastMileOrderQuery { export interface LastMileOrderSummaryDto { id: string customerId: string + sourceContainerBookingId?: string | null orderNo: string orderType?: string | null orderTypeName?: string | null @@ -153,6 +155,10 @@ export interface ConvertInboundPlanPayload { customerId: string } +export interface ConvertContainerInboundPlanPayload { + sourceContainerBookingId: string +} + export interface LastMileOrderDetailDto { order: LastMileOrderDto cargo: LastMileCargoDto[] @@ -196,3 +202,10 @@ export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundP ) return unwrapApiResponse(response) } + +export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) { + const response = await overseasHttp.post, ApiEnvelope>( + '/platform/v1/last-leg/inbound-plans/convert-container', payload, + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts new file mode 100644 index 00000000..9e422fe1 --- /dev/null +++ b/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { groupPendingOrdersByContainer } from './lastMileContainerPlanning' +import type { LastMileOrderListItem } from './types' + +function order(overrides: Partial): LastMileOrderListItem { + return { + id: '1', customerId: '100', sourceContainerBookingId: 'BOOKING-1', orderNo: 'LMO-1', warning: '—', orderType: '头程订单', rejectedAt: '—', inboundCode: 'IN-1', + owner: '客户', warehouse: '—', transferWarehouse: '—', fbaWarehouse: '—', containerNo: 'CONT-1', containerStatus: '—', abnormal: '否', held: '否', + pallets: 1, cartons: 10, volume: 2.5, quantity: '—', volumeWeight: '—', portEta: '—', portAta: '—', warehouseEta: '—', inboundOrderNo: '—', outboundOrderNo: '—', dispatchOrderNo: '—', relabelOrderNo: '—', relabelStatus: '—', consolidated: '—', status: '待分配', internalRemark: '—', customerRemark: '—', warehouseRemark: '—', createdAt: '—', + ...overrides, + } +} + +describe('last-mile container planning groups', () => { + it('groups the whole container while preserving one independent plan per order', () => { + const [group] = groupPendingOrdersByContainer([ + order({ id: '1', customerId: '100', inboundCode: 'IN-1', cartons: 10, pallets: 1, volume: 2.5 }), + order({ id: '2', customerId: '200', inboundCode: 'IN-2', cartons: 5, pallets: 2, volume: 1.25 }), + ]) + expect(group).toMatchObject({ + containerNo: 'CONT-1', orderCount: 2, inboundCodeCount: 2, customerCount: 2, + totalCartons: 15, totalPallets: 3, totalVolumeCbm: 3.75, eligible: true, + planNotice: '将为 2 个尾程订单生成 2 个独立入库计划,PDA仍按同柜作业', + orderIds: ['1', '2'], customerIds: ['100', '200'], + }) + }) + + it('fails closed for missing container/customer facts and never treats unknown totals as zero', () => { + const groups = groupPendingOrdersByContainer([ + order({ id: '1', sourceContainerBookingId: '', containerNo: '—', cartons: '—' }), + order({ id: '2', containerNo: 'CONT-2', customerId: '', pallets: '—' }), + ]) + expect(groups.find((group) => group.id === 'missing:1')).toMatchObject({ eligible: false, totalCartons: '—' }) + expect(groups.find((group) => group.containerNo === 'CONT-2')).toMatchObject({ eligible: false, totalPallets: '—' }) + }) +}) diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts new file mode 100644 index 00000000..5a69a5ea --- /dev/null +++ b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts @@ -0,0 +1,67 @@ +import type { LastMileOrderListItem } from './types' + +type DisplayTotal = number | '—' + +export interface LastMileContainerPlanRow { + id: string + sourceContainerBookingId: string + containerNo: string + orderCount: number + inboundCodeCount: number + customerCount: number + totalCartons: DisplayTotal + totalPallets: DisplayTotal + totalVolumeCbm: DisplayTotal + eligibility: string + planNotice: string + eligible: boolean + orderIds: string[] + customerIds: string[] +} + +function sumComplete(values: Array): DisplayTotal { + return values.every((value): value is number => typeof value === 'number') + ? values.reduce((sum, value) => sum + value, 0) + : '—' +} + +export function groupPendingOrdersByContainer(rows: LastMileOrderListItem[]): LastMileContainerPlanRow[] { + const groups = new Map() + rows.forEach((row) => { + const key = row.sourceContainerBookingId || `missing:${row.id}` + groups.set(key, [...(groups.get(key) || []), row]) + }) + + return [...groups.entries()].map(([key, orders]) => { + const sourceContainerBookingId = key.startsWith('missing:') ? '' : key + const containerValues = [...new Set(orders.map((order) => order.containerNo).filter((value) => value !== '—'))] + const containerNo = containerValues.length === 1 ? containerValues[0] : '—' + const customerIds = [...new Set(orders.map((order) => order.customerId.trim()).filter(Boolean))] + const inboundCodes = new Set(orders.map((order) => order.inboundCode).filter((value) => value !== '—')) + const hasCompleteCustomerScope = orders.every((order) => Boolean(order.customerId.trim())) + const eligible = Boolean(sourceContainerBookingId) && containerNo !== '—' && hasCompleteCustomerScope + const eligibility = !sourceContainerBookingId + ? '缺少来源柜身份,不可生成' + : containerNo === '—' + ? '柜号不一致或缺失,不可生成' + : !hasCompleteCustomerScope + ? '存在缺失客户,不可生成' + : '可生成' + return { + id: key, + sourceContainerBookingId, + containerNo, + orderCount: orders.length, + inboundCodeCount: inboundCodes.size, + customerCount: customerIds.length, + totalCartons: sumComplete(orders.map((order) => order.cartons)), + totalPallets: sumComplete(orders.map((order) => order.pallets)), + totalVolumeCbm: sumComplete(orders.map((order) => order.volume)), + eligibility, + planNotice: `将为 ${orders.length} 个尾程订单生成 ${orders.length} 个独立入库计划,PDA仍按同柜作业`, + eligible, + orderIds: orders.map((order) => order.id), + customerIds, + } + }).sort((left, right) => left.containerNo.localeCompare(right.containerNo)) +} diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 2588b24a..dafe7d12 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -56,6 +56,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO return { id: String(dto.id), customerId: String(dto.customerId), + sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(), orderNo: text(dto.orderNo), warning: dto.abnormal ? '异常' : '—', orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)), diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 564b5787..e09e981a 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -3,14 +3,14 @@ import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' -import DataTable from '@/shared/components/DataTable.vue' -import { convertLastMileOrderToInboundPlan, listLastMileOrders } from '../api/lastMileOrders' +import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders } from '../api/lastMileOrders' import LastMileOrdersView from './LastMileOrdersView.vue' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('../api/lastMileOrders', () => ({ listLastMileOrders: vi.fn(), + convertLastMileContainerToInboundPlans: vi.fn(), convertLastMileOrderToInboundPlan: vi.fn(), transferLastMileOrders: vi.fn(), })) @@ -28,22 +28,25 @@ function memoryStorage(): Storage { enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) -function selectedRow(customerId = '2092618717861048321') { - return { id: '2092873463568596994', customerId, orderNo: 'LMO-260827-0001' } -} - async function mountView() { const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() return wrapper } -async function selectAndConvert(wrapper: Awaited>, row = selectedRow()) { - wrapper.findComponent(DataTable).vm.$emit('selection-change', [row]) - await wrapper.vm.$nextTick() - const button = wrapper.findAll('button').find((item) => item.text().includes('生成入库计划')) - expect(button).toBeDefined() - await button!.trigger('click') +const containerRecords = [ + { id: '1', customerId: '100', sourceContainerBookingId: '2092873463568597001', orderNo: 'LMO-1', inboundCode: 'IN-1', containerNo: 'CONT-1', packageCount: 10, palletCount: 1, volumeCbm: 2, currentStatus: 'PENDING_ASSIGNMENT' }, + { id: '2', customerId: '200', sourceContainerBookingId: '2092873463568597001', orderNo: 'LMO-2', inboundCode: 'IN-2', containerNo: 'CONT-1', packageCount: 5, palletCount: 2, volumeCbm: 1, currentStatus: 'PENDING_ASSIGNMENT' }, +] + +function plan(id: string, customerId: string) { + return { id, planNo: `LIP-${id}`, customerId, sourceOrderId: id, containerNo: 'CONT-1', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 1, lines: [], pallets: [], allocations: [] } +} + +async function openPendingContainers(wrapper: Awaited>) { + const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) + expect(pendingTab).toBeDefined() + await pendingTab!.trigger('click') await flushPromises() } @@ -53,50 +56,48 @@ describe('LastMileOrdersView inbound-plan conversion', () => { vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({}) + vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([plan('1', '100'), plan('2', '200')]) }) - it('submits the selected long customer id without coercing it to a number', async () => { - const wrapper = await mountView() - await selectAndConvert(wrapper) - expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ - orderId: '2092873463568596994', customerId: '2092618717861048321', - }) - }) - - it('refreshes the list and displays INBOUND_PLANNED after a successful conversion', async () => { - const base = { - id: '2092873463568596994', customerId: '2092618717861048321', customerName: '华远贸易', - orderNo: 'LMO-260827-0001', orderType: 'FIRST_LEG', - } + it('calls the atomic container endpoint once and refreshes all affected orders', async () => { vi.mocked(listLastMileOrders) - .mockResolvedValueOnce({ records: [{ ...base, currentStatus: 'PENDING_ASSIGNMENT' }], page: 1, size: 10, total: 1, pages: 1 }) - .mockResolvedValueOnce({ records: [{ ...base, currentStatus: 'INBOUND_PLANNED', inboundOrderNo: 'IP-260827-0001' }], page: 1, size: 10, total: 1, pages: 1 }) + .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) + .mockResolvedValueOnce({ records: containerRecords, page: 1, size: 10, total: 2, pages: 1 }) + .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) const wrapper = await mountView() - expect(wrapper.text()).toContain('待分配') - await selectAndConvert(wrapper) - expect(listLastMileOrders).toHaveBeenCalledTimes(2) - expect(wrapper.text()).toContain('已生成入库计划') - expect(wrapper.text()).toContain('IP-260827-0001') - }) - - it('fails closed when the selected row has no customer scope', async () => { - const wrapper = await mountView() - await selectAndConvert(wrapper, selectedRow('')) + await openPendingContainers(wrapper) + expect(wrapper.text()).toContain('CONT-1') + expect(wrapper.text()).toContain('将为 2 个尾程订单生成 2 个独立入库计划,PDA仍按同柜作业') + const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) + expect(button).toBeDefined() + expect(button!.attributes('disabled')).toBeUndefined() + await button!.trigger('click') + await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: '2092873463568597001' }) expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() - expect(wrapper.text()).toContain('所选尾程订单缺少客户标识') + expect(listLastMileOrders).toHaveBeenCalledTimes(3) + expect(document.body.textContent).toContain('已为2个尾程订单生成2个入库计划') }) it.each([ - ['A0404', 'Inbound order not found'], - ['A0409', 'Inbound order version conflict'], - ])('keeps %s request metadata visible after conversion failure', async (code, message) => { - vi.mocked(convertLastMileOrderToInboundPlan).mockRejectedValueOnce(new ApiResponseError( + ['A0404', 'Container orders not found'], + ['A0409', 'Container conversion conflict'], + ])('keeps %s request evidence after the atomic operation fails', async (code, message) => { + vi.mocked(listLastMileOrders) + .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) + .mockResolvedValueOnce({ records: containerRecords, page: 1, size: 10, total: 2, pages: 1 }) + vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError( message, code, 200, undefined, `request-${code}`, `trace-${code}`, )) const wrapper = await mountView() - await selectAndConvert(wrapper) + await openPendingContainers(wrapper) + const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) + await button!.trigger('click') + await flushPromises() expect(wrapper.text()).toContain(code) expect(wrapper.text()).toContain(`request-${code}`) expect(wrapper.text()).toContain(`trace-${code}`) + expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() }) }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index c1619e02..fae38324 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -12,11 +12,12 @@ 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 { convertLastMileOrderToInboundPlan, listLastMileOrders, transferLastMileOrders } from '../api/lastMileOrders' +import { convertLastMileContainerToInboundPlans, listLastMileOrders, transferLastMileOrders } from '../api/lastMileOrders' import OrderReferenceText from '../components/OrderReferenceText.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' import { toLastMileOrderListItem } from '../lastMilePresentation' +import { groupPendingOrdersByContainer, type LastMileContainerPlanRow } from '../lastMileContainerPlanning' import type { LastMileOrderListItem } from '../types' const rows = ref([]) @@ -93,6 +94,19 @@ const columns: DataTableColumn[] = [ { key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 }, { key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 }, ] +const containerColumns: DataTableColumn[] = [ + { key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, + { key: 'containerNo', prop: 'containerNo', label: '柜号', minWidth: 180 }, + { key: 'orderCount', prop: 'orderCount', label: '订单数', width: 90, align: 'right' }, + { key: 'inboundCodeCount', prop: 'inboundCodeCount', label: '入仓码数', width: 100, align: 'right' }, + { key: 'customerCount', prop: 'customerCount', label: '客户数', width: 90, align: 'right' }, + { key: 'totalCartons', prop: 'totalCartons', label: '总箱数', width: 100, align: 'right' }, + { key: 'totalPallets', prop: 'totalPallets', label: '总托数', width: 100, align: 'right' }, + { key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right' }, + { key: 'eligibility', prop: 'eligibility', label: '资格', minWidth: 160, slotName: 'eligibility' }, + { key: 'planNotice', prop: 'planNotice', label: '生成说明', minWidth: 320 }, +] +const containerRows = computed(() => groupPendingOrdersByContainer(rows.value)) async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) @@ -132,7 +146,7 @@ async function load(model: QueryModel = filters.value) { function search(model: QueryModel) { page.value = 1; void load(model) } function reset(model: QueryModel) { page.value = 1; void load(model) } -function changeStatusTab() { page.value = 1; void load() } +function changeStatusTab() { selectedRows.value = []; page.value = 1; void load() } function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] } function openTransfer() { if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return } @@ -152,20 +166,30 @@ async function submitTransfer() { } catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') } finally { actionLoading.value = false } } -async function convertInboundPlan() { - if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } - const selected = selectedRows.value[0] - const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : '' - if (!customerId) { - actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' } +async function generateContainerInboundPlans(row: LastMileContainerPlanRow) { + if (!row.eligible) { + actionError.value = { message: row.eligibility } return } - actionLoading.value = true; actionError.value = null + if (!row.sourceContainerBookingId) { + actionError.value = { message: '所选整柜缺少稳定来源柜身份,无法安全生成入库计划' } + return + } + actionLoading.value = true + actionError.value = null try { - await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId }) - selectedRows.value = []; ElMessage.success('入库计划生成成功'); await load() - } catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') } - finally { actionLoading.value = false } + const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: row.sourceContainerBookingId }) + if (plans.length !== row.orderCount) { + actionError.value = { message: `柜级接口返回 ${plans.length} 个计划,与所选整柜 ${row.orderCount} 个尾程订单不一致` } + return + } + ElMessage.success(`已为${row.orderCount}个尾程订单生成${plans.length}个入库计划`) + await load() + } catch (error) { + actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试') + } finally { + actionLoading.value = false + } } function openDetail(source: object) { @@ -195,8 +219,19 @@ onMounted(() => { void load() }) -
- +
+ + + + + +
+
+ @@ -215,4 +250,5 @@ onMounted(() => { void load() }) diff --git a/src/modules/overseas-warehouse/types.ts b/src/modules/overseas-warehouse/types.ts index 295ce1d0..3f69f128 100644 --- a/src/modules/overseas-warehouse/types.ts +++ b/src/modules/overseas-warehouse/types.ts @@ -6,6 +6,7 @@ export interface LastMileOrderListItem { id: string customerId: string + sourceContainerBookingId: string orderNo: string warning: string orderType: string