diff --git a/AGENTS.md b/AGENTS.md index 9c533d58..4e5edcb8 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`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `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..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 7a648fec..2d58fb54 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -18,6 +18,8 @@ import { listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, recommendSmartLoading, + adjustSmartLoadingItem, + removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, } from '../api/platformContainerLoading' @@ -47,6 +49,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 +456,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 +469,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 +493,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() @@ -518,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, @@ -717,13 +746,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 +761,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 f5c95a4a..8fbf636e 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 @@ -521,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 @@ -572,20 +579,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 }) @@ -595,6 +589,7 @@ async function mutate( fallback: string, recovery: { preserveDrafts?: Record; preserveAssign?: boolean; pickingConflict?: boolean } = {}, ) { + if (mutating.value) return mutating.value = true mutationError.value = null try { @@ -685,9 +680,9 @@ onMounted(() => { void load(); void loadCustomers() }) {{ row.coLoadGroupId ? '整组分配' : '加入' }} @@ -763,10 +758,10 @@ onMounted(() => { void load(); void loadCustomers() })
重量{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg
- + - + @@ -778,7 +773,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 || '—' }}

+ - + diff --git a/src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.test.ts b/src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.test.ts new file mode 100644 index 00000000..1a1442db --- /dev/null +++ b/src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.vue'), 'utf8') + +describe('DomesticContainerInventoryDetailView', () => { + it('does not invent an action column for immutable transaction history', () => { + expect(source).not.toContain("label: '操作'") + expect(source).toContain('进销存交易记录') + }) + it('surfaces loading and API diagnostic errors and supports retry', () => { + expect(source).toContain('整柜库存详情加载失败') + expect(source).toContain('@retry="load"') + expect(source).toContain('') + expect(source).toContain('v-loading="loading"') + }) + it('uses a four-column desktop field grid and preserves long business text', () => { + expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))') + expect(source).toContain('overflow-wrap: anywhere') + }) +}) diff --git a/src/modules/domestic-containers/pages/DomesticContainersView.test.ts b/src/modules/domestic-containers/pages/DomesticContainersView.test.ts index 1d3af183..c1a22cf6 100644 --- a/src/modules/domestic-containers/pages/DomesticContainersView.test.ts +++ b/src/modules/domestic-containers/pages/DomesticContainersView.test.ts @@ -12,4 +12,7 @@ describe('DomesticContainersView', () => { it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") }) it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') }) it('matches the prototype four-column inventory summary', () => { expect(source).toContain("import './domesticContainersSummary.css'"); expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))'); expect(summaryStyles).toContain('border: 1px solid #e7edf5'); expect(summaryStyles).toContain('font-size: 20px'); expect(summaryStyles).toContain('.inventory-summary + .data-table > .data-table__toolbar') }) + it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") }) + it('matches backend numeric and container-number input boundaries before submit', () => { expect(source).toContain("pattern: /^[A-Za-z0-9-]{4,32}$/"); expect(source).toContain(':max="999999999"'); expect(source).toContain(':max="999999999999.9999"') }) + it('requires a refreshed inventory version for completed outbound submission', () => { expect(source).toContain("outboundForm.inventoryVersion == null"); expect(source).toContain('库存版本未就绪,请重新选择集装箱') }) }) diff --git a/src/modules/domestic-containers/pages/DomesticContainersView.vue b/src/modules/domestic-containers/pages/DomesticContainersView.vue index b879637f..cade3974 100644 --- a/src/modules/domestic-containers/pages/DomesticContainersView.vue +++ b/src/modules/domestic-containers/pages/DomesticContainersView.vue @@ -45,6 +45,7 @@ const columns = computed(() => activeTab.value === 'inventory ]) function displayTime(value: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') } +function canViewInventory(row: ContainerOrder | InventoryRecord) { return 'inventoryStatus' in row || activeTab.value === 'outbound' || row.status === 'COMPLETED' } function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } } async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } } async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } } @@ -59,20 +60,20 @@ const inboundForm = reactive({ warehouseId: '', cargoName: '', containerNo: '', const outboundForm = reactive({ warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined as number | undefined, boxCount: 1, operationTime: '', status: 'COMPLETED' as ContainerOrderStatus, remark: '' }) const inventoryCandidates = ref([]); const candidateLoading = ref(false); const candidateError = ref(null) const currentForm = computed(() => dialogType.value === 'inbound' ? inboundForm : outboundForm) -const rules: FormRules = { warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }], cargoName: [{ required: true, message: '请输入货物名称', trigger: 'blur' }], containerNo: [{ required: true, message: '请选择或输入集装箱号', trigger: 'change' }], boxCount: [{ required: true, type: 'integer', min: 1, message: '箱数必须为正整数', trigger: 'change' }], volumeCbm: [{ required: true, type: 'number', min: 0.0001, message: '体积必须大于等于 0.0001', trigger: 'change' }], operationTime: [{ required: true, message: '请选择操作时间', trigger: 'change' }], status: [{ required: true, message: '请选择状态', trigger: 'change' }] } +const rules: FormRules = { warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }], cargoName: [{ required: true, message: '请输入货物名称', trigger: 'blur' }], containerNo: [{ required: true, message: '请选择或输入集装箱号', trigger: 'change' }, { pattern: /^[A-Za-z0-9-]{4,32}$/, message: '集装箱号须为 4–32 位字母、数字或连字符', trigger: 'blur' }], boxCount: [{ required: true, type: 'integer', min: 1, max: 999999999, message: '箱数须为 1–999999999 的整数', trigger: 'change' }], volumeCbm: [{ required: true, type: 'number', min: 0.0001, max: 999999999999.9999, message: '体积须在 0.0001–999999999999.9999 m³ 之间', trigger: 'change' }], operationTime: [{ required: true, message: '请选择操作时间', trigger: 'change' }], status: [{ required: true, message: '请选择状态', trigger: 'change' }] } function localDateTime() { const offset = new Date().getTimezoneOffset() * 60_000; return `${new Date(Date.now() - offset).toISOString().slice(0, 16)}:00` } function newIdempotencyKey(type: string) { return `${type}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}` } function openInbound() { dialogType.value = 'inbound'; Object.assign(inboundForm, { warehouseId: '', cargoName: '', containerNo: '', boxCount: 1, volumeCbm: 0.01, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); idempotencyKey.value = newIdempotencyKey('inbound'); submitError.value = null; dialogOpen.value = true } function openOutbound() { dialogType.value = 'outbound'; Object.assign(outboundForm, { warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined, boxCount: 1, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); inventoryCandidates.value = []; candidateError.value = null; idempotencyKey.value = newIdempotencyKey('outbound'); submitError.value = null; dialogOpen.value = true } async function loadCandidates() { inventoryCandidates.value = []; Object.assign(outboundForm, { inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined }); if (!outboundForm.warehouseId) return; candidateLoading.value = true; candidateError.value = null; try { const result = await listDomesticContainerInventories({ page: 1, size: 200, warehouseId: outboundForm.warehouseId }); inventoryCandidates.value = result.records.filter((item) => item.availableBoxes > 0) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '可出库整柜库存加载失败') } finally { candidateLoading.value = false } } async function selectInventory(id: string) { const selected = inventoryCandidates.value.find((item) => String(item.id) === id); if (!selected) return; Object.assign(outboundForm, { inventoryId: id, containerNo: selected.containerNo, cargoName: selected.cargoName, availableBoxes: selected.availableBoxes, inventoryVersion: selected.version }); try { const detail = await getDomesticContainerInventory(id); Object.assign(outboundForm, { availableBoxes: detail.inventory.availableBoxes, inventoryVersion: detail.inventory.version }) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '库存最新版本加载失败') } } -async function submit() { if (!await formRef.value?.validate().catch(() => false)) return; if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.boxCount > outboundForm.availableBoxes) { submitError.value = { message: `库存不足,当前可用 ${outboundForm.availableBoxes} 箱`, code: 'A0409' }; return } saving.value = true; submitError.value = null; try { const result = dialogType.value === 'inbound' ? await createDomesticContainerInbound({ warehouseId: inboundForm.warehouseId, cargoName: inboundForm.cargoName.trim(), containerNo: inboundForm.containerNo.trim().toUpperCase(), boxCount: inboundForm.boxCount, volumeCbm: inboundForm.volumeCbm, inboundTime: inboundForm.operationTime, status: inboundForm.status, remark: inboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value }) : await createDomesticContainerOutbound({ warehouseId: outboundForm.warehouseId, containerNo: outboundForm.containerNo, boxCount: outboundForm.boxCount, outboundTime: outboundForm.operationTime, status: outboundForm.status, remark: outboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value, inventoryVersion: outboundForm.status === 'COMPLETED' ? outboundForm.inventoryVersion : undefined }); ElMessage.success(result.replayed ? `请求已处理,单号 ${result.orderNo}` : `创建成功,单号 ${result.orderNo}`); dialogOpen.value = false; await load() } catch (cause) { submitError.value = getApiErrorDetails(cause, '整柜单据创建失败'); if (dialogType.value === 'outbound' && submitError.value.code === 'A0409') await loadCandidates() } finally { saving.value = false } } +async function submit() { if (!await formRef.value?.validate().catch(() => false)) return; if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.inventoryVersion == null) { submitError.value = { message: '库存版本未就绪,请重新选择集装箱', code: 'A0422' }; return } if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.boxCount > outboundForm.availableBoxes) { submitError.value = { message: `库存不足,当前可用 ${outboundForm.availableBoxes} 箱`, code: 'A0409' }; return } saving.value = true; submitError.value = null; try { const result = dialogType.value === 'inbound' ? await createDomesticContainerInbound({ warehouseId: inboundForm.warehouseId, cargoName: inboundForm.cargoName.trim(), containerNo: inboundForm.containerNo.trim().toUpperCase(), boxCount: inboundForm.boxCount, volumeCbm: inboundForm.volumeCbm, inboundTime: inboundForm.operationTime, status: inboundForm.status, remark: inboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value }) : await createDomesticContainerOutbound({ warehouseId: outboundForm.warehouseId, containerNo: outboundForm.containerNo, boxCount: outboundForm.boxCount, outboundTime: outboundForm.operationTime, status: outboundForm.status, remark: outboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value, inventoryVersion: outboundForm.status === 'COMPLETED' ? outboundForm.inventoryVersion : undefined }); ElMessage.success(result.replayed ? `请求已处理,单号 ${result.orderNo}` : `创建成功,单号 ${result.orderNo}`); dialogOpen.value = false; await load() } catch (cause) { submitError.value = getApiErrorDetails(cause, '整柜单据创建失败'); if (dialogType.value === 'outbound' && submitError.value.code === 'A0409') await loadCandidates() } finally { saving.value = false } } watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) }) +
入库箱数{{ summary.inboundBoxes }}
已出库箱数{{ summary.outboundBoxes }}
当前库存{{ summary.availableBoxes }}
累计入库体积{{ summary.inboundVolumeCbm.toFixed(2) }} m³
+ diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index 73c7ae96..b72aa05a 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -117,6 +117,27 @@ describe('InboundFeePanel', () => { expect(wrapper.find('.surface-panel').exists()).toBe(false) }) + it('supports a display-only fee snapshot without calculation or transition actions', async () => { + vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null }) + const pinia = setupPermissions([ + 'firstleg:fba-pricing:view', 'firstleg:fba-pricing:calculate', 'firstleg:fba-pricing:adjust', + 'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee', + ]) + const wrapper = mount(InboundFeePanel, { + props: { customerId: '10001', inboundId: '88', displayOnly: true }, + global: { plugins: [pinia, ElementPlus] }, + }) + await flushPromises() + + expect(wrapper.find('button[aria-label="刷新费用快照"]').exists()).toBe(true) + expect(wrapper.text()).not.toContain('费用试算') + expect(wrapper.text()).not.toContain('确认费用') + expect(wrapper.text()).not.toContain('确认推送费用') + expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="confirm-fee-button"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false) + }) + it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => { const pinia = setupPermissions(['firstleg:fba-pricing:view']) mount(InboundFeePanel, { diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index e05b3ed1..b51a25ce 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -30,6 +30,7 @@ const props = defineProps<{ tenantId?: string inboundId: string compact?: boolean + displayOnly?: boolean }>() const emit = defineEmits<{ @@ -352,20 +353,20 @@ defineExpose({ reload: loadFee }) > 费用试算 调整初算费用 确认费用 确认推送费用 { 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 @@ - +