优化
This commit is contained in:
@@ -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<ReturnType<typeof mountView>>, 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<ReturnType<typeof mountView>>) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<LastMileOrderListItem[]>([])
|
||||
@@ -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() })
|
||||
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
|
||||
<template #details><ApiErrorMeta :details="actionError" /></template>
|
||||
</LoadErrorState>
|
||||
<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></template>
|
||||
<section v-if="!loadError && statusTab === '待分配'" class="surface-panel table-panel container-planning-panel">
|
||||
<el-alert title="选择一行即代表整柜;一次调用将为柜内每个尾程订单/入仓码分别生成独立入库计划,海外 PDA 仍按同一柜号作业。" type="info" :closable="false" show-icon />
|
||||
<DataTable :data="containerRows" :columns="containerColumns" :loading="loading" preference-key="overseas-last-mile-container-plans" row-key="id" empty-text="暂无待分配整柜">
|
||||
<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 LastMileContainerPlanRow).eligible || actionLoading" @click="generateContainerInboundPlans(row as LastMileContainerPlanRow)">生成整柜入库计划</el-button></span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #eligibility="{ row }"><el-tag :type="(row as LastMileContainerPlanRow).eligible ? 'success' : 'danger'" effect="plain">{{ (row as LastMileContainerPlanRow).eligibility }}</el-tag></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>
|
||||
<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>
|
||||
@@ -215,4 +250,5 @@ onMounted(() => { void load() })
|
||||
<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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user