diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index b8734de7..40fade8c 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -24,7 +24,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 () => { @@ -33,6 +33,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('宁波 → 长滩') @@ -53,7 +54,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, diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 28a25478..64c1af48 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -73,7 +73,7 @@ const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatu const queryFields = computed(() => [ { key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } }, { 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[] = [ @@ -90,7 +90,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] @@ -240,8 +240,8 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) }) + - diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index b4de8d78..ab9af385 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -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' const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() })) @@ -73,6 +75,15 @@ 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' }, + { label: '装柜完成', value: 'COMPLETED' }, + ]) + expect(wrapper.text()).not.toContain('税率') expect(wrapper.text()).not.toContain('是否退税') expect(wrapper.text()).not.toContain('美森正班') @@ -88,23 +99,21 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('CB-1 装柜方案') }) - it('keeps pushed containers visible and routes them to the preload list without loading an editing draft', async () => { + it('keeps picking containers editable while hiding the push action', async () => { const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const } + const pickingPlan = { ...plan, bookingId: pushed.id, status: 'LOADING' as const } vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 }) + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(pickingPlan) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled() - expect(listSmartLoadingCandidates).not.toHaveBeenCalled() - expect(wrapper.text()).toContain('该柜次正在预装流程中') - expect(wrapper.text()).not.toContain('智能推荐') + expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(pushed.id) + expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.warehouseId) + expect(wrapper.text()).toContain('CB-2') + expect(wrapper.text()).toContain('拣货中') + expect(wrapper.text()).toContain('智能推荐') + expect(wrapper.text()).toContain('加入') expect(wrapper.findAll('button').some((item) => item.text().trim() === '推送预装')).toBe(false) - - await wrapper.findAll('button').find((item) => item.text().includes('查看预装清单'))!.trigger('click') - expect(routerPush).toHaveBeenCalledWith({ - name: 'loading-plans', - query: { planId: pushed.planId, warehouseId: pushed.warehouseId }, - }) }) it('routes completed containers to the read-only loading list', async () => { @@ -145,4 +154,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('该订单在拣货中不许删除') + }) }) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index 90d36f12..fbc18ffd 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -56,7 +56,7 @@ const canAssign = computed(() => access.can('platform:firstleg:smart-loading:ass const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete')) const canPush = computed(() => access.can('platform:firstleg:smart-loading:push')) const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null) -const editable = computed(() => plan.value?.status === 'EDITING') +const editable = computed(() => plan.value?.status === 'EDITING' || plan.value?.status === 'LOADING') const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null) const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId))) const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0)) @@ -71,9 +71,10 @@ const queryFields = computed(() => [ { key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、客户、货物', width: 250 }, { key: 'containerKeyword', type: 'input', label: '柜次', placeholder: '柜次号、集装箱号、目的仓', width: 230 }, { key: 'status', type: 'select', label: '柜次状态', width: 160, options: [ - { label: '可编辑', value: 'EDITING' }, - { label: '已推送预装', value: 'LOADING' }, - { label: '已完成', value: 'COMPLETED' }, + { label: '待装柜', value: 'WAIT_LOADING' }, + { label: '预装柜中', value: 'EDITING' }, + { label: '拣货中', value: 'LOADING' }, + { label: '装柜完成', value: 'COMPLETED' }, ] }, ]) @@ -97,7 +98,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[] = [ @@ -126,10 +128,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) @@ -163,9 +165,7 @@ async function loadSelectedData(preserved: Record = {}) { const request = ++selectionRequest clearSelectionData() if (!container) return - // Draft recovery is an EDITING-only contract. Later states remain visible and - // continue through their dedicated read-only operational lists. - if (container.planStatus !== 'EDITING') return + if (container.planStatus === 'COMPLETED') return selectionLoading.value = true try { const [candidateResult, planResult] = await Promise.all([ @@ -278,7 +278,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 @@ -295,7 +295,7 @@ async function pushPreload() { async function mutate( action: () => Promise, fallback: string, - recovery: { preserveDrafts?: Record; preserveAssign?: boolean } = {}, + recovery: { preserveDrafts?: Record; preserveAssign?: boolean; pickingConflict?: boolean } = {}, ) { mutating.value = true mutationError.value = null @@ -305,9 +305,13 @@ async function mutate( const details = getApiErrorDetails(error, fallback) if (details.code === 'A0409') { await loadAll(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) @@ -378,7 +382,7 @@ onMounted(load)