From 7f7c84d404ed757b5e82c058f5fd34bb0ae46aa0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 10:51:31 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../api/platformContainerLoading.test.ts | 15 ++++++- .../api/platformContainerLoading.ts | 45 ++++++++++++------- .../components/SmartLoadingDialog.test.ts | 11 +++-- .../components/SmartLoadingDialog.vue | 6 +-- .../pages/GlobalSmartLoadingView.test.ts | 21 ++++++--- .../pages/GlobalSmartLoadingView.vue | 41 ++++++++--------- 7 files changed, 84 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ec19a163..92732c75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ | 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | | 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关逐资料完成,全买单柜使用 `complete-all-buy` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | -| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers` 和计划分配接口;渠道分组选项与服务端映射必须一致,同柜不可拆组作为单个候选卡片整组分配,前端不得拆成员分页或自行拼组 | +| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | | 三期订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | | 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index 3582800e..8487b6a3 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path' import { http } from '@/shared/api/http' import { adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, - listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeSmartLoadingGroup, removeSmartLoadingItem, + listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getCustomerContainerTransitSummary, @@ -66,7 +66,7 @@ describe('platform container loading API', () => { it('normalizes every long id in candidates and plans', async () => { vi.mocked(http.get) .mockResolvedValueOnce({ code: '200', data: container }) - .mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, customerId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } }) + .mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } }) expect((await getContainer('9')).id).toBe(String(container.id)) expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', orderStatuses: ['PENDING_REVIEW', 'WAIT_MEASURE'], page: 1, size: 20 })).toMatchObject({ records: [{ accountId: '1', customerId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }], total: 1 }) const candidateParams = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams @@ -144,6 +144,17 @@ describe('platform container loading API', () => { ) }) + it('uses one endpoint to remove all unpicked plan items', async () => { + vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan }) + + await removeAllUnpickedSmartLoadingItems('10', 6) + + expect(http.delete).toHaveBeenCalledWith( + '/platform/v1/first-leg/smart-loading/plans/10/unpicked-items', + { data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-unpicked-') } }, + ) + }) + it('uses stable container id and versioned clearance document actions', async () => { vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } }) vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 9895e157..2c0947a3 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -259,23 +259,26 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue ...result, page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages), - records: result.records.map((raw) => ({ - ...raw, accountId: id(raw.accountId), customerId: id(raw.customerId), inboundId: optionalId(raw.inboundId), - orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), - coLoadGroupId: optionalId(raw.coLoadGroupId), - memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), - groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes), - locked: raw.locked == null ? null : Boolean(raw.locked), - availablePackageCount: numberValue(raw.availablePackageCount), - unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg), - unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg), - averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm), - availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm), - groupMembers: raw.groupMembers.map((member) => ({ - ...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId), - availablePackageCount: numberValue(member.availablePackageCount), - })), - })), + records: result.records.map((raw) => { + const accountId = id(raw.accountId) + return { + ...raw, accountId, customerId: accountId, inboundId: optionalId(raw.inboundId), + orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), + coLoadGroupId: optionalId(raw.coLoadGroupId), + memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), + groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes), + locked: raw.locked == null ? null : Boolean(raw.locked), + availablePackageCount: numberValue(raw.availablePackageCount), + unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg), + unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg), + averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm), + availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm), + groupMembers: raw.groupMembers.map((member) => ({ + ...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId), + availablePackageCount: numberValue(member.availablePackageCount), + })), + } + }), } } @@ -348,3 +351,11 @@ export async function removeSmartLoadingGroup(planId: string, groupId: string, e return plan(unwrapApiResponse(response)) } +export async function removeAllUnpickedSmartLoadingItems(planId: string, expectedPlanVersion: number) { + const response = await http.delete, ApiEnvelope>( + `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/unpicked-items`, + { data: { expectedPlanVersion, operationId: operationId('remove-unpicked') } }, + ) + return plan(unwrapApiResponse(response)) +} + diff --git a/src/modules/container-loading/components/SmartLoadingDialog.test.ts b/src/modules/container-loading/components/SmartLoadingDialog.test.ts index 64f4c3e0..26cc574e 100644 --- a/src/modules/container-loading/components/SmartLoadingDialog.test.ts +++ b/src/modules/container-loading/components/SmartLoadingDialog.test.ts @@ -8,7 +8,7 @@ import SmartLoadingDialog from './SmartLoadingDialog.vue' describe('SmartLoadingDialog', () => { afterEach(() => { document.body.innerHTML = '' }) - it('expands to the viewport and can return to the normal dialog size', async () => { + it('opens fullscreen by default, can return to normal size, and reopens fullscreen', async () => { const wrapper = mount(SmartLoadingDialog, { attachTo: document.body, props: { modelValue: true }, @@ -21,14 +21,19 @@ describe('SmartLoadingDialog', () => { expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false) expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true) - const expand = wrapper.get('button[aria-label="展开全屏"]') - await expand.trigger('click') expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull() expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true) await wrapper.get('button[aria-label="退出全屏"]').trigger('click') expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull() expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true) + + await wrapper.setProps({ modelValue: false }) + await flushPromises() + await wrapper.setProps({ modelValue: true }) + await flushPromises() + expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull() + expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true) }) it('keeps the outer dialog fixed so only workbench data panes scroll', () => { diff --git a/src/modules/container-loading/components/SmartLoadingDialog.vue b/src/modules/container-loading/components/SmartLoadingDialog.vue index 045685cc..33b5b4f2 100644 --- a/src/modules/container-loading/components/SmartLoadingDialog.vue +++ b/src/modules/container-loading/components/SmartLoadingDialog.vue @@ -5,12 +5,10 @@ import AppDialog from '@/shared/components/AppDialog.vue' import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue' const open = defineModel({ default: false }) -const fullscreen = ref(false) +const fullscreen = ref(true) watch(open, (value) => { - if (!value) { - fullscreen.value = false - } + if (value) fullscreen.value = true }) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 7a648fec..6b79bb66 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -18,6 +18,7 @@ import { listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, recommendSmartLoading, + removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, } from '../api/platformContainerLoading' @@ -47,6 +48,7 @@ vi.mock('../api/platformContainerLoading', () => ({ adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), + removeAllUnpickedSmartLoadingItems: vi.fn(), pushSmartLoadingPreload: vi.fn(), })) vi.mock('@/modules/warehouses', () => ({ @@ -453,7 +455,7 @@ describe('GlobalSmartLoadingView', () => { await flushPromises() expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除') expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1') - expect(document.body.textContent).toContain('发货仓:义乌仓 · 渠道:美森正班') + expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班海卡包税') expect(document.body.textContent).not.toContain('本次分配箱数') ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click() await flushPromises() @@ -466,10 +468,12 @@ describe('GlobalSmartLoadingView', () => { it('allows a pending-review order to join through the provisional assignment command', async () => { vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ ...candidate, - customerId: '1', + customerId: '', inboundId: null, orderStatusSnapshot: 'PENDING_REVIEW', inboundCode: 'EXXHK2609020361', + channelCode: 'SLOW', + channelName: '慢船', unitGrossWeightKg: null, averageUnitVolumeCbm: null, availableVolumeCbm: null, @@ -488,6 +492,9 @@ describe('GlobalSmartLoadingView', () => { expect(document.body.textContent).toContain('平均单箱毛重 待测量') expect(document.body.textContent).toContain('平均单箱体积 待测量') expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg') + expect(document.body.textContent).toContain('订单渠道:慢船') + expect(document.body.textContent).toContain('这是人工排柜,确认后仍会加入') + expect(document.body.textContent).toContain('智能推荐不会跨渠道') ;([...document.body.querySelectorAll('button')] .find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click() await flushPromises() @@ -717,13 +724,12 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('trace-remove-1') }) - it('removes every item with the latest returned plan version', async () => { + it('removes every unpicked item with one bulk request', async () => { const secondItem = { ...plan.items[0], id: '102', inboundDetailId: '4', inboundCode: 'IN-2' } const initialPlan = { ...plan, items: [plan.items[0], secondItem] } - const afterFirst = { ...plan, version: 3, items: [secondItem] } const emptyPlan = { ...plan, version: 4, items: [] } vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(initialPlan).mockResolvedValue(emptyPlan) - vi.mocked(removeSmartLoadingItem).mockResolvedValueOnce(afterFirst).mockResolvedValueOnce(emptyPlan) + vi.mocked(removeAllUnpickedSmartLoadingItems).mockResolvedValueOnce(emptyPlan) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) @@ -733,8 +739,9 @@ describe('GlobalSmartLoadingView', () => { await removeAllButton!.trigger('click') await flushPromises() - expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1) - expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondItem.id, 3, 1) + expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledTimes(1) + expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledWith(plan.id, 2) + expect(removeSmartLoadingItem).not.toHaveBeenCalled() expect(wrapper.text()).toContain('当前柜次暂无装柜明细') }) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index a63fa8d4..22edc45b 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -22,6 +22,7 @@ import { listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, recommendSmartLoading, + removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, } from '../api/platformContainerLoading' @@ -74,6 +75,11 @@ let containerRequest = 0 const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign')) const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete')) const selectedContainer = computed(() => containers.value.find((item) => item.bookingId === selectedContainerId.value) || null) +const crossChannelAssignment = computed(() => Boolean( + selectedCandidate.value?.channelCode + && selectedContainer.value?.channelCode + && selectedCandidate.value.channelCode !== selectedContainer.value.channelCode, +)) const orchestratableContainers = computed(() => containers.value) const completedContainerCount = computed(() => 0) const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length) @@ -211,7 +217,7 @@ function preferredContainerId() { } const candidateColumns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' }, + { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, @@ -224,13 +230,13 @@ const candidateColumns: DataTableColumn[] = [ { key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' }, ] const itemColumns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 80, fixed: 'left', slotName: 'actions' }, + { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, { key: 'packageCount', label: '分配箱数', width: 100, slotName: 'packageCount' }, - { key: 'grossWeightKg', label: '毛重', width: 80, slotName: 'weight' }, - { key: 'volumeCbm', label: '体积', width: 80, slotName: 'volume' }, + { key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' }, + { key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' }, ] function formatNumber(value: number, digits = 3) { @@ -474,7 +480,7 @@ async function assign() { const selected = selectedCandidate.value! acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW' ? await assignPendingReviewSmartLoadingOrder(plan.value!.id, { - customerId: selected.customerId, + customerId: selected.accountId, orderId: selected.orderId, inboundCode: selected.inboundCode || '', packageCount: assignForm.packageCount, @@ -495,7 +501,7 @@ async function assign() { function candidateBlocked(candidate: SmartLoadingCandidateRow) { if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') { return Boolean(candidate.blockReason) || candidate.locked === true - || !candidate.inboundCode || candidate.availablePackageCount < 1 + || !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1 } return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.inboundId @@ -572,20 +578,7 @@ async function removeAll() { }).catch(() => false) if (!confirmed || !plan.value) return await mutate(async () => { - let current = plan.value! - for (const inboundItem of removableItems) { - if (inboundItem.coLoadGroup) { - current = await removeSmartLoadingGroup(current.id, inboundItem.coLoadGroup.groupId, current.version) - acceptPlan(current) - continue - } - for (const original of inboundItem.sourceItems) { - const item = current.items.find((value) => value.id === original.id) - if (!item) continue - current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version) - acceptPlan(current) - } - } + acceptPlan(await removeAllUnpickedSmartLoadingItems(plan.value!.id, plan.value!.version)) await loadCandidateData() ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`) }, '全部移除失败', { pickingConflict: true }) @@ -685,7 +678,7 @@ onMounted(() => { void load(); void loadCustomers() }) { void load(); void loadCustomers() })
重量{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg
- + @@ -778,7 +771,9 @@ onMounted(() => { void load(); void loadCustomers() })

{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}

目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}

-

发货仓:{{ selectedContainer?.warehouseName || '—' }} · 渠道:{{ resolveSmartLoadingChannelGroup(selectedContainer?.channelCode, selectedContainer?.channelName)?.name || '—' }}

+

发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ selectedContainer?.channelName || '—' }}

+

订单渠道:{{ selectedCandidate.channelName || '—' }}

+ @@ -756,7 +757,7 @@ onMounted(() => { void load(); void loadCustomers() })
重量{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg
- + diff --git a/src/modules/inbounds/measurement/InboundMeasurementDialog.test.ts b/src/modules/inbounds/measurement/InboundMeasurementDialog.test.ts index fcb50478..9c066939 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementDialog.test.ts +++ b/src/modules/inbounds/measurement/InboundMeasurementDialog.test.ts @@ -9,6 +9,8 @@ const orderListSource = readFileSync(resolve(process.cwd(), 'src/modules/unified describe('InboundMeasurementDialog', () => { it('owns the complete measurement workspace behind one centered dialog', () => { + expect(source).toContain("import AppDialog from '@/shared/components/AppDialog.vue'") + expect(source).toContain(' { expect(source).toContain('measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)') }) + it('offers the shared fullscreen control without forcing fullscreen on open', () => { + expect(source).toContain('fullscreenable') + expect(source).not.toContain('default-fullscreen') + expect(source).not.toContain(' { expect(source).toContain("`测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`") expect(source).not.toContain('props.inboundCode?.trim() || props.inboundId') diff --git a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue index d4986eca..f5c859c2 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue @@ -1,6 +1,7 @@ - + From 4eda56acf1ed703593d7c8e2ff4d10cab959e6aa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 12:18:39 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/GlobalSmartLoadingView.test.ts | 24 ++- .../pages/GlobalSmartLoadingView.vue | 5 +- .../pages/SmartLoadingWorkbenchView.test.ts | 3 +- .../pages/SmartLoadingWorkbenchView.vue | 5 +- .../WeightOcrPanel.behavior.test.ts | 198 +++++++++++++++++- .../weight/components/WeightOcrPanel.test.ts | 9 + .../weight/components/WeightOcrPanel.vue | 118 ++++++++++- src/modules/inbounds/weight/types.ts | 31 +++ 8 files changed, 381 insertions(+), 12 deletions(-) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 6b79bb66..2d58fb54 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -18,6 +18,7 @@ import { listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, recommendSmartLoading, + adjustSmartLoadingItem, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, @@ -525,11 +526,32 @@ describe('GlobalSmartLoadingView', () => { it('keeps the package count editor inside its table cell without overflow ellipsis', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') expect(source).toContain('
{ + vi.mocked(adjustSmartLoadingItem).mockResolvedValue({ + ...plan, + version: 3, + items: [{ ...plan.items[0], packageCount: 4, version: 2 }], + }) + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + + const quantityInput = wrapper.findAllComponents(ElInputNumber).find((input) => input.props('modelValue') === 6) + expect(quantityInput?.props('controls')).toBe(false) + quantityInput?.vm.$emit('update:modelValue', 4) + await flushPromises() + quantityInput?.vm.$emit('blur') + await flushPromises() + + expect(adjustSmartLoadingItem).toHaveBeenCalledTimes(1) + expect(adjustSmartLoadingItem).toHaveBeenCalledWith(plan.id, plan.items[0].id, 4, plan.version, plan.items[0].version) + }) + it('rounds capacity percentages and hides column settings in embedded tables', async () => { vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index 62278091..ec3efbcd 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -527,8 +527,9 @@ async function recommend() { } async function adjust(source: object) { const item = itemRow(source) - if (!plan.value) return + if (!plan.value || mutating.value) return const count = Number(quantityDrafts[item.id]) + if (count === item.packageCount) return if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`) return @@ -760,7 +761,7 @@ onMounted(() => { void load(); void loadCustomers() }) - + diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index dfaab72f..9678238d 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -132,7 +132,8 @@ describe('SmartLoadingWorkbenchView', () => { it('keeps the package count editor inside its table cell without overflow ellipsis', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8') expect(source).toContain('
- + diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts index f9745c05..d570f9b5 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts @@ -61,6 +61,14 @@ const pendingWeight: WeightResultResource = { adjustedAt: null, confirmedBy: null, confirmedAt: null, + palletSequence: 1, + blockId: '70001', + slotNo: 1, + reservationId: '71001', + locationId: '72001', + locationCode: 'A01', + sharedOverflow: false, + slotStatus: 'BOUND', version: 0, } @@ -71,6 +79,24 @@ const workspace: WeightWorkspaceResource = { actualGrossWeightKg: null, actualNetWeightKg: null, weightResults: [pendingWeight], + palletLocationGuide: { + blockId: '70001', + reservedSlotCount: 1, + actualPalletCount: 1, + reservedEmptySlotCount: 0, + slots: [{ + slotNo: 1, + reservationId: '71001', + locationId: '72001', + locationCode: 'A01', + sharedOverflow: false, + slotStatus: 'BOUND', + boundWeightResultId: '30001', + palletSequence: 1, + palletId: '50001', + palletCode: 'TP-001', + }], + }, } function palletPage(records = [{ @@ -205,9 +231,137 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(wrapper.text()).not.toContain('raw-0') }) + it('renders server-bound pallet sequence, target location, and pending putaway status ahead of OCR facts', async () => { + const wrapper = mountPanel() + await flushPromises() + + const card = wrapper.get('.weight-card') + expect(card.text()).toContain('第 1 托') + expect(card.text()).toContain('目标库位') + expect(card.text()).toContain('A01') + expect(card.text()).toContain('整票预留库位') + expect(card.text()).toContain('待上架') + const putawayGuide = card.get('.weight-card__putaway').element + expect(putawayGuide.compareDocumentPosition( + card.get('.weight-card__value').element, + ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('renders the authoritative whole-inbound reservation summary and an empty reserved slot', async () => { + vi.mocked(getWeightWorkspace).mockResolvedValue({ + ...workspace, + palletLocationGuide: { + blockId: '70001', + reservedSlotCount: 3, + actualPalletCount: 2, + reservedEmptySlotCount: 1, + slots: [ + { slotNo: 1, reservationId: '71001', locationId: '72001', locationCode: 'A01', sharedOverflow: false, slotStatus: 'BOUND', boundWeightResultId: '30001', palletSequence: 1, palletId: '50001', palletCode: 'TP-001' }, + { slotNo: 2, reservationId: '71002', locationId: '72002', locationCode: 'A02', sharedOverflow: false, slotStatus: 'TRANSFERRED', boundWeightResultId: '30002', palletSequence: 2, palletId: '50002', palletCode: 'TP-002' }, + { slotNo: 3, reservationId: '71003', locationId: '72003', locationCode: 'A03', sharedOverflow: false, slotStatus: 'RESERVED_EMPTY', boundWeightResultId: null, palletSequence: null, palletId: null, palletCode: null }, + ], + }, + }) + const wrapper = mountPanel() + await flushPromises() + + const guide = wrapper.get('.putaway-guide') + expect(guide.text()).toContain('整票预留库位') + expect(guide.text()).toContain('A01 / A02 / A03') + expect(guide.text()).toContain('预留槽位数3') + expect(guide.text()).toContain('实际托数2') + expect(guide.text()).toContain('预留空位数1') + expect(guide.text()).toContain('A03预留空位本票预留空位') + }) + + it('shows an explicit whole-inbound unassigned state when no guide exists', async () => { + vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, palletLocationGuide: null }) + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.text()).toContain('尚未分配整票预留库位') + }) + + it('marks A0 as a temporary overflow location from the server flag', async () => { + vi.mocked(getWeightWorkspace).mockResolvedValue({ + ...workspace, + weightResults: [{ ...pendingWeight, locationCode: 'A0', sharedOverflow: true }], + palletLocationGuide: { + ...workspace.palletLocationGuide!, + slots: [{ + ...workspace.palletLocationGuide!.slots[0]!, + locationCode: 'A0', + sharedOverflow: true, + }], + }, + }) + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.text()).toContain('A0 临时溢出位') + expect(wrapper.get('.weight-card__putaway').classes()).toContain('is-overflow') + expect(wrapper.get('.putaway-guide__slots li').classes()).toContain('is-overflow') + }) + + it.each([ + ['TRANSFERRED', '已上架'], + ['RELEASED', '已释放'], + ['RESERVED_EMPTY', '预留空位'], + ] as const)('renders the authoritative %s slot status as %s', async (slotStatus, label) => { + vi.mocked(getWeightWorkspace).mockResolvedValue({ + ...workspace, + weightResults: [{ ...pendingWeight, slotStatus }], + }) + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.text()).toContain(label) + }) + + it('shows an explicit unassigned state when the server has not bound a slot', async () => { + vi.mocked(getWeightWorkspace).mockResolvedValue({ + ...workspace, + weightResults: [{ + ...pendingWeight, + palletSequence: null, + blockId: null, + slotNo: null, + reservationId: null, + locationId: null, + locationCode: null, + sharedOverflow: null, + slotStatus: null, + }], + }) + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.text()).toContain('托盘未分配') + expect(wrapper.text()).toContain('目标库位未分配') + }) + it('deletes a pending image with its optimistic-lock version', async () => { - vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) + const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) vi.mocked(deleteWeightResult).mockResolvedValue(undefined) + vi.mocked(getWeightWorkspace) + .mockResolvedValueOnce(workspace) + .mockResolvedValueOnce({ + ...workspace, + weightResults: [], + palletLocationGuide: { + ...workspace.palletLocationGuide!, + actualPalletCount: 0, + reservedEmptySlotCount: 1, + slots: [{ + ...workspace.palletLocationGuide!.slots[0]!, + slotStatus: 'RESERVED_EMPTY', + boundWeightResultId: null, + palletSequence: null, + palletId: null, + palletCode: null, + }], + }, + }) const wrapper = mountPanel() await flushPromises() @@ -215,6 +369,48 @@ describe('WeightOcrPanel confirmation behavior', () => { await flushPromises() expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0) + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining('对应库位仍为本票保留,不会释放给其他订单'), + '删除称重图片', + expect.any(Object), + ) + expect(getWeightWorkspace).toHaveBeenCalledTimes(2) + expect(wrapper.findAll('.weight-card')).toHaveLength(0) + expect(wrapper.get('.putaway-guide').text()).toContain('预留空位数1') + expect(wrapper.get('.putaway-guide').text()).toContain('A01预留空位本票预留空位') + }) + + it('keeps the image and server workspace visible when deletion fails', async () => { + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) + const errorMessage = vi.spyOn(ElMessage, 'error') + vi.mocked(deleteWeightResult).mockRejectedValueOnce( + new ApiResponseError('无权删除当前称重图片', 'A0403', 403), + ) + const wrapper = mountPanel() + await flushPromises() + + await wrapper.get('button[aria-label="删除称重图片 1"]').trigger('click') + await flushPromises() + + expect(wrapper.findAll('.weight-card')).toHaveLength(1) + expect(wrapper.text()).toContain('A01') + expect(getWeightWorkspace).toHaveBeenCalledTimes(1) + expect(errorMessage).toHaveBeenCalledWith(expect.objectContaining({ + message: '当前账号无权访问该数据,请联系管理员确认权限', + })) + }) + + it('shows a retryable workspace load failure without fabricating putaway guidance', async () => { + vi.mocked(getWeightWorkspace).mockRejectedValueOnce( + new ApiResponseError('称重工作区暂不可用', 'A0500', 503), + ) + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.text()).toContain('称重数据加载失败') + expect(wrapper.text()).toContain('称重工作区暂不可用') + expect(wrapper.find('.putaway-guide').exists()).toBe(false) + expect(wrapper.find('.putaway-guide-empty').exists()).toBe(false) }) it('shows only the key weights and allows direct confirmation', async () => { diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts index 24903aa8..76428385 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts @@ -35,6 +35,15 @@ describe('WeightOcrPanel contract', () => { expect(source).toContain('货物重量') expect(source).toContain('palletTareWeightKg') expect(source).toContain('netWeightKg') + expect(source).toContain('row.palletSequence') + expect(source).toContain('row.locationCode') + expect(source).toContain('row.sharedOverflow') + expect(source).toContain('putawayStatusLabel(row.slotStatus)') + expect(source).toContain('workspace.palletLocationGuide.reservedSlotCount') + expect(source).toContain('workspace.palletLocationGuide.actualPalletCount') + expect(source).toContain('workspace.palletLocationGuide.reservedEmptySlotCount') + expect(source).toContain('本票预留空位') + expect(source).toContain('对应库位仍为本票保留,不会释放给其他订单') expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)") }) diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.vue b/src/modules/inbounds/weight/components/WeightOcrPanel.vue index 2518c26a..9ef1b013 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.vue +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.vue @@ -22,7 +22,12 @@ import { getWeightWorkspace, uploadWeightImage, } from '../api/weightOcr' -import type { WeightPalletOption, WeightResultResource, WeightWorkspaceResource } from '../types' +import type { + WeightPalletOption, + WeightPutawaySlotStatus, + WeightResultResource, + WeightWorkspaceResource, +} from '../types' import WeightImageThumbnail from './WeightImageThumbnail.vue' const props = withDefaults(defineProps<{ @@ -180,6 +185,26 @@ function cardWeight(row: WeightResultResource) { return formatWeight(draft?.value ?? null, draft?.unit ?? 'KG') } +function putawayStatusLabel(status: WeightResultResource['slotStatus']) { + return ({ + RESERVED_EMPTY: '预留空位', + BOUND: '待上架', + TRANSFERRED: '已上架', + RELEASED: '已释放', + } as const)[status as Exclude] || '未分配' +} + +function putawayStatusType(status: WeightResultResource['slotStatus']) { + if (status === 'TRANSFERRED') return 'success' + if (status === 'BOUND') return 'warning' + if (status === 'RELEASED') return 'info' + return 'info' +} + +function guideSlotStatusType(status: WeightPutawaySlotStatus) { + return putawayStatusType(status) +} + function hasWeightDraftChanged(row: WeightResultResource) { const draft = weightDrafts.value[row.id] if (!draft) return false @@ -403,8 +428,8 @@ async function removeResult(row: WeightResultResource) { try { await ElMessageBox.confirm( row.recognitionStatus === 'CONFIRMED' - ? '删除后将重新计算货物总重量,确认删除这张称重图片吗?' - : '确认删除这张称重图片吗?', + ? '删除后将重新计算货物总重量;对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?' + : '对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?', '删除称重图片', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }, ) @@ -532,6 +557,41 @@ defineExpose({ reload: loadWorkspace })