This commit is contained in:
2026-08-11 11:52:30 +08:00
parent 852207efee
commit 347200a8c6
10 changed files with 89 additions and 30 deletions
@@ -14,7 +14,7 @@ describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
})
it('loads the real paged contract and renders core container fields', async () => {
@@ -23,6 +23,7 @@ describe('ContainersView', () => {
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
expect(wrapper.text()).toContain('待装柜')
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).toContain('宁波 → 长滩')
@@ -31,7 +32,7 @@ describe('ContainersView', () => {
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 0,
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
@@ -72,7 +72,7 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'warehouseId', type: 'select', label: '装柜仓库', width: 220, options: warehouses.value.map((warehouse) => ({ label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`, value: String(warehouse.id) })), props: { 'aria-label': '装柜仓库', filterable: true, loading: warehouseLoading.value } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '编辑中', value: 'EDITING' }, { label: '装柜中', value: 'LOADING' }, { label: '已完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const columns: DataTableColumn[] = [
@@ -89,7 +89,7 @@ const columns: DataTableColumn[] = [
]
function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ EDITING: '编辑中', LOADING: '装柜中', COMPLETED: '已完成' })[status] }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function containerViewLabel(row: ContainerBookingResource) {
const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
@@ -215,7 +215,7 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
<AppDrawer v-model="editorOpen" :title="editorTitle" size="min(860px, 96vw)" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<el-alert v-if="transportOnly" title="装柜中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" :disabled="transportOnly" /></el-form-item>
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="开始装柜前必须填写" /></el-form-item>
@@ -1,14 +1,16 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import {
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
recommendSmartLoading,
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
vi.mock('@/core/access/accessPreview', () => ({
@@ -67,6 +69,14 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const statusField = wrapper.findComponent(QueryToolbar).props('fields')
.find((field: { key: string }) => field.key === 'status')
expect(statusField?.options).toEqual([
{ label: '待装柜', value: 'WAIT_LOADING' },
{ label: '预装柜中', value: 'EDITING' },
{ label: '拣货中', value: 'LOADING' },
])
expect(wrapper.text()).not.toContain('税率')
expect(wrapper.text()).not.toContain('是否退税')
expect(wrapper.text()).not.toContain('美森正班')
@@ -93,7 +103,7 @@ describe('GlobalSmartLoadingView', () => {
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(loadingContainer.id)
expect(wrapper.text()).toContain('CB-LOADING')
expect(wrapper.text()).toContain('装柜中')
expect(wrapper.text()).toContain('拣货中')
expect(wrapper.text()).not.toContain('CB-COMPLETED')
expect(wrapper.text()).toContain('智能推荐')
expect(wrapper.text()).toContain('加入')
@@ -123,4 +133,31 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).toContain('已刷新最新版本')
expect(wrapper.text()).toContain('request-1')
})
it('shows a picking-specific message when a picked order cannot be removed', async () => {
const loadingContainer = { ...container, planStatus: 'LOADING' as const }
const loadingPlan = { ...plan, status: 'LOADING' as const }
vi.mocked(listContainers).mockResolvedValue({ records: [loadingContainer], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(loadingPlan)
vi.mocked(removeSmartLoadingItem).mockRejectedValue(new AxiosError(
'conflict',
'ERR_BAD_REQUEST',
undefined,
undefined,
{ data: { code: 'A0409', message: '已有拣货记录的明细不能删除' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const errorMessage = vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never)
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const removeButton = wrapper.find('.row-actions .el-button--danger')
expect(removeButton.exists()).toBe(true)
await removeButton.trigger('click')
await flushPromises()
expect(removeSmartLoadingItem).toHaveBeenCalled()
expect(errorMessage).toHaveBeenCalledWith('该订单在拣货中不许删除')
expect(wrapper.text()).toContain('该订单在拣货中不许删除')
})
})
@@ -74,8 +74,9 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'containerKeyword', type: 'input', label: '柜次', placeholder: '柜次号、集装箱号、目的仓', width: 230 },
{ key: 'warehouseId', type: 'select', label: '装柜仓库', options: warehouseOptions.value, width: 190 },
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
{ label: '可编辑', value: 'EDITING' },
{ label: '装柜中', value: 'LOADING' },
{ label: '待装柜', value: 'WAIT_LOADING' },
{ label: '预装柜中', value: 'EDITING' },
{ label: '拣货中', value: 'LOADING' },
] },
])
@@ -102,7 +103,8 @@ const filteredCandidates = computed(() => {
function preferredContainerId() {
const visible = filteredContainers.value
if (String(appliedQuery.value.status || '').trim()) return visible[0]?.id || ''
return visible.find((item) => item.planStatus === 'EDITING')?.id || visible[0]?.id || ''
return visible.find((item) => item.planStatus === 'EDITING')?.id
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
}
const candidateColumns: DataTableColumn[] = [
@@ -131,10 +133,10 @@ function formatNumber(value: number, digits = 3) {
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function statusLabel(status: ContainerPlanStatus) {
return ({ EDITING: '可编辑', LOADING: '装柜中', COMPLETED: '已完成' } as const)[status]
return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as const)[status]
}
function statusType(status: ContainerPlanStatus) {
return status === 'EDITING' ? 'primary' : status === 'LOADING' ? 'warning' : 'success'
return status === 'WAIT_LOADING' ? 'info' : status === 'EDITING' ? 'primary' : status === 'LOADING' ? 'warning' : 'success'
}
function routeMatches(source: object) {
const candidate = candidateRow(source)
@@ -274,7 +276,7 @@ async function remove(source: object) {
await mutate(async () => {
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
ElMessage.success('装柜明细已移除')
}, '移除装柜明细失败')
}, '移除装柜明细失败', { pickingConflict: true })
}
async function pushPreload() {
if (!plan.value) return
@@ -291,7 +293,7 @@ async function pushPreload() {
async function mutate(
action: () => Promise<void>,
fallback: string,
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean } = {},
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
) {
mutating.value = true
mutationError.value = null
@@ -301,9 +303,13 @@ async function mutate(
const details = getApiErrorDetails(error, fallback)
if (details.code === 'A0409') {
await loadSelectedData(recovery.preserveDrafts)
mutationError.value = { ...details, message: '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
? '该订单在拣货中不许删除'
: null
mutationError.value = { ...details, message: pickingMessage || '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
if (recovery.preserveAssign) assignOpen.value = true
ElMessage.warning(mutationError.value.message)
if (pickingMessage) ElMessage.error(pickingMessage)
else ElMessage.warning(mutationError.value.message)
} else {
mutationError.value = details
ElMessage.error(details.message)
@@ -374,7 +380,7 @@ onMounted(load)
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>货柜</strong><small>可用与装柜中</small></div></header>
<header class="pane-header"><div><strong>货柜</strong><small>待装柜、预装柜中与拣货中</small></div></header>
<div v-loading="loading" class="container-list">
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
<button
@@ -148,7 +148,7 @@ async function remove(source: object) {
mutating.value = true
mutationError.value = null
try { acceptPlan(await removeSmartLoadingItem(plan.value.id, item.id, plan.value.version, item.version)); ElMessage.success('装柜明细已移除') }
catch (error) { handleMutationError(error, '移除失败') }
catch (error) { handleMutationError(error, '移除失败', true) }
finally { mutating.value = false }
})
}
@@ -162,9 +162,12 @@ async function pushPreload() {
catch (error) { handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
function handleMutationError(error: unknown, fallback: string) {
function handleMutationError(error: unknown, fallback: string, pickingConflict = false) {
const details = getApiErrorDetails(error, fallback)
mutationError.value = { ...details, message: details.code === 'A0409' ? '计划已被其他人修改,请刷新最新计划后重试' : details.message }
const message = pickingConflict && details.code === 'A0409' && details.message.includes('拣货')
? '该订单在拣货中不许删除'
: details.code === 'A0409' ? '计划已被其他人修改,请刷新最新计划后重试' : details.message
mutationError.value = { ...details, message }
ElMessage.error(mutationError.value.message)
}
@@ -190,7 +193,7 @@ onMounted(load)
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else>
<LoadErrorState v-if="mutationError" title="排柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div><div><span>目的仓</span><strong>{{ container.destinationWarehouseName || container.destinationWarehouseCode || '不限' }}</strong></div><div><span>计划状态</span><strong>{{ plan?.status === 'LOADING' ? '已推送预装' : plan ? '编辑中' : '未创建' }}</strong></div></section>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div><div><span>目的仓</span><strong>{{ container.destinationWarehouseName || container.destinationWarehouseCode || '不限' }}</strong></div><div><span>计划状态</span><strong>{{ plan?.status === 'LOADING' ? '拣货中' : plan ? '预装柜中' : '待装柜' }}</strong></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(volumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(weightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<el-empty v-if="noDraft" description="该货柜尚未创建排柜计划"><el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建排柜计划</el-button></el-empty>
<template v-else-if="plan">
+1 -1
View File
@@ -1,4 +1,4 @@
export type ContainerPlanStatus = 'EDITING' | 'LOADING' | 'COMPLETED'
export type ContainerPlanStatus = 'WAIT_LOADING' | 'EDITING' | 'LOADING' | 'COMPLETED'
export interface ContainerBookingResource {
id: string