diff --git a/AGENTS.md b/AGENTS.md index dac37c46..ec19a163 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,15 +46,15 @@ | 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 | | 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖 | | 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 | -| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` | +| 入库、测量、客户箱规策略与计价主数据 | `modules/inbounds/measurement/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算 | | 财务与柜级公共费用分摊 | `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/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 | +| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers` 和计划分配接口;渠道分组选项与服务端映射必须一致,同柜不可拆组作为单个候选卡片整组分配,前端不得拆成员分页或自行拼组 | | 三期订舱资料、打印、关联候选与出仓前船期 | `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/` | -| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` | +| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | | 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 | | 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` | | 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 | diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index c8a91b68..931719b7 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -95,7 +95,6 @@ function containerPage(records: ContainerBookingResource[] = [container]) { function mockContainerSearch(records: ContainerBookingResource[]) { vi.mocked(listSmartLoadingWorkbenchContainers).mockImplementation(async (query) => containerPage(records.filter((item) => ( item.planStatus !== 'COMPLETED' - && (!query.warehouseId || item.warehouseId === query.warehouseId) && query.channelGroupCodes.includes(resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code || '') )))) } @@ -259,7 +258,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).not.toContain('美森正班海派') }) - it('moves the shipping warehouse into the query filters', async () => { + it('keeps the shipping warehouse as a candidate-only query filter', async () => { const dongguanContainer = { ...container, id: '9007199254740998', @@ -277,16 +276,18 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('MSCU-1') expect(wrapper.text()).toContain('MSCU-DG') + const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) await flushPromises() expect(wrapper.text()).toContain('MSCU-DG') - expect(wrapper.text()).not.toContain('MSCU-1') + expect(wrapper.text()).toContain('MSCU-1') expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: dongguanContainer.warehouseId, page: 1, size: 20 }) + expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount) }) - it('keeps the selected channel when the warehouse changes even if that warehouse has no matching container', async () => { + it('keeps channel cabinets unchanged when another search field has no matching cabinet', async () => { const dongguanOvertimeContainer = { ...container, id: '9007199254740998', @@ -302,15 +303,49 @@ describe('GlobalSmartLoadingView', () => { mockContainerSearch([container, dongguanOvertimeContainer]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() + const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) await flushPromises() expect(wrapper.find('.channel-tags .el-checkbox-button.is-checked').text()).toBe('美森正班') - expect(wrapper.findAll('.container-option')).toHaveLength(0) + expect(wrapper.findAll('.container-option')).toHaveLength(1) + expect(wrapper.text()).toContain('MSCU-1') expect(wrapper.text()).not.toContain('MSCU-DG-MAX') expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(2) expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: '18', page: 1, size: 20 }) + expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount) + }) + + it('filters candidate details by inbound code without filtering channel cabinets', async () => { + const secondContainer = { + ...container, + id: '9007199254740998', + planId: '9007199254740999', + cabinetNo: 'CB-2', + containerNo: 'MSCU-2', + } + vi.mocked(listContainers).mockResolvedValue({ records: [container, secondContainer], page: 1, size: 100, total: 2, pages: 1 }) + mockContainerSearch([container, secondContainer]) + vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId })) + vi.mocked(listSmartLoadingCandidates) + .mockResolvedValueOnce(candidatePage([candidate])) + .mockResolvedValueOnce(candidatePage([{ ...candidate, inboundCode: 'NYEXX2609030002' }])) + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length + + wrapper.findComponent(QueryToolbar).vm.$emit('search', { + warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', expectedArrivalRange: [], orderStatus: '', + }) + await flushPromises() + + expect(wrapper.get('.candidate-pane').text()).toContain('NYEXX2609030002') + expect(wrapper.findAll('.container-option')).toHaveLength(2) + expect(wrapper.text()).toContain('MSCU-1') + expect(wrapper.text()).toContain('MSCU-2') + expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], inboundCode: 'NYEXX2609030002', page: 1, size: 20 }) + expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount) }) it('sends the active name-only customer selector to the server candidate query', () => { @@ -336,6 +371,7 @@ describe('GlobalSmartLoadingView', () => { .mockResolvedValueOnce(candidatePage([{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' }])) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() + const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length wrapper.getComponent(QueryToolbar).vm.$emit('search', { customerId: '', inboundCode: '', orderStatus: 'WAIT_SPLIT', expectedArrivalRange: ['2026-09-10', '2026-09-12'], @@ -347,7 +383,8 @@ describe('GlobalSmartLoadingView', () => { expect(candidates).not.toContain('IN-WRONG-STATUS') expect(candidates).not.toContain('IN-WRONG-DATE') expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], expectedArrivalStart: '2026-09-10', expectedArrivalEnd: '2026-09-12', orderStatus: 'WAIT_SPLIT', page: 1, size: 20 }) - expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], expectedArrivalStart: '2026-09-10', expectedArrivalEnd: '2026-09-12', orderStatus: 'WAIT_SPLIT', page: 1, size: 200 }) + expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount) + expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 200 }) }) it('paginates large inbound candidate results with twenty rows by default', async () => { diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index 008affe8..9a83fb45 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -219,19 +219,19 @@ const candidateColumns: DataTableColumn[] = [ { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70 }, { key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 }, // { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' }, - { key: 'unitGrossWeightKg', label: '平均单箱毛重', width: 100, slotName: 'unitGrossWeight' }, - { key: 'totalGrossWeightKg', label: '总重量', width: 90, slotName: 'candidateTotalWeight' }, - { key: 'averageUnitVolumeCbm', label: '平均单箱体积', width: 100, slotName: 'averageUnitVolume' }, - { key: 'totalVolumeCbm', label: '总体积', width: 90, slotName: 'candidateTotalVolume' }, + { key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' }, + { key: 'totalGrossWeightKg', label: '重量', width: 90, slotName: 'candidateTotalWeight' }, + { key: 'averageUnitVolumeCbm', label: '单箱体积', width: 80, slotName: 'averageUnitVolume' }, + { key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' }, ] const itemColumns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, - { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true }, - { key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' }, - { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 }, - { key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' }, - { key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' }, - { key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' }, + { key: 'actions', label: '操作', width: 80, 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 }, + { key: 'packageCount', label: '分配箱数', width: 100, slotName: 'packageCount' }, + { key: 'grossWeightKg', label: '毛重', width: 80, slotName: 'weight' }, + { key: 'volumeCbm', label: '体积', width: 80, slotName: 'volume' }, ] function formatNumber(value: number, digits = 3) { @@ -306,6 +306,14 @@ function candidateQueryParams(): Parameters[0 return params } +function containerQueryParams(page = 1): Parameters[0] { + return { + channelGroupCodes: [...selectedChannelCodes.value], + page, + size: 200, + } +} + async function loadCandidateData() { if (!selectedChannelCodes.value.length) return const request = ++candidateRequest @@ -357,9 +365,9 @@ async function loadContainerData(preserved: Record = {}) { if (!selectedChannelCodes.value.length) return const request = ++containerRequest try { - const firstPage = await listSmartLoadingWorkbenchContainers({ ...candidateQueryParams(), page: 1, size: 200 }) + const firstPage = await listSmartLoadingWorkbenchContainers(containerQueryParams()) const remainingPages = firstPage.pages > 1 - ? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listSmartLoadingWorkbenchContainers({ ...candidateQueryParams(), page: index + 2, size: 200 }))) + ? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listSmartLoadingWorkbenchContainers(containerQueryParams(index + 2)))) : [] if (request !== containerRequest) return containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records) @@ -431,7 +439,7 @@ async function selectChannels(codes: string[]) { async function applyFilters(model: QueryModel) { appliedQuery.value = { ...model } candidatePage.value = 1 - await loadWorkbenchData() + await loadCandidateData() } async function changeCandidatePage(page: number) {