This commit is contained in:
2026-08-27 17:49:05 +08:00
parent 929f50dc55
commit 44d05e77cb
3 changed files with 182 additions and 126 deletions
@@ -7,8 +7,8 @@
| 尾程列表/详情 | `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 元数据;详情只读展示离仓关联事实 |
| 尾程转目的仓 | `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/orders/container-summaries` | DTO:`sourceContainerBookingId,containerNo?,orderCount,inboundCodeCount,customerCount,totalPackageCount,totalPalletCount,totalVolumeCbm,eligible,ineligibleReason?`;query:`page,size,containerNo?,createdFrom?,createdTo?` | 服务端一柜一行并负责全量聚合;前端只消费摘要分页,不从订单分页本地聚合 |
| 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功按接口返回计划数提示并刷新原待分配订单列表,不用当前订单页行数校验整柜总数;错误保留 code/requestId/traceId |
| 待分配整柜摘要分页 | `GET /platform/v1/last-leg/orders/container-summaries` | DTO:`sourceContainerBookingId,containerNo?,orderCount,inboundCodeCount,customerCount,totalPackageCount,totalPalletCount,totalVolumeCbm,eligible,ineligibleReason?`;query:`page,size,containerNo?,createdFrom?,createdTo?` | 仅供原待分配订单页的“按柜批量生成”选择弹框加载柜候选;不替换原订单表,也不从订单分页本地聚合 |
| 入库列表 | `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 仓勾选 |
@@ -3,14 +3,20 @@ 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 { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders, listPendingContainerSummaries } from '../api/lastMileOrders'
import DataTable from '@/shared/components/DataTable.vue'
import {
convertLastMileContainerToInboundPlans,
convertLastMileOrderToInboundPlan,
listLastMileOrders,
listPendingContainerSummaries,
} 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(), listPendingContainerSummaries: vi.fn(),
convertLastMileContainerToInboundPlans: vi.fn(), convertLastMileOrderToInboundPlan: vi.fn(), transferLastMileOrders: vi.fn(),
convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -22,94 +28,126 @@ function memoryStorage(): Storage {
}
}
const bookingId = '2092873463568597001'
const customerId = '2092618717861048321'
const summary = {
sourceContainerBookingId: '2092873463568597001', containerNo: 'CONT-15', orderCount: 15,
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', orderCount: 15,
inboundCodeCount: 15, customerCount: 3, totalPackageCount: 150, totalPalletCount: 30,
totalVolumeCbm: 45.5, eligible: true, ineligibleReason: null,
}
function plans(count = 15) {
return Array.from({ length: count }, (_, index) => ({
id: String(index + 1), planNo: `LIP-${index + 1}`, customerId: String(index % 3 + 1), sourceOrderId: String(index + 1),
containerNo: 'CONT-15', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 1,
lines: [], pallets: [], allocations: [],
}))
function selectedRow(overrides: Record<string, unknown> = {}) {
return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides }
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
async function openPending() {
async function mountView() {
const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配'))
return wrapper
}
async function openPending() {
const wrapper = await mountView()
const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')
await pendingTab!.trigger('click')
await flushPromises()
return wrapper
}
describe('LastMileOrdersView server-side container summaries', () => {
async function selectRows(wrapper: Awaited<ReturnType<typeof mountView>>, rows: object[]) {
wrapper.findComponent(DataTable).vm.$emit('selection-change', rows)
await wrapper.vm.$nextTick()
}
function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
return wrapper.findAll('button').find((item) => item.text().includes(text))
}
async function openContainerDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
await button(wrapper, '按柜批量生成')!.trigger('click')
await flushPromises()
}
async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
const containerSelect = selects[selects.length - 1]
expect(containerSelect).toBeDefined()
containerSelect.vm.$emit('update:modelValue', bookingId)
await wrapper.vm.$nextTick()
}
describe('LastMileOrdersView pending order and container conversion', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(listPendingContainerSummaries).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 })
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue(plans())
vi.mocked(convertLastMileOrderToInboundPlan).mockReset()
vi.mocked(listPendingContainerSummaries).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 50, total: 1, pages: 1 })
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
})
it('uses one server summary for a 15-order container that could span order pages', async () => {
it('keeps the original pending order table and does not render a view switch or container table', async () => {
const wrapper = await openPending()
expect(listPendingContainerSummaries).toHaveBeenCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined })
expect(wrapper.text()).toContain('CONT-15')
expect(wrapper.text()).toContain('15')
expect(wrapper.text()).toContain('将为 15 个尾程订单生成 15 个独立入库计划')
expect(listLastMileOrders).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('尾程单号')
expect(wrapper.text()).toContain('海外仓')
expect(wrapper.text()).toContain('转仓')
expect(wrapper.text()).toContain('生成入库计划')
expect(wrapper.text()).toContain('按柜批量生成')
expect(wrapper.text()).not.toContain('按订单')
expect(wrapper.text()).not.toContain('待分配整柜')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: 'PENDING_ASSIGNMENT' }))
expect(listPendingContainerSummaries).not.toHaveBeenCalled()
})
it('requests the next server summary page instead of deriving containers from order rows', async () => {
it('keeps single-order conversion isolated with long order and customer ids', async () => {
const wrapper = await openPending()
const nextPage = wrapper.find('button[aria-label="第 2 页"]')
expect(nextPage.exists()).toBe(true)
await nextPage.trigger('click')
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(listPendingContainerSummaries).toHaveBeenLastCalledWith({
page: 2, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined,
})
expect(listLastMileOrders).toHaveBeenCalledTimes(1)
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1)
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId })
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
expect(listPendingContainerSummaries).not.toHaveBeenCalled()
})
it('calls convert-container once, validates N plans and refreshes the server summary', async () => {
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
const wrapper = await openPending()
const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划'))
await button!.trigger('click')
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: '2092873463568597001' })
expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled()
expect(listPendingContainerSummaries).toHaveBeenCalledTimes(2)
expect(document.body.textContent).toContain('已为15个尾程订单生成15个入库计划')
})
it('does not report success or refresh when N returned plans do not match N orders', async () => {
vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValueOnce(plans(14))
const wrapper = await openPending()
const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划'))
await button!.trigger('click')
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
await openContainerDialog(wrapper)
expect(listPendingContainerSummaries).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('返回 14 个计划')
expect(document.body.textContent).not.toContain('已为15个尾程订单生成14个入库计划')
expect(listPendingContainerSummaries).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
expect(document.body.textContent).toContain('选择待分配柜')
expect(document.body.textContent).toContain('将为所选柜内所有待分配尾程订单分别生成独立入库计划')
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
it.each(['A0404', 'A0409'])('keeps %s request evidence visible', async (code) => {
it('selects one cabinet and sends one long-id convert-container request without using current-page row counts', async () => {
const wrapper = await openPending()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
expect(confirm.disabled).toBe(false)
confirm.click()
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId })
expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划')
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
})
it.each(['A0404', 'A0409'])('keeps %s request evidence visible after cabinet conversion failure', async (code) => {
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError(
'柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`,
))
const wrapper = await openPending()
const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划'))
await button!.trigger('click')
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain(code)
expect(wrapper.text()).toContain(`request-${code}`)
expect(wrapper.text()).toContain(`trace-${code}`)
@@ -12,7 +12,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 { convertLastMileContainerToInboundPlans, listLastMileOrders, listPendingContainerSummaries, transferLastMileOrders, type PendingContainerSummaryDto } from '../api/lastMileOrders'
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders, listPendingContainerSummaries, transferLastMileOrders, type PendingContainerSummaryDto } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -27,13 +27,17 @@ const router = useRouter()
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const containerTotal = ref(0)
const selectedRows = ref<LastMileOrderListItem[]>([])
const containerRows = ref<PendingContainerSummaryDto[]>([])
const actionLoading = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
const transferOpen = ref(false)
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
const containerConvertOpen = ref(false)
const containerOptions = ref<PendingContainerSummaryDto[]>([])
const containerOptionsLoading = ref(false)
const containerLoadError = ref<ApiErrorDetails | null>(null)
const selectedContainerBookingId = ref('')
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
@@ -60,9 +64,8 @@ const allFields: QueryField[] = [
{ key: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间' },
]
const fields = computed(() => statusTab.value === '待分配'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
const fields = computed<QueryField[]>(() => allFields)
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
@@ -98,45 +101,10 @@ 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: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 100, align: 'right' },
{ key: 'totalPalletCount', prop: 'totalPalletCount', 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', label: '生成说明', minWidth: 320, slotName: 'planNotice' },
]
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
if (statusTab.value === '待分配') {
rows.value = []
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
try {
const result = await listPendingContainerSummaries({
page: page.value,
size: pageSize.value,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
})
containerRows.value = result.records
containerTotal.value = result.total
} catch (error) {
containerRows.value = []
containerTotal.value = 0
loadError.value = getApiErrorDetails(error, '柜级待分配摘要加载失败,请重试')
} finally {
loading.value = false
}
return
}
try {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
@@ -149,7 +117,7 @@ async function load(model: QueryModel = filters.value) {
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
currentStatus: String(filters.value.status || '').trim() || undefined,
currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
abnormal: booleanValue(filters.value.abnormal),
detained: booleanValue(filters.value.detained),
@@ -171,7 +139,11 @@ 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() { selectedRows.value = []; 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 }
@@ -191,24 +163,69 @@ async function submitTransfer() {
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
finally { actionLoading.value = false }
}
async function generateContainerInboundPlans(row: PendingContainerSummaryDto) {
if (!row.eligible) {
actionError.value = { message: row.ineligibleReason || '当前整柜不满足生成资格' }
return
}
if (!row.sourceContainerBookingId) {
actionError.value = { message: '所选整柜缺少稳定来源柜身份,无法安全生成入库计划' }
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: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
return
}
actionLoading.value = true
actionError.value = null
try {
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 convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
selectedRows.value = []
ElMessage.success('入库计划生成成功')
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试')
} finally {
actionLoading.value = false
}
}
async function loadContainerOptions(containerNo = '') {
const request = ++containerOptionRequest
containerOptionsLoading.value = true
containerLoadError.value = null
try {
const result = await listPendingContainerSummaries({
page: 1,
size: 50,
containerNo: containerNo.trim() || undefined,
})
if (request !== containerOptionRequest) return
containerOptions.value = result.records
} catch (error) {
if (request !== containerOptionRequest) return
containerOptions.value = []
containerLoadError.value = getApiErrorDetails(error, '待分配柜列表加载失败,请重试')
} finally {
if (request === containerOptionRequest) containerOptionsLoading.value = false
}
}
function openContainerConversion() {
selectedContainerBookingId.value = ''
containerOptions.value = []
containerLoadError.value = null
actionError.value = null
containerConvertOpen.value = true
void loadContainerOptions()
}
async function convertContainerInboundPlans() {
const container = selectedContainer.value
if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return }
if (!container.eligible) {
actionError.value = { message: container.ineligibleReason || '所选柜当前不满足生成资格' }
return
}
actionLoading.value = true
actionError.value = null
try {
const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: container.sourceContainerBookingId })
containerConvertOpen.value = false
selectedContainerBookingId.value = ''
ElMessage.success(`已按柜生成 ${plans.length} 个独立入库计划`)
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试')
@@ -244,21 +261,8 @@ onMounted(() => { void load() })
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState>
<section v-if="!loadError && statusTab === '待分配'" class="surface-panel table-panel container-planning-panel">
<el-alert title="选择一行即代表整柜;一次调用将为柜内每个尾程订单/入仓码分别生成独立入库计划,海外 PDA 仍按同一柜号作业。" type="info" :closable="false" show-icon />
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="containerRows" :columns="containerColumns" :loading="loading" :total="containerTotal" preference-key="overseas-last-mile-container-plans" row-key="sourceContainerBookingId" empty-text="暂无待分配整柜" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }">
<el-tooltip content="按整柜原子生成入库计划" placement="top">
<span><el-button v-if="access.can(permission.inboundOrderCreate)" size="small" type="primary" plain :disabled="!(row as PendingContainerSummaryDto).eligible || actionLoading" @click="generateContainerInboundPlans(row as PendingContainerSummaryDto)">生成整柜入库计划</el-button></span>
</el-tooltip>
</template>
<template #eligibility="{ row }"><el-tag :type="(row as PendingContainerSummaryDto).eligible ? 'success' : 'danger'" effect="plain">{{ (row as PendingContainerSummaryDto).eligible ? '可生成' : (row as PendingContainerSummaryDto).ineligibleReason || '不可生成' }}</el-tag></template>
<template #planNotice="{ row }">将为 {{ (row as PendingContainerSummaryDto).orderCount }} 个尾程订单生成 {{ (row as PendingContainerSummaryDto).orderCount }} 个独立入库计划,PDA仍按同柜作业</template>
<template #pagination-left>共 {{ containerTotal }} 个待分配整柜</template>
</DataTable>
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
@@ -272,10 +276,24 @@ onMounted(() => { void load() })
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="containerConvertOpen" title="按柜批量生成入库计划" width="640px" confirm-text="确认按柜生成" :loading="containerOptionsLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedContainer || !selectedContainer.eligible" @confirm="convertContainerInboundPlans">
<p class="container-convert-hint">将为所选柜内所有待分配尾程订单分别生成独立入库计划。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form-item label="选择待分配柜" required>
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择">
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :disabled="!container.eligible" :label="`${container.containerNo || '柜号缺失'} · ${container.orderCount} 票${container.eligible ? '' : ` · ${container.ineligibleReason || '不可生成'}`}`" />
</el-select>
</el-form-item>
<el-alert v-if="selectedContainer" :title="selectedContainer.eligible ? `确认后将生成该柜全部 ${selectedContainer.orderCount} 票的独立入库计划` : selectedContainer.ineligibleReason || '所选柜不可生成'" :type="selectedContainer.eligible ? 'info' : 'error'" :closable="false" show-icon />
</el-form>
</AppDialog>
</template>
<style scoped>
.last-mile-toolbar { padding: 14px 16px 0; }
.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-planning-panel > :deep(.el-alert) { margin-bottom: 12px; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
</style>