diff --git a/AGENTS.md b/AGENTS.md index cd3f82ac..97325f4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,11 +46,11 @@ | 消息中心 | `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/measurement/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算 | +| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 | | 财务与柜级公共费用分摊 | `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`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准。待审核/待测量可提前占位,但确认预装和启动 PDA 前必须达到 `WAIT_SPLIT`。人工加入允许跨渠道,智能推荐仍按柜次渠道;柜次容量和限重取服务端字段。同柜不可拆组作为单卡整组分页、分配和移除,前端不得拆成员或自行拼组,任一成员已拣时整组保留 | +| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | | 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `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.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 2c0947a3..da5d5f19 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -104,6 +104,8 @@ function item(raw: SmartLoadingItem): SmartLoadingItem { packageCount: numberValue(raw.packageCount), pickedPackageCount: numberValue(raw.pickedPackageCount), minPackageCount: numberValue(raw.minPackageCount, 1), + totalPackageCount: numberValue(raw.totalPackageCount, raw.packageCount), + remainingPackageCount: numberValue(raw.remainingPackageCount), canAdjust: Boolean(raw.canAdjust), canRemove: Boolean(raw.canRemove), productQuantity: raw.productQuantity == null ? null : numberValue(raw.productQuantity), grossWeightKg: raw.grossWeightKg == null ? null : numberValue(raw.grossWeightKg), diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index b0666730..116af506 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -123,11 +123,11 @@ describe('ContainersView', () => { const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) await flushPromises() - expect(wrapper.text()).toContain('排柜') + expect(wrapper.text()).toContain('智能装柜') expect(wrapper.text()).toContain('预装清单') expect(wrapper.text()).toContain('装柜清单') - await wrapper.findAll('button').find((button) => button.text().trim() === '排柜')!.trigger('click') + await wrapper.findAll('button').find((button) => button.text().trim() === '智能装柜')!.trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('预装清单'))!.trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click') expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } }) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index d363b1ca..bc578e49 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -135,7 +135,7 @@ const queryFields = computed(() => [ { key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } }, { key: 'containerType', type: 'select', label: '柜型', width: 140, placeholder: '请选择箱型', options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } }, - { key: 'status', type: 'select', label: '排柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } }, + { key: 'status', type: 'select', label: '装柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '装柜状态' } }, { key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } }, ]) const columns: DataTableColumn[] = [ @@ -536,7 +536,7 @@ onMounted(async () => {
- + diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 2d58fb54..9ddb6211 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -255,6 +255,37 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('柜子数量2 个') }) + it('selects only the double-clicked channel as a shortcut', async () => { + const other = { + ...container, + id: '9007199254740998', + planId: '9007199254740999', + cabinetNo: 'CB-2', + containerNo: 'MSCU-2', + channelCode: 'HDHK', + channelName: '合德海卡包税', + } + vi.mocked(listContainers).mockResolvedValue({ records: [container, other], page: 1, size: 100, total: 2, pages: 1 }) + mockContainerSearch([container, other]) + vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId })) + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + + const hedeChannel = wrapper.findAllComponents(ElCheckboxButton).find((item) => item.text() === '合德')! + await hedeChannel.find('input').setValue(true) + await flushPromises() + expect(wrapper.findAll('.channel-tags .el-checkbox-button.is-checked')).toHaveLength(2) + + await hedeChannel.trigger('dblclick') + await flushPromises() + + expect(wrapper.findAll('.channel-tags .el-checkbox-button.is-checked').map((item) => item.text())).toEqual(['合德']) + expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ channelGroupCodes: ['HEDE'] })) + expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['HEDE'], page: 1, size: 200 }) + expect(wrapper.text()).toContain('MSCU-2') + expect(wrapper.text()).not.toContain('MSCU-1') + }) + it('merges detailed pricing channels into the same confirmed display group', async () => { const parcelContainer = { ...container, @@ -494,7 +525,7 @@ describe('GlobalSmartLoadingView', () => { 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('这是人工装柜分配,确认后仍会加入') expect(document.body.textContent).toContain('智能推荐不会跨渠道') ;([...document.body.querySelectorAll('button')] .find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click() @@ -523,9 +554,12 @@ describe('GlobalSmartLoadingView', () => { expect(assignSmartLoadingGroup).not.toHaveBeenCalled() }) - it('keeps the package count editor inside its table cell without overflow ellipsis', () => { + it('shows the package summary first and opens the editor from an icon', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') - expect(source).toContain('
{ const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() + await wrapper.find('[aria-label="调整箱数"]').trigger('click') + 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) @@ -809,6 +845,8 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('已拣 3 箱') expect(wrapper.findAll('.row-actions .el-button--danger')).toHaveLength(0) + await wrapper.find('[aria-label="调整箱数"]').trigger('click') + await flushPromises() const quantityInput = wrapper.findAllComponents(ElInputNumber).find((input) => input.props('modelValue') === 6) expect(quantityInput?.props('min')).toBe(3) expect(wrapper.findAll('button').some((button) => button.text().includes('移除未拣'))).toBe(false) @@ -832,11 +870,20 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.findAll('button').some((button) => button.text().includes('加入'))).toBe(false) }) - it('limits adjusted package count to the assigned count plus remaining availability', async () => { + it('uses the server supplied total package count as the adjustment maximum', async () => { vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ 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: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])) + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ + ...plan, + items: [{ ...plan.items[0], totalPackageCount: 10, remainingPackageCount: 4 }], + }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() + expect(wrapper.text()).toContain('使用:6') + expect(wrapper.text()).toContain('未用:4') + expect(wrapper.text()).not.toContain('共 10 箱') + await wrapper.find('[aria-label="调整箱数"]').trigger('click') + await flushPromises() const maxima = wrapper.findAllComponents(ElInputNumber).map((input) => input.props('max')) expect(maxima).toContain(10) }) @@ -878,7 +925,8 @@ describe('GlobalSmartLoadingView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') expect(source).toContain(' import { computed, onMounted, reactive, ref } from 'vue' -import { Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue' +import { Pencil, 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' @@ -39,6 +39,7 @@ import { } from '../smartLoadingChannelGroups' const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false }) +const editingQuantityId = ref('') const access = useAccessPreviewStore() const containers = ref([]) @@ -234,7 +235,7 @@ const itemColumns: DataTableColumn[] = [ { 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: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' }, { key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' }, { key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' }, ] @@ -250,8 +251,12 @@ function candidateRow(source: object) { return source as SmartLoadingCandidateRo function itemRow(source: object) { return source as SmartLoadingInboundItem } function adjustmentMax(source: object) { const item = itemRow(source) - const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0 - return Math.max(item.minPackageCount, item.packageCount + remaining) + return Math.max(item.minPackageCount, item.totalPackageCount || item.packageCount) +} +function startQuantityEdit(source: object) { + const item = itemRow(source) + quantityDrafts[item.id] = item.packageCount + editingQuantityId.value = item.id } function displayCabinetNo(container: SmartLoadingWorkbenchContainer | null | undefined) { if (!container) return '待生成' @@ -440,6 +445,12 @@ async function selectChannels(codes: string[]) { candidatePage.value = 1 await loadWorkbenchData() } +async function selectOnlyChannel(code: string) { + if (selectedChannelCodes.value.length === 1 && selectedChannelCodes.value[0] === code) return + selectedChannelCodes.value = [code] + candidatePage.value = 1 + await loadWorkbenchData() +} async function applyFilters(model: QueryModel) { appliedQuery.value = { ...model, @@ -539,6 +550,7 @@ async function adjust(source: object) { await mutate(async () => { acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version)) await loadCandidateData() + editingQuantityId.value = '' ElMessage.success('箱数已调整') }, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } }) } @@ -623,7 +635,14 @@ onMounted(() => { void load(); void loadCustomers() })
渠道 - {{ channel.name }} + {{ channel.name }}
候选明细
{{ candidateTotal }} 条
@@ -761,7 +780,7 @@ onMounted(() => { void load(); void loadCustomers() }) - + @@ -775,7 +794,7 @@ onMounted(() => { void load(); void loadCustomers() })

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

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

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

- +