From 8ccfb9eea40ade7b85a334f0937d972d366dc634 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 15:34:48 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d2463417..f84bc0bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,8 @@ | 订单列表、详情、商品图片与入库费用 | `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/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/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | +| 财务与柜级公共费用分摊、客户资金账户 | `modules/finance/`,继续读该目录 `AGENTS.md`;资金账户 `pages/FinanceCustomerAccountsView.vue`,柜费入口 `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`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | From 7ba81007a81b1eaa05a83daa24e49e7b92b6e672 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:24:49 +0800 Subject: [PATCH 2/9] feat: support dropship pallet loading --- .../api/platformContainerLoading.test.ts | 13 ++++- .../api/platformContainerLoading.ts | 13 ++++- .../pages/GlobalSmartLoadingView.test.ts | 9 ++- .../pages/GlobalSmartLoadingView.vue | 39 +++++++++---- .../pages/SmartLoadingWorkbenchView.test.ts | 33 +++++++++-- .../pages/SmartLoadingWorkbenchView.vue | 35 ++++++++---- .../smartLoadingItems.test.ts | 13 ++++- .../container-loading/smartLoadingItems.ts | 6 +- .../smartLoadingPlanGroups.test.ts | 2 +- src/modules/container-loading/types.ts | 8 +++ .../InboundWorkflowWorkspace.test.ts | 3 +- .../components/InboundWorkflowWorkspace.vue | 9 ++- .../inbounds/core/InboundHistoryPanel.test.ts | 21 +++++++ .../inbounds/core/InboundHistoryPanel.vue | 57 ++++++++++++++++++- src/modules/inbounds/core/api.test.ts | 16 +++++- src/modules/inbounds/core/api.ts | 38 +++++++++++-- src/modules/inbounds/core/types.ts | 21 ++++++- .../inbounds/pages/InboundOrdersView.test.ts | 2 +- .../api/platformLoadingLists.test.ts | 4 +- .../loading-lists/api/platformLoadingLists.ts | 7 +++ .../components/LoadingListDetailDrawer.vue | 3 + 21 files changed, 300 insertions(+), 52 deletions(-) create mode 100644 src/modules/inbounds/core/InboundHistoryPanel.test.ts diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index 4c0fea2e..25a6d45d 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { http } from '@/shared/api/http' import { - adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, + adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, @@ -91,6 +91,17 @@ describe('platform container loading API', () => { expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } }) }) + it('assigns a sealed pallet through the whole-pallet command', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) + + await assignSmartLoadingPallet('10', { palletId: '9007199254740998', expectedVersion: 5 }) + + expect(http.post).toHaveBeenCalledWith( + '/platform/v1/first-leg/smart-loading/plans/10/pallet-assignments', + { palletId: '9007199254740998', expectedVersion: 5, operationId: expect.stringContaining('assign-pallet-') }, + ) + }) + it('uses the dedicated provisional assignment command for a pending-review order', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index a9454c01..249e211a 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -101,6 +101,8 @@ function item(raw: SmartLoadingItem): SmartLoadingItem { id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), accountId: id(raw.accountId), inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version), + palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null, + unitType: raw.unitType, indivisible: Boolean(raw.indivisible), packageCount: numberValue(raw.packageCount), pickedPackageCount: numberValue(raw.pickedPackageCount), minPackageCount: numberValue(raw.minPackageCount, 1), @@ -267,7 +269,8 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue 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), + coLoadGroupId: optionalId(raw.coLoadGroupId), palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null, + unitType: raw.unitType, indivisible: Boolean(raw.indivisible), memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes), locked: raw.locked == null ? null : Boolean(raw.locked), @@ -317,6 +320,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI return plan(unwrapApiResponse(response)) } +export async function assignSmartLoadingPallet(planId: string, payload: { palletId: string; expectedVersion: number }) { + const response = await http.post, ApiEnvelope>( + `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pallet-assignments`, + { ...payload, operationId: operationId('assign-pallet') }, + ) + return plan(unwrapApiResponse(response)) +} + export async function assignPendingReviewSmartLoadingOrder(planId: string, payload: { customerId: string; orderId: string; inboundCode: string; packageCount: number; expectedVersion: number }) { const response = await http.post, ApiEnvelope>( `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pending-review-assignments`, diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 0dec60bd..0cc2bdd9 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -13,6 +13,7 @@ import { assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, + assignSmartLoadingPallet, getSmartLoadingPlanByBooking, listContainers, listSmartLoadingCandidates, @@ -43,6 +44,7 @@ vi.mock('../api/platformContainerLoading', () => ({ listSmartLoadingWorkbenchContainers: vi.fn(), createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), + assignSmartLoadingPallet: vi.fn(), assignPendingReviewSmartLoadingOrder: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(), @@ -74,7 +76,7 @@ const plan = { 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 }], + 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: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }], } const candidate = { accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', @@ -89,7 +91,7 @@ function candidatePage(records: unknown[], page = 1, size = 20, total = records. return { records: records.map((record) => { const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] } - return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate } + return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate } }), page, size, total, pages: total ? Math.ceil(total / size) : 0, } as Awaited> @@ -116,7 +118,8 @@ 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('') + expect(source).toContain("row.unitType === 'SEALED_PALLET'") 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(' { const editable = computed(() => plan.value?.canEdit === true) const assignable = computed(() => plan.value?.canAssign === true) const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId))) +const assignedPalletIds = computed(() => new Set((plan.value?.items || []).map((item) => item.palletId).filter(Boolean))) 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() @@ -114,6 +116,7 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) { : `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段` } function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) { + if (candidate.unitType === 'SEALED_PALLET') return candidate.unitGrossWeightKg return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0) } function candidateTotalVolume(candidate: SmartLoadingCandidate) { @@ -221,20 +224,23 @@ function preferredContainerId() { const candidateColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, + { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true }, + { key: 'unitType', label: '分配单位', width: 105, slotName: 'unitType' }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, { key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' }, - { key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 }, + { key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 }, // { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' }, - { key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' }, + { key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' }, { key: 'totalGrossWeightKg', label: '重量', width: 90, slotName: 'candidateTotalWeight' }, - { key: 'averageUnitVolumeCbm', label: '单箱体积', width: 80, slotName: 'averageUnitVolume' }, + { key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' }, { key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' }, { key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' }, ] const itemColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, + { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, { key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' }, @@ -490,7 +496,8 @@ function openAssign(source: object) { assignOpen.value = true } async function assign() { - if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return + if (!plan.value || !selectedCandidate.value) return + if (selectedCandidate.value.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return if (!selectedCandidate.value.coLoadGroupId && assignForm.packageCount > selectedCandidate.value.availablePackageCount) { ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`) @@ -498,7 +505,9 @@ async function assign() { } await mutate(async () => { const selected = selectedCandidate.value! - acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW' + acceptPlan(selected.unitType === 'SEALED_PALLET' && selected.palletId + ? await assignSmartLoadingPallet(plan.value!.id, { palletId: selected.palletId, expectedVersion: plan.value!.version }) + : selected.orderStatusSnapshot === 'PENDING_REVIEW' ? await assignPendingReviewSmartLoadingOrder(plan.value!.id, { customerId: selected.accountId, orderId: selected.orderId, @@ -519,6 +528,10 @@ async function assign() { }, '分配明细失败', { preserveAssign: true }) } function candidateBlocked(candidate: SmartLoadingCandidateRow) { + if (candidate.unitType === 'SEALED_PALLET') { + return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.palletId + || !candidate.indivisible || assignedPalletIds.value.has(candidate.palletId) + } if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') { return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1 @@ -547,6 +560,7 @@ async function recommend() { } async function adjust(source: object) { const item = itemRow(source) + if (item.unitType === 'SEALED_PALLET') return if (!plan.value || mutating.value) return const count = Number(quantityDrafts[item.id]) if (count === item.packageCount) return @@ -712,10 +726,10 @@ onMounted(() => { void load(); void loadCustomers() }) plain :disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)" - >{{ row.coLoadGroupId ? '整组分配' : '加入' }} + >{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '加入' }} - + @@ -789,8 +803,9 @@ onMounted(() => { void load(); void loadCustomers() }) - - + + + @@ -798,7 +813,7 @@ onMounted(() => { void load(); void loadCustomers() }) - +

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

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

@@ -810,6 +825,10 @@ onMounted(() => { void load(); void loadCustomers() })

{{ summary }}

+ -

{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}

+

{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}

diff --git a/src/modules/container-loading/smartLoadingItems.test.ts b/src/modules/container-loading/smartLoadingItems.test.ts index 570ff2fd..f6e9917f 100644 --- a/src/modules/container-loading/smartLoadingItems.test.ts +++ b/src/modules/container-loading/smartLoadingItems.test.ts @@ -7,7 +7,7 @@ function item(overrides: Partial = {}): SmartLoadingItem { id: '1', loadingPlanId: '10', accountId: '20', inboundId: '30', inboundDetailId: '40', orderId: '50', orderNo: 'FL-1', inboundCode: 'NYEXX2608180003', customerName: '柏悦物流', destinationWarehouseId: '60', destinationWarehouseCode: 'LAX', destinationWarehouseName: '洛杉矶仓', - skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 2, productQuantity: 20, + skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, packageCount: 2, productQuantity: 20, grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 2, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true, ...overrides, @@ -15,6 +15,17 @@ function item(overrides: Partial = {}): SmartLoadingItem { } describe('groupSmartLoadingItems', () => { + it('keeps every sealed pallet as an independent non-adjustable allocation unit', () => { + const grouped = groupSmartLoadingItems([ + item({ id: 'p1', unitType: 'SEALED_PALLET', palletId: '101', palletNo: 'PLT-101', indivisible: true, packageCount: 4 }), + item({ id: 'p2', unitType: 'SEALED_PALLET', palletId: '102', palletNo: 'PLT-102', indivisible: true, packageCount: 6 }), + ]) + + expect(grouped).toHaveLength(2) + expect(grouped.map((row) => row.palletNo)).toEqual(['PLT-101', 'PLT-102']) + expect(grouped.every((row) => row.canAdjust === false)).toBe(true) + }) + it('shows multiple specification rows as one inbound-code row with summed quantities', () => { const grouped = groupSmartLoadingItems([ item(), diff --git a/src/modules/container-loading/smartLoadingItems.ts b/src/modules/container-loading/smartLoadingItems.ts index 574fbd7b..8d66768e 100644 --- a/src/modules/container-loading/smartLoadingItems.ts +++ b/src/modules/container-loading/smartLoadingItems.ts @@ -21,7 +21,9 @@ function joinDistinct(items: SmartLoadingItem[], value: (item: SmartLoadingItem) export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingInboundItem[] { const groups = new Map() for (const item of items) { - const key = `${item.accountId}:${item.inboundId}` + const key = item.unitType === 'SEALED_PALLET' + ? `${item.accountId}:${item.inboundId}:pallet:${item.palletId}` + : `${item.accountId}:${item.inboundId}:packages` groups.set(key, [...(groups.get(key) || []), item]) } @@ -44,7 +46,7 @@ export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingI minPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.minPackageCount || 0), 0), totalPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.totalPackageCount || item.packageCount || 0), 0), remainingPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.remainingPackageCount || 0), 0), - canAdjust: sourceItems.length === 1 && first.canAdjust, + canAdjust: first.unitType !== 'SEALED_PALLET' && sourceItems.length === 1 && first.canAdjust, canRemove: sourceItems.every((item) => item.canRemove), sourceItems, detailCount: sourceItems.length, diff --git a/src/modules/container-loading/smartLoadingPlanGroups.test.ts b/src/modules/container-loading/smartLoadingPlanGroups.test.ts index cf4db946..3a87e604 100644 --- a/src/modules/container-loading/smartLoadingPlanGroups.test.ts +++ b/src/modules/container-loading/smartLoadingPlanGroups.test.ts @@ -5,7 +5,7 @@ import type { SmartLoadingPlan } from './types' const item = { id: '701', loadingPlanId: '99', accountId: '1', inboundId: '201', inboundDetailId: '801', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '客户一', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', - skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1, + skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true, } diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index c7a282dc..699a3aaf 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -246,6 +246,10 @@ export interface SmartLoadingCandidate { destinationWarehouseName: string | null customerName: string | null goodsSummary: string | null + unitType: 'PACKAGE' | 'SEALED_PALLET' + palletId: string | null + palletNo: string | null + indivisible: boolean availablePackageCount: number unitGrossWeightKg: number | null unitNetWeightKg: number | null @@ -336,6 +340,10 @@ export interface SmartLoadingItem { destinationWarehouseName: string | null skuCode: string | null productNameZh: string | null + unitType: 'PACKAGE' | 'SEALED_PALLET' + palletId: string | null + palletNo: string | null + indivisible: boolean packageCount: number productQuantity: number | null grossWeightKg: number | null diff --git a/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts b/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts index 94f9382a..9b5f1dc9 100644 --- a/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts +++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts @@ -19,7 +19,8 @@ describe('InboundWorkflowWorkspace', () => { expect(source).toContain('label="测量审核"') expect(source).toContain('label="费用信息"') expect(source).toContain('label="状态历史"') - expect(source).toContain('label="其他记录"') + expect(source).toContain("detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'") + expect(source).toContain('listPlatformInboundDropshipPallets(props.customerId, props.inboundId)') }) it('opens measurement editing and review in prototype-sized centered dialogs', () => { diff --git a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue index 4d98e83d..4786aa0d 100644 --- a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue +++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue @@ -6,7 +6,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' -import { getPlatformInbound } from '../core/api' +import { getPlatformInbound, listPlatformInboundDropshipPallets } from '../core/api' import InboundHistoryPanel from '../core/InboundHistoryPanel.vue' import InboundSummaryPanel from '../core/InboundSummaryPanel.vue' import type { InboundOrderDetail } from '../core/types' @@ -37,6 +37,7 @@ let disposed = false const title = computed(() => detail.value?.summary.inboundCode || '入仓码待补充') const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view')) +const canViewDropshipPallets = computed(() => access.can('platform:firstleg:pda:view')) const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态')) async function loadDetail() { @@ -51,6 +52,9 @@ async function loadDetail() { loadError.value = null try { const response = await getPlatformInbound(props.customerId, props.inboundId) + if (response.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' && canViewDropshipPallets.value) { + response.dropshipPallets = await listPlatformInboundDropshipPallets(props.customerId, props.inboundId) + } if (disposed || serial !== requestSerial) return detail.value = response } catch (error) { @@ -174,12 +178,13 @@ onBeforeUnmount(() => { :sections="['history']" /> - + { + it('shows the frozen pallet, carton and discrepancy facts', () => { + for (const marker of [ + '一件代发清点与组托结果', 'pallet.palletNo', 'pallet.status', 'pallet.cartonCount', + 'pallet.skuCount', 'pallet.weightKg', 'pallet.volumeCbm', 'pallet.discrepancyCartons', 'pallet.sealedAt', + 'item.inboundCode', 'item.skuCode', 'item.cargoName', 'item.skuQuantity', 'item.cartonCount', 'item.cartonMarks', + ]) expect(source).toContain(marker) + expect(source).toContain("SEALED: '已封托'") + }) + + it('keeps the existing scoped pallet-label action available', () => { + expect(source).toContain('downloadDropshipPalletLabel(props.customerId, props.inboundId, pallet.palletId)') + expect(source).toContain('打印托盘唛') + }) +}) diff --git a/src/modules/inbounds/core/InboundHistoryPanel.vue b/src/modules/inbounds/core/InboundHistoryPanel.vue index cd6710c0..ff8c85a9 100644 --- a/src/modules/inbounds/core/InboundHistoryPanel.vue +++ b/src/modules/inbounds/core/InboundHistoryPanel.vue @@ -9,6 +9,7 @@ const props = withDefaults(defineProps<{ dropshipPallets?: InboundDropshipPallet[] customerId?: string inboundId?: string + canViewDropship?: boolean files: InboundFileSummary[] internalNotes: InboundInternalNote[] statusHistory: InboundStatusHistory[] @@ -16,6 +17,7 @@ const props = withDefaults(defineProps<{ sections?: Array<'dropship' | 'allocations' | 'files' | 'notes' | 'history' | 'remark'> }>(), { dropshipPallets: () => [], + canViewDropship: true, sections: () => ['dropship', 'allocations', 'files', 'notes', 'history', 'remark'], }) @@ -30,6 +32,9 @@ function date(value: string | null) { const parsed = new Date(value) return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false }) } +function dropshipPalletStatus(value: string) { + return ({ OPEN: '清点组托中', SEALED: '已封托' } as Record)[value] || value || '状态未知' +} async function printPallet(pallet: InboundDropshipPallet) { if (!props.customerId || !props.inboundId || !pallet.palletNo) return try { @@ -44,9 +49,35 @@ async function printPallet(pallet: InboundDropshipPallet) { diff --git a/src/modules/overseas-warehouse/pages/OutboundDetailView.vue b/src/modules/overseas-warehouse/pages/OutboundDetailView.vue index 22d4dc93..3fb029db 100644 --- a/src/modules/overseas-warehouse/pages/OutboundDetailView.vue +++ b/src/modules/overseas-warehouse/pages/OutboundDetailView.vue @@ -18,10 +18,12 @@ import { overseasWarehousePermissions as permission } from '../permissions' const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore() const outboundNo = computed(() => String(route.params.outboundNo || '').trim()) const detail = ref(null); const loading = ref(false); const cancelling = ref(false) +const palletRows = computed(() => detail.value?.pallets || []) const loadError = ref(null); const mutationError = ref(null) const statusLabels: Record = { WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' } function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' } function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) } +function marks(value: unknown) { return String(value || '').split(',').map((item) => item.trim()).filter(Boolean) } const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED') async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } } async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } } @@ -34,7 +36,8 @@ watch(outboundNo, () => { void load() }, { immediate: true }) - + diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.vue b/src/modules/overseas-warehouse/pages/OutboundsView.vue index 11f25ca3..6cbdb53d 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.vue +++ b/src/modules/overseas-warehouse/pages/OutboundsView.vue @@ -21,7 +21,7 @@ import { overseasWarehousePermissions as permission } from '../permissions' const router = useRouter(); const access = useAccessPreviewStore() const rows = ref([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref(null); const mutationError = ref(null) -const filters = ref({ documentNo: '', lxCode: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref(); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref>([]); const inventoryLoading = ref(false) +const filters = ref({ documentNo: '', lxCode: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref(); const cartonMarks = ref(''); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref>([]); const inventoryLoading = ref(false) const customerOptions = computed(() => { const customers = new Map() positionOptions.value.forEach(({ balance }) => customers.set(balance.customerId, balance.customerName?.trim() || '客户名称待补充')) @@ -34,7 +34,7 @@ const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库 async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } } async function openCreate() { createOpen.value = true; selectedCustomerId.value = ''; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } } function selectCustomer() { selectedPositionId.value = ''; quantity.value = undefined } -async function submit() { const selected = availablePositionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!selectedCustomerId.value || !recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择客户,填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } } +async function submit() { const selected = availablePositionOptions.value.find(item => item.position.id === selectedPositionId.value); const marks = cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedCustomerId.value || !recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择客户,填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; if (marks.length && marks.length !== quantity.value) { ElMessage.warning('指定箱唛数量必须等于出库数量'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined, cartonMarks: marks.length ? marks : undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } } function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) } function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.status === 'PICKED' } async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } } @@ -53,9 +53,9 @@ onMounted(load) - +
填写后按指定箱唛出库,数量必须与出库数量一致。
diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue new file mode 100644 index 00000000..e0f23423 --- /dev/null +++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue @@ -0,0 +1,55 @@ + + + + diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts index 7045a30a..e323bdd8 100644 --- a/src/modules/overseas-warehouse/permissions.ts +++ b/src/modules/overseas-warehouse/permissions.ts @@ -25,6 +25,8 @@ export const overseasWarehousePermissions = { inventoryExport: 'platform:overseas-warehouse:inventory:export', inventoryEdit: 'platform:overseas-warehouse:inventory:edit', inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust', + returnInventoryList: 'platform:overseas-warehouse:return-inventory:list', + returnCartonsList: 'platform:overseas-warehouse:return-cartons:list', relabelOrderList: 'platform:overseas-warehouse:relabel-order:list', relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create', relabelOrderStatus: 'platform:overseas-warehouse:relabel-order:status', diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts index de4bd8f5..4394c76d 100644 --- a/src/modules/overseas-warehouse/routes.ts +++ b/src/modules/overseas-warehouse/routes.ts @@ -80,6 +80,20 @@ export const overseasWarehouseRoutes = [ component: () => import('./pages/InventoryView.vue'), meta: { title: '库存管理', description: '查看海外仓库存汇总、可用量和库内分布', permission: permission.inventoryList }, }, + { + path: 'overseas-warehouse/return-inventory', + name: 'overseas-return-inventory', + component: () => import('./pages/ReturnInventoryManagementView.vue'), + props: { mode: 'inventory' }, + meta: { title: '退货库存管理', description: '按客户和 SKU 查询退货签收后的可用库存', permission: permission.returnInventoryList }, + }, + { + path: 'overseas-warehouse/return-cartons', + name: 'overseas-return-cartons', + component: () => import('./pages/ReturnInventoryManagementView.vue'), + props: { mode: 'cartons' }, + meta: { title: '退货箱号管理', description: '查询合箱后的箱号、箱规和箱内 SKU', permission: permission.returnCartonsList }, + }, { path: 'overseas-warehouse/inventory-transactions', name: 'overseas-inventory-transactions', From 4ccad3bc3c132cf583b6d9baa33dfd05844c302e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 12:15:24 +0800 Subject: [PATCH 5/9] fix(admin): consume booking link actions when cached pages reactivate --- .../pages/ContainersView.test.ts | 60 ++++++++++++++++--- .../pages/ContainersView.vue | 23 ++++--- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 1e2ba78c..c86ff490 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -1,6 +1,7 @@ import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' import { createMemoryHistory, createRouter } from 'vue-router' +import { defineComponent } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' @@ -34,7 +35,7 @@ const drayage = { function createTestRouter() { return createRouter({ history: createMemoryHistory(), routes: [ - { path: '/', component: ContainersView }, + { path: '/', name: 'domestic-container-workbench', component: ContainersView }, { path: '/container-loading/domestic/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '
' } }, { path: '/loading-plans', name: 'loading-plans', component: { template: '
' } }, { path: '/container-loading/domestic/:containerId', name: 'domestic-container-detail', component: { template: '
' } }, @@ -417,12 +418,57 @@ describe('ContainersView', () => { wrapper.unmount() }) - it('opens the existing released-booking workflow when entered from container detail', () => { - const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') - expect(source).toContain('route.query.linkContainerId') - expect(source).toContain("const target = await getContainer(linkContainerId, 'DOMESTIC')") - expect(source).toContain('await openLink(target)') - expect(source).toContain("void router.replace({ name: 'domestic-container-workbench' })") + it('consumes the booking-link action once when returning to a cached list and preserves other query state', async () => { + vi.mocked(getContainer).mockClear() + vi.mocked(listContainers).mockClear() + vi.mocked(listOceanBookingLinkCandidates).mockClear().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) + const router = createTestRouter() + await router.push('/'); await router.isReady() + const shell = defineComponent({ template: '' }) + const wrapper = mount(shell, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + const initial = wrapper.findComponent(ContainersView).vm.$.uid + await router.push('/container-loading/domestic/9001') + await flushPromises() + await router.push({ name: 'domestic-container-workbench', query: { linkContainerId: '9001', keyword: 'CB-1' }, hash: '#table' }) + await flushPromises() + expect(wrapper.findComponent(ContainersView).vm.$.uid).toBe(initial) + expect(getContainer).toHaveBeenCalledTimes(1) + expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1) + expect(listContainers).toHaveBeenCalledTimes(1) + expect(router.currentRoute.value.query).toEqual({ keyword: 'CB-1' }) + expect(router.currentRoute.value.hash).toBe('#table') + const dialog = wrapper.findAllComponents(AppDialog).find((item) => item.props('title') === '关联已放舱订舱')! + expect(dialog.props('modelValue')).toBe(true) + dialog.vm.$emit('update:modelValue', false) + await flushPromises() + await router.push('/container-loading/domestic/9001') + await router.push({ name: 'domestic-container-workbench' }) + await flushPromises() + expect(dialog.props('modelValue')).toBe(false) + expect(getContainer).toHaveBeenCalledTimes(1) + expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) + + it('ignores a stale linking response after leaving the list', async () => { + const target = await vi.mocked(getContainer).getMockImplementation()!('9001', 'DOMESTIC') + let finish!: (value: typeof target) => void + vi.mocked(getContainer).mockClear().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve })) + vi.mocked(listOceanBookingLinkCandidates).mockClear() + const router = createTestRouter() + await router.push({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } }); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + await router.replace({ query: { linkContainerId: '9001', keyword: 'updated' } }) + await flushPromises() + expect(getContainer).toHaveBeenCalledTimes(1) + await router.push('/container-loading/domestic/9001') + finish(target) + await flushPromises() + expect(listOceanBookingLinkCandidates).not.toHaveBeenCalled() + expect(router.currentRoute.value.name).toBe('domestic-container-detail') + wrapper.unmount() }) it('normalizes an optional booking-link container number', async () => { diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index c8fc56c1..ce8f98dc 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -1,5 +1,5 @@ From 2dd777d74a9dc2ca54134b8d467d30fa0313df89 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 13:00:45 +0800 Subject: [PATCH 6/9] fix(admin): load outbound facts when access becomes ready --- .../components/ContainerCustomsPanel.vue | 4 +-- ...nerDetailView.emptyReturn.behavior.test.ts | 25 +++++++++++++++++++ .../pages/ContainerDetailView.vue | 19 ++++++++++---- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 3bb09b2d..0f290caf 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -23,7 +23,7 @@ const props = defineProps<{ loadingPlanId: string canList: boolean canManage: boolean - departureState: 'DEPARTED' | 'WAITING_OTHER_GATES' + departureState: 'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN' }>() const emit = defineEmits<{ refreshed: [] }>() @@ -182,7 +182,7 @@ onMounted(load)
-
国内离仓状态:{{ departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件' }}
+
国内离仓状态:{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}
diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index ece4fed0..f4e555e2 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ContainerDetailView from './ContainerDetailView.vue' import { getContainer, getContainerOverview } from '../api/platformContainerLoading' import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages' +import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch' +import { ref } from 'vue' const canAccess = vi.hoisted(() => vi.fn<(code: string) => boolean>(() => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) })) @@ -29,11 +31,34 @@ async function mountDetail(workbench: 'DOMESTIC' | 'OVERSEAS' = 'OVERSEAS') { describe('ContainerDetailView empty return', () => { beforeEach(() => { canAccess.mockReset().mockReturnValue(true) + vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never) vi.mocked(getContainer).mockReset().mockResolvedValue(base as never) vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never) }) + it('loads departed gates exactly once when read permission arrives after the container detail', async () => { + const granted = ref(false) + canAccess.mockImplementation((code) => code === 'platform:firstleg:outbound-dispatch:view' ? granted.value : true) + const wrapper = await mountDetail('DOMESTIC') + expect(getContainerOutboundDispatch).not.toHaveBeenCalled() + granted.value = true + await flushPromises() + expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('已确认离仓') + expect(wrapper.text()).toContain('整柜已确认离仓;') + expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓') + }) + + it('reloads authoritative departure state once on each newly opened domestic detail', async () => { + const first = await mountDetail('DOMESTIC') + expect(first.text()).toContain('已确认离仓') + first.unmount() + const reopened = await mountDetail('DOMESTIC') + expect(reopened.text()).toContain('已确认离仓') + expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2) + }) + it.each(['DOMESTIC', 'OVERSEAS'] as const)('gates fee tabs by granted permissions in %s', async (workbench) => { for (const [firstLeg, lastLeg] of [[true, false], [false, true], [true, true], [false, false]]) { canAccess.mockImplementation((code) => code === 'firstleg:inbound:fee-detail:view' ? firstLeg! : code === 'platform:finance:customer-arap:list' ? lastLeg! : false) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index cbd5496b..000752d3 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -1,5 +1,5 @@