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 27fa86be..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,9 +66,12 @@ 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', 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 }) + 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 + expect(candidateParams.getAll('orderStatuses')).toEqual(['PENDING_REVIEW', 'WAIT_MEASURE']) + expect(candidateParams.has('orderStatus')).toBe(false) const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams expect(params.getAll('channelGroupCodes')).toEqual(['EXX', 'HEDE']) expect(params.get('warehouseId')).toBe('5') @@ -112,7 +115,7 @@ describe('platform container loading API', () => { page: '1', size: '200', total: '1', pages: '1', } }) - const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', createdStart: '2026-09-01', createdEnd: '2026-09-03', orderStatus: 'WAIT_SPLIT', page: 1, size: 200 }) + const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', createdStart: '2026-09-01', createdEnd: '2026-09-03', orderStatuses: ['WAIT_SPLIT'], page: 1, size: 200 }) expect(result).toMatchObject({ records: [{ bookingId: '9007199254740993', warehouseId: '8', planId: '9007199254740994', version: 2 }], total: 1 }) const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams @@ -121,7 +124,8 @@ describe('platform container loading API', () => { expect(params.get('inboundCode')).toBe('IN-1') expect(params.get('createdStart')).toBe('2026-09-01') expect(params.get('createdEnd')).toBe('2026-09-03') - expect(params.get('orderStatus')).toBe('WAIT_SPLIT') + expect(params.has('orderStatus')).toBe(false) + expect(params.has('orderStatuses')).toBe(false) expect(params.has('bookingId')).toBe(false) }) @@ -140,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 d6d82bda..2c0947a3 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -119,6 +119,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan { blockedReason: raw.blockedReason || null, pdaTaskId: optionalId(raw.pdaTaskId), pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion), + coLoadGroups: (raw.coLoadGroups || []).map((group) => ({ + ...group, + groupId: id(group.groupId), lockedPlanId: optionalId(group.lockedPlanId), + memberOrderCount: numberValue(group.memberOrderCount), + inboundFragmentCount: numberValue(group.inboundFragmentCount), + fullPackageCount: numberValue(group.fullPackageCount), + memberInboundIds: (group.memberInboundIds || []).map(id), + })), items: (raw.items || []).map(item), } } @@ -228,43 +236,49 @@ export async function completeContainerClearance(containerId: string, expectedVe return document(unwrapApiResponse(response)) } -function smartLoadingQueryParams(query: SmartLoadingCandidateQuery) { +function smartLoadingQueryParams(query: SmartLoadingCandidateQuery, includeOrderStatuses = false) { const params = new URLSearchParams() for (const channelGroupCode of query.channelGroupCodes) params.append('channelGroupCodes', channelGroupCode) + if (includeOrderStatuses) { + for (const orderStatus of query.orderStatuses || []) params.append('orderStatuses', orderStatus) + } const optionalParams: Array<[string, string | number | undefined]> = [ ['warehouseId', query.warehouseId], ['customerId', query.customerId], ['inboundCode', query.inboundCode], ['createdStart', query.createdStart], ['createdEnd', query.createdEnd], - ['orderStatus', query.orderStatus], ['page', query.page], ['size', query.size], + ['page', query.page], ['size', query.size], ] for (const [key, value] of optionalParams) if (value !== undefined && value !== '') params.append(key, String(value)) return params } export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQuery) { - const params = smartLoadingQueryParams(query) + const params = smartLoadingQueryParams(query, true) const response = await http.get, ApiEnvelope>(`${smartLoadingPath}/inbound-candidates`, { params }) const result = unwrapApiResponse(response) return { ...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), + })), + } + }), } } @@ -277,6 +291,8 @@ export async function listSmartLoadingWorkbenchContainers(query: SmartLoadingCan total: numberValue(result.total), pages: numberValue(result.pages), records: result.records.map((raw) => ({ ...raw, bookingId: id(raw.bookingId), warehouseId: id(raw.warehouseId), planId: optionalId(raw.planId), + capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm), + maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg), version: numberValue(raw.version), })), } @@ -335,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..8ad28fed 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,19 +21,30 @@ 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', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/SmartLoadingDialog.vue'), 'utf8') + expect(source).toContain('fullscreenable') + expect(source).toContain('default-fullscreen') + expect(source).not.toContain('Maximize2') + expect(source).not.toContain('const fullscreen') + expect(source).toContain('.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)') expect(source).toContain('height: 94vh; max-height: 94vh;') + expect(source).not.toContain('.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw)') expect(source).toContain('flex: 1; padding: 0; overflow: hidden;') expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;') }) diff --git a/src/modules/container-loading/components/SmartLoadingDialog.vue b/src/modules/container-loading/components/SmartLoadingDialog.vue index 045685cc..fc4b3eba 100644 --- a/src/modules/container-loading/components/SmartLoadingDialog.vue +++ b/src/modules/container-loading/components/SmartLoadingDialog.vue @@ -1,17 +1,8 @@ diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 6cffd382..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,9 +48,16 @@ vi.mock('../api/platformContainerLoading', () => ({ adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), + removeAllUnpickedSmartLoadingItems: vi.fn(), pushSmartLoadingPreload: vi.fn(), })) -vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) })) +vi.mock('@/modules/warehouses', () => ({ + listAllPlatformWarehouses: vi.fn().mockResolvedValue([ + { id: '8', warehouseName: '义乌仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' }, + { id: '18', warehouseName: '东莞仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' }, + ]), +})) +vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) })) const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', @@ -64,6 +72,7 @@ const plan = { id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2, canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, + coLoadGroups: [], items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }], } const candidate = { @@ -89,7 +98,8 @@ function containerPage(records: ContainerBookingResource[] = [container]) { records: records.map((item) => ({ bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo, warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelCode: item.channelCode || '', channelName: item.channelName || '', - containerType: item.containerType, planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version, + containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg, + planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version, })), page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0, } as Awaited> @@ -102,10 +112,18 @@ function mockContainerSearch(records: ContainerBookingResource[]) { } describe('GlobalSmartLoadingView', () => { + it('uses one-line shared text clamps for co-load and assigned-count summaries', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') + expect(source).toContain('') + expect(source).toContain('') + expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;') + expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%; }') + expect(source).not.toContain(' { setActivePinia(createPinia()) vi.clearAllMocks() - vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 }) mockContainerSearch([container]) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan) vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate])) @@ -115,7 +133,6 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 }) expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 }) expect(wrapper.text()).toContain('候选装柜明细') @@ -145,13 +162,12 @@ describe('GlobalSmartLoadingView', () => { total: 1, pages: 1, }) - mockContainerSearch([{ ...container, shippingCompanyId: '32', shippingCompanyName: 'EXX', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }]) - vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])) + mockContainerSearch([container]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(wrapper.text()).toContain('EXX') - expect(wrapper.text()).not.toContain('纽约EXX海铁') + expect(wrapper.text()).toContain('美森正班') + expect(wrapper.text()).not.toContain('美森正班海卡包税') expect(wrapper.text()).not.toContain('缺少渠道快照') expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) }) @@ -280,7 +296,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('MSCU-DG') const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length - wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatus: '' }) + wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] }) await flushPromises() expect(wrapper.text()).toContain('MSCU-DG') @@ -307,7 +323,7 @@ describe('GlobalSmartLoadingView', () => { await flushPromises() const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length - wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatus: '' }) + wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] }) await flushPromises() expect(wrapper.find('.channel-tags .el-checkbox-button.is-checked').text()).toBe('美森正班') @@ -338,7 +354,7 @@ describe('GlobalSmartLoadingView', () => { const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length wrapper.findComponent(QueryToolbar).vm.$emit('search', { - warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', createdRange: [], orderStatus: '', + warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', createdRange: [], orderStatuses: [], }) await flushPromises() @@ -363,7 +379,7 @@ describe('GlobalSmartLoadingView', () => { expect(source).toContain(': candidate.availablePackageCount') }) - it('filters candidates by expected arrival range and order status', async () => { + it('filters candidates by creation range and multiple order statuses', async () => { vi.mocked(listSmartLoadingCandidates) .mockResolvedValueOnce(candidatePage([ { ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' }, @@ -376,7 +392,7 @@ describe('GlobalSmartLoadingView', () => { const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length wrapper.getComponent(QueryToolbar).vm.$emit('search', { - customerId: '', inboundCode: '', orderStatus: 'WAIT_SPLIT', createdRange: ['2026-09-10', '2026-09-12'], + customerId: '', inboundCode: '', orderStatuses: ['WAIT_SPLIT', 'LOADING'], createdRange: ['2026-09-10', '2026-09-12'], }) await flushPromises() @@ -384,7 +400,7 @@ describe('GlobalSmartLoadingView', () => { expect(candidates).toContain('IN-MATCH') expect(candidates).not.toContain('IN-WRONG-STATUS') expect(candidates).not.toContain('IN-WRONG-DATE') - expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], createdStart: '2026-09-10', createdEnd: '2026-09-12', orderStatus: 'WAIT_SPLIT', page: 1, size: 20 }) + expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], createdStart: '2026-09-10', createdEnd: '2026-09-12', orderStatuses: ['WAIT_SPLIT', 'LOADING'], page: 1, size: 20 }) expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount) expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 200 }) }) @@ -438,6 +454,8 @@ describe('GlobalSmartLoadingView', () => { await buttons[0].trigger('click') 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).not.toContain('本次分配箱数') ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click() await flushPromises() @@ -450,10 +468,15 @@ 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, blockReason: null, }])) vi.mocked(assignPendingReviewSmartLoadingOrder).mockResolvedValue(plan) @@ -466,6 +489,12 @@ describe('GlobalSmartLoadingView', () => { expect(add.classes()).not.toContain('is-disabled') await add.trigger('click') await flushPromises() + 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() @@ -509,8 +538,8 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(wrapper.text()).toContain('0.22%') - expect(wrapper.text()).toContain('0.04%') + expect(wrapper.text()).toContain('0.16%') + expect(wrapper.text()).toContain('0.03%') expect(wrapper.text()).not.toContain('0.169999999') expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0) }) @@ -520,16 +549,19 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }> - expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'orderStatus']) - expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([{ label: '义乌仓', value: '8' }]) - expect(fields.find((field) => field.key === 'orderStatus')?.options).toEqual([ - { value: '', label: '全部' }, + const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }>; props?: Record }> + expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'orderStatuses']) + expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([ + { label: '义乌仓', value: '8' }, + { label: '东莞仓', value: '18' }, + ]) + expect(fields.find((field) => field.key === 'orderStatuses')?.options).toEqual([ { value: 'PENDING_REVIEW', label: '待审核' }, { value: 'WAIT_MEASURE', label: '待测量数据' }, { value: 'WAIT_SPLIT', label: '待分柜' }, { value: 'LOADING', label: '装柜中' }, ]) + expect(fields.find((field) => field.key === 'orderStatuses')?.props).toMatchObject({ multiple: true, collapseTags: true, maxCollapseTags: 2 }) expect(source).not.toContain('目的仓') expect(wrapper.text()).not.toContain('税率') @@ -610,7 +642,7 @@ describe('GlobalSmartLoadingView', () => { expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('CB-3') - expect(wrapper.text()).toContain('当前柜次已完成装柜,不可继续编辑') + expect(wrapper.text()).toContain('暂无符合条件的货柜') expect(wrapper.text()).not.toContain('缺少渠道快照') }) @@ -661,7 +693,7 @@ describe('GlobalSmartLoadingView', () => { expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2) expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3) - expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(3) + expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2) expect(warningMessage).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('装柜操作失败') expect(wrapper.text()).not.toContain('request-1') @@ -692,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] } }) @@ -708,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 9594301d..62278091 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -4,6 +4,7 @@ import { Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue' import { ElMessage, ElMessageBox } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' +import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' @@ -18,15 +19,14 @@ import { assignSmartLoadingGroup, assignSmartLoadingItem, getSmartLoadingPlanByBooking, - listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, recommendSmartLoading, + removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, } from '../api/platformContainerLoading' import type { - ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan, SmartLoadingWorkbenchContainer, @@ -42,7 +42,7 @@ const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: fa const access = useAccessPreviewStore() const containers = ref([]) -const containerCatalog = ref([]) +const warehouses = ref([]) const customers = ref([]) const customersLoading = ref(false) const selectedContainerId = ref('') @@ -63,8 +63,8 @@ const selectedCandidate = ref(null) const assignForm = reactive({ packageCount: 1 }) const quantityDrafts = reactive>({}) const planGroupsByInboundId = ref(new Map()) -const query = ref({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], orderStatus: '' }) -const appliedQuery = ref({ ...query.value }) +const query = ref({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] }) +const appliedQuery = ref({ ...query.value, orderStatuses: [] }) const candidatePage = ref(1) const candidatePageSize = ref(20) const candidateTotal = ref(0) @@ -75,8 +75,13 @@ 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 orchestratableContainers = computed(() => containerCatalog.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus))) -const completedContainerCount = computed(() => containerCatalog.value.filter((item) => item.planStatus === 'COMPLETED').length) +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) const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => ( item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName) @@ -86,17 +91,12 @@ const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.va )).length) const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS) const warehouseOptions = computed(() => { - const options = new Map() - for (const item of orchestratableContainers.value) { - if (item.warehouseId && !options.has(item.warehouseId)) options.set(item.warehouseId, item.warehouseName?.trim() || '未命名发货仓') - } - return [...options].map(([value, label]) => ({ value, label })) + return warehouses.value.map((warehouse) => ({ value: String(warehouse.id), label: warehouse.warehouseName })) }) const editable = computed(() => plan.value?.canEdit === true) const assignable = computed(() => plan.value?.canAssign === true) -const selectedContainerResource = computed(() => containerCatalog.value.find((item) => item.id === selectedContainerId.value) || null) const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId))) -type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number; totalVolumeCbm: number } +type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null } function memberOrderSummaries(members: T[]) { const orders = new Map() for (const member of members) { @@ -113,11 +113,11 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) { : `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段` } function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) { - return Number(candidate.unitGrossWeightKg || 0) * Number(candidate.availablePackageCount || 0) + return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0) } function candidateTotalVolume(candidate: SmartLoadingCandidate) { return candidate.availableVolumeCbm == null - ? Number(candidate.averageUnitVolumeCbm || 0) * Number(candidate.availablePackageCount || 0) + ? candidate.averageUnitVolumeCbm == null ? null : Number(candidate.averageUnitVolumeCbm) * Number(candidate.availablePackageCount || 0) : Number(candidate.availableVolumeCbm) } const displayedCandidates = computed(() => { @@ -178,14 +178,13 @@ const displayedPlanItems = computed(() => { const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0)) const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0)) const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0)) -const volumeLimit = computed(() => Number(selectedContainerResource.value?.capacityCbm || 0) * 1.1) -const weightLimit = computed(() => Number(selectedContainerResource.value?.maxWeightKg || 0) * 1.1) +const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.5) +const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.5) const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0) const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0) const displayedVolumePercent = computed(() => percentage(volumePercent.value)) const displayedWeightPercent = computed(() => percentage(weightPercent.value)) const candidateOrderStatusOptions = [ - { value: '', label: '全部' }, { value: 'PENDING_REVIEW', label: '待审核' }, { value: 'WAIT_MEASURE', label: '待测量数据' }, { value: 'WAIT_SPLIT', label: '待分柜' }, @@ -199,7 +198,7 @@ const queryFields = computed(() => [ { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 210 }, { key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260 }, - { key: 'orderStatus', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 160, options: candidateOrderStatusOptions }, + { key: 'orderStatuses', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 210, options: candidateOrderStatusOptions, props: { multiple: true, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 2 } }, ]) const filteredContainers = computed(() => containers.value) @@ -218,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' }, @@ -231,18 +230,21 @@ 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) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' } +function formatMeasurement(value: number | null | undefined, unit: string, digits = 3) { + return value == null ? '待测量' : `${formatNumber(Number(value), digits)} ${unit}` +} function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 } function candidateRow(source: object) { return source as SmartLoadingCandidateRow } function itemRow(source: object) { return source as SmartLoadingInboundItem } @@ -251,17 +253,17 @@ function adjustmentMax(source: object) { const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0 return Math.max(item.minPackageCount, item.packageCount + remaining) } -function displayCabinetNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { +function displayCabinetNo(container: SmartLoadingWorkbenchContainer | null | undefined) { if (!container) return '待生成' const cabinetReference = String(container.cabinetNo || '').trim() return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成' } -function hasRealContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { +function hasRealContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) { if (!container) return false const value = String(container.containerNo || '').trim() return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value) } -function displayActualContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { +function displayActualContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) { return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录' } function syncDrafts(next: SmartLoadingPlan, preserved: Record = {}) { @@ -270,6 +272,7 @@ function syncDrafts(next: SmartLoadingPlan, preserved: Record = } function acceptPlan(next: SmartLoadingPlan, preserved: Record = {}, syncContainerStatus = true) { plan.value = next + planGroupsByInboundId.value = loadSmartLoadingPlanCoLoadGroups(next) noDraft.value = false syncDrafts(next, preserved) const container = selectedContainer.value @@ -300,7 +303,9 @@ function candidateQueryParams(): Parameters[0 const warehouseId = String(appliedQuery.value.warehouseId || '') const customerId = String(appliedQuery.value.customerId || '') const inboundCode = String(appliedQuery.value.inboundCode || '').trim() - const orderStatus = String(appliedQuery.value.orderStatus || '') as typeof params.orderStatus + const orderStatuses = Array.isArray(appliedQuery.value.orderStatuses) + ? appliedQuery.value.orderStatuses.map((value) => String(value)).filter(Boolean) as NonNullable + : [] if (warehouseId) params.warehouseId = warehouseId if (customerId) params.customerId = customerId if (inboundCode) params.inboundCode = inboundCode @@ -308,7 +313,7 @@ function candidateQueryParams(): Parameters[0 params.createdStart = createdRange[0] params.createdEnd = createdRange[1] } - if (orderStatus) params.orderStatus = orderStatus + if (orderStatuses.length) params.orderStatuses = orderStatuses return params } @@ -356,7 +361,6 @@ async function loadSelectedPlan(preserved: Record = {}) { if (request !== selectionRequest) return if (planResult.value) { acceptPlan(planResult.value, preserved, false) - planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value) syncDrafts(planResult.value, preserved) } else noDraft.value = planResult.missing @@ -397,17 +401,11 @@ async function loadAll(preserved: Record = {}) { loading.value = true loadError.value = null try { - const firstPage = await listContainers({ page: 1, size: 100 }) - const remainingPages = firstPage.pages > 1 - ? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 }))) - : [] - containerCatalog.value = [firstPage, ...remainingPages].flatMap((page) => page.records) + warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' }) const availableChannelCodes = new Set(channelOptions.value.map((item) => item.code)) selectedChannelCodes.value = selectedChannelCodes.value.filter((code) => availableChannelCodes.has(code)) if (!selectedChannelCodes.value.length) { - selectedChannelCodes.value = [channelOptions.value.find((channel) => orchestratableContainers.value.some((item) => ( - resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === channel.code - )))?.code || channelOptions.value[0]?.code || ''] + selectedChannelCodes.value = [channelOptions.value[0]?.code || ''] } await loadWorkbenchData(preserved) } catch (error) { @@ -443,7 +441,10 @@ async function selectChannels(codes: string[]) { await loadWorkbenchData() } async function applyFilters(model: QueryModel) { - appliedQuery.value = { ...model } + appliedQuery.value = { + ...model, + orderStatuses: Array.isArray(model.orderStatuses) ? model.orderStatuses.map((value) => String(value)) : [], + } candidatePage.value = 1 await loadCandidateData() } @@ -479,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, @@ -493,14 +494,14 @@ async function assign() { expectedVersion: plan.value!.version, })) assignOpen.value = false - await loadWorkbenchData() + await loadCandidateData() ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次') }, '分配明细失败', { preserveAssign: true }) } 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 @@ -520,7 +521,7 @@ async function recommend() { if (!plan.value) throw error acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)) } - await loadWorkbenchData() + await loadCandidateData() ElMessage.success('智能推荐已生成') }, '智能推荐失败') } @@ -536,7 +537,7 @@ async function adjust(source: object) { if (count > max) { ElMessage.warning(`该明细最多可分配 ${max} 箱`); return } await mutate(async () => { acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version)) - await loadWorkbenchData() + await loadCandidateData() ElMessage.success('箱数已调整') }, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } }) } @@ -553,7 +554,7 @@ async function remove(source: object) { await mutate(async () => { if (row.coLoadGroup) { acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version)) - await loadWorkbenchData() + await loadCandidateData() ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`) return } @@ -564,7 +565,7 @@ async function remove(source: object) { current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version) acceptPlan(current) } - await loadWorkbenchData() + await loadCandidateData() ElMessage.success('该入仓码已从当前柜次移除') }, '移除装柜明细失败', { pickingConflict: true }) } @@ -577,21 +578,8 @@ 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) - } - } - await loadWorkbenchData() + acceptPlan(await removeAllUnpickedSmartLoadingItems(plan.value!.id, plan.value!.version)) + await loadCandidateData() ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`) }, '全部移除失败', { pickingConflict: true }) } @@ -600,6 +588,7 @@ async function mutate( fallback: string, recovery: { preserveDrafts?: Record; preserveAssign?: boolean; pickingConflict?: boolean } = {}, ) { + if (mutating.value) return mutating.value = true mutationError.value = null try { @@ -690,19 +679,19 @@ onMounted(() => { void load(); void loadCustomers() }) {{ row.coLoadGroupId ? '整组分配' : '加入' }} - + - - - - + + + + @@ -768,10 +757,10 @@ onMounted(() => { void load(); void loadCustomers() })
重量{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg
- + - - + + @@ -782,13 +771,17 @@ onMounted(() => { void load(); void loadCustomers() })

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

+

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

+

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

+

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

+ @@ -854,7 +847,9 @@ onMounted(() => { void load(); void loadCustomers() }) .plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; } .assign-dialog p { margin: 0 0 10px; } .assign-dialog :deep(.el-input-number) { width: 100%; } -.candidate-group { display: grid; gap: 4px; } +.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; } +.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; } +.candidate-group :deep(.line-clamp-text) { width: 100%; } .candidate-block { color: var(--el-color-danger) !important; } @media (max-width: 1180px) { :deep(.smart-loading-query .query-toolbar__filters),:deep(.smart-loading-query .query-toolbar__tail),:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: wrap; overflow: visible; } diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index 7f455c0f..dfaab72f 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -29,7 +29,7 @@ const container = { const planResponse = { id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2, canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, - pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, items: [], + pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], items: [], } const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null } function candidatePage(records: unknown[]) { @@ -43,6 +43,15 @@ function candidatePage(records: unknown[]) { } describe('SmartLoadingWorkbenchView', () => { + it('uses one-line shared text clamps for co-load and assigned-count summaries', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8') + expect(source).toContain('') + expect(source).toContain('') + expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;') + expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;') + expect(source).not.toContain(' { setActivePinia(createPinia()) vi.clearAllMocks() @@ -63,7 +72,7 @@ describe('SmartLoadingWorkbenchView', () => { expect(wrapper.text()).not.toContain('LP-99') expect(wrapper.text()).toContain('IN-1') expect(wrapper.text()).not.toContain('ORD-1') - expect(wrapper.text()).toContain('110% 容量上限') + expect(wrapper.text()).toContain('150% 容量上限') }) it('uses loading plan capabilities to keep PDA increments editable', async () => { @@ -179,7 +188,7 @@ describe('SmartLoadingWorkbenchView', () => { await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) await flushPromises() - await wrapper.findAll('button').find((button) => button.text() === '分配')!.trigger('click') + await wrapper.find('.el-table__body-wrapper button').trigger('click') await flushPromises() expect(document.body.textContent).toContain('本次计划装柜箱数') ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click() diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue index f4a021fb..eef11aee 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue @@ -118,8 +118,8 @@ const displayedPlanItems = computed(() => { }) const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0)) const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0)) -const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1) -const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.1) +const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.5) +const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.5) const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0) const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0) const displayedVolumePercent = computed(() => percentage(volumePercent.value)) @@ -158,7 +158,7 @@ function explainCandidate(source: object) { const weight = Number(candidate.unitGrossWeightKg || 0) const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount - return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱` + return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱` } function displayCabinetNo(value: ContainerBookingResource | null | undefined) { if (!value) return '待生成' @@ -351,7 +351,7 @@ onMounted(load)