From 64bddb1f812010c022496b98483c58c93cdd8a7a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 14 Sep 2026 17:22:09 +0800 Subject: [PATCH] fix: scope smart loading recommendations --- firstleg/AGENTS.md | 2 +- .../baiyue/firstleg/dto/SmartLoadingDtos.java | 3 +- .../firstleg/service/SmartLoadingService.java | 37 ++++-- .../main/resources/openapi/firstleg-v1.yaml | 22 ++++ .../service/SmartLoadingServiceTest.java | 18 ++- scripts/admin-test-env-acceptance.mjs | 23 ++-- scripts/admin-test-env-modules/README.md | 4 +- .../admin-test-env-modules/phase3-loading.mjs | 122 +++++++++++++++--- ...-phase4-producer-fixture-contract.test.mjs | 7 + .../phase3-producer-chain-contract.test.mjs | 5 +- 10 files changed, 199 insertions(+), 44 deletions(-) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 96039fb8e..a27da582d 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -86,7 +86,7 @@ ## 国内货柜履约装柜与 PDA 资源结算 - 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 -- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 +- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index f43568c32..33771ea87 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -76,7 +76,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record RemoveGroupRequest(@NotNull @Min(0) Integer expectedPlanVersion, @NotBlank @Size(max=128) String operationId) {} public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max=128) String operationId) {} + @NotBlank @Size(max=128) String operationId, + @Positive Long customerId, @Size(max=64) String inboundCode) {} public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 8691f0797..b9ccdcf8a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -106,9 +106,14 @@ public class SmartLoadingService { return grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList(); } - private List recommendationCandidates(ContainerBookingEntity booking) { + private List recommendationCandidates(ContainerBookingEntity booking, + Long customerId, String inboundCode) { List values = repository.candidates(null).stream() - .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); + .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)) + .filter(candidate -> customerId == null || Objects.equals(customerId, candidate.accountId())) + .filter(candidate -> !StringUtils.hasText(inboundCode) + || inboundCode.trim().equals(candidate.inboundCode())) + .toList(); Map> grouped = enrichColoadCandidates(booking, repository.planByBooking(booking.getId()), values).stream() .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, @@ -442,6 +447,11 @@ public class SmartLoadingService { @BusinessAudit(event = "SMART_LOADING_PALLET_ASSIGN", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "按已封托托盘分配智能装柜明细") public Plan assignPallet(long planId, AssignPalletRequest request, long operatorId, String operatorName) { + return assignPallet(planId, request, operatorId, operatorName, true); + } + + private Plan assignPallet(long planId, AssignPalletRequest request, + long operatorId, String operatorName, boolean recalculateFees) { String operation = operation(request.operationId()); String requestHash = sha256("ASSIGN_PALLET|" + planId + '|' + request.palletId() + '|' + request.expectedVersion()); @@ -475,7 +485,10 @@ public class SmartLoadingService { json(values("palletId", request.palletId(), "version", request.expectedVersion())), json(values("requestHash", requestHash, "palletNo", first.getPalletNo(), "lineCount", lines.size())), operatorId, operatorName); - recalculateAfterRelationshipChange(assigned.getContainerBookingId(), Set.of(first.getAccountId()), operation); + if (recalculateFees) { + recalculateAfterRelationshipChange(assigned.getContainerBookingId(), + Set.of(first.getAccountId()), operation); + } return view(assigned, requireBooking(assigned.getContainerBookingId())); } @@ -901,14 +914,18 @@ public class SmartLoadingService { resourceId = "#planId", description = "执行智能装柜推荐") public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion()); + String scopedInboundCode = trim(request.inboundCode()); + String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion() + + '|' + request.customerId() + '|' + scopedInboundCode); Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash); if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); Set visitedGroups = new HashSet<>(); Set visitedPallets = new HashSet<>(); - for (InboundCandidate candidate : recommendationCandidates(booking)) { + Set affectedCustomers = new HashSet<>(); + for (InboundCandidate candidate : recommendationCandidates(booking, + request.customerId(), scopedInboundCode)) { if (candidate.coLoadGroupId() != null) { if (!visitedGroups.add(candidate.coLoadGroupId()) || StringUtils.hasText(candidate.blockReason())) { continue; @@ -943,7 +960,8 @@ public class SmartLoadingService { plan = repository.plan(planId); assignPallet(planId, new AssignPalletRequest(candidate.palletId(), plan.getVersion(), "REC-PAL-" + sha256(operation + '|' + candidate.palletId()).substring(0, 48)), - operatorId, operatorName); + operatorId, operatorName, false); + affectedCustomers.add(candidate.accountId()); continue; } int count = fittingBoxes(booking, repository.items(planId), candidate.availablePackageCount(), @@ -952,12 +970,15 @@ public class SmartLoadingService { plan = repository.plan(planId); assignInbound(planId, new AssignInboundRequest(candidate.inboundId(), count, plan.getVersion(), "REC-INB-" + sha256(operation + '|' + candidate.inboundId()) - .substring(0, 48)), operatorId, operatorName); + .substring(0, 48)), operatorId, operatorName, false, null, false); + affectedCustomers.add(candidate.accountId()); } } + recalculateAfterRelationshipChange(booking.getId(), affectedCustomers, operation); LoadingPlanEntity recommended = repository.plan(planId); history(planId, null, "SMART_RECOMMEND", plan.getPlanStatus(), recommended.getPlanStatus(), operation, - json(values("version", request.expectedVersion())), + json(values("version", request.expectedVersion(), "customerId", request.customerId(), + "inboundCode", scopedInboundCode)), json(values("requestHash", requestHash, "version", recommended.getVersion())), operatorId, operatorName); return view(recommended, requireBooking(plan.getContainerBookingId())); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 84626d505..9477cf80a 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -678,6 +678,20 @@ paths: responses: '200': {description: 成功、计划不存在或计划已进入预装流程} '403': {description: 无智能装柜查询权限} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/recommend: + post: + summary: 按当前筛选范围执行智能装柜推荐 + description: >- + 需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围; + 传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。 + 一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingRecommendRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} /api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments: post: summary: 向预装计划新增入库单明细 @@ -2304,6 +2318,14 @@ components: packageCount: {type: integer, minimum: 1, description: 待审核订单本次占用的申报箱数} expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion} operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingRecommendRequest: + type: object + required: [expectedVersion, operationId] + properties: + expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion} + operationId: {type: string, minLength: 1, maxLength: 128} + customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 当前页面客户筛选;传入时限制推荐范围} + inboundCode: {type: string, minLength: 1, maxLength: 64, nullable: true, description: 当前页面入仓码筛选;传入时限制推荐范围} SmartLoadingAdjustRequest: type: object required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 2c6b38bd5..53181f21c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -764,7 +764,7 @@ class SmartLoadingServiceTest { when(repository.candidates(null)).thenReturn(List.of(first, second, third)); when(coloadGroups.findByInbound(anyLong(), anyLong())).thenReturn(group); - Plan result = service.recommend(1L, new RecommendRequest(0, "recommend-group-too-large"), 7L, "u"); + Plan result = service.recommend(1L, new RecommendRequest(0, "recommend-group-too-large", null, null), 7L, "u"); assertThat(result.items()).isEmpty(); verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(), @@ -775,6 +775,7 @@ class SmartLoadingServiceTest { @Test void recommendAssignsMultipleDropshipCandidatesThroughWholePalletCommands() { Candidate dropship = candidate(9L, 10L, 20L, 30L, 20); + Candidate anotherCustomer = candidate(19L, 11L, 21L, 31L, 10); AtomicInteger version = new AtomicInteger(); List saved = new ArrayList<>(); InboundOrderEntity inbound = inbound(9L, 10L); @@ -783,7 +784,7 @@ class SmartLoadingServiceTest { when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get())); when(repository.booking(99L)).thenReturn(booking()); when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved)); - when(repository.candidates(null)).thenReturn(List.of(dropship)); + when(repository.candidates(null)).thenReturn(List.of(dropship, anotherCustomer)); when(repository.inbound(9L, 10L)).thenReturn(inbound); when(repository.activeItemsByPallet(anyLong())).thenReturn(List.of()); when(repository.activePalletItem(eq(1L), anyLong(), eq(20L))).thenReturn(null); @@ -810,7 +811,7 @@ class SmartLoadingServiceTest { }).when(repository).insert(any(LoadingPlanItemEntity.class)); Plan result = service.recommend(1L, - new RecommendRequest(0, "recommend-dropship-pallet"), 7L, "u"); + new RecommendRequest(0, "recommend-dropship-pallet", 9L, "I10"), 7L, "u"); assertThat(result.version()).isEqualTo(2); assertThat(saved).hasSize(2).allSatisfy(item -> { @@ -823,6 +824,9 @@ class SmartLoadingServiceTest { org.assertj.core.groups.Tuple.tuple(801L, "DP-260914-0021"), org.assertj.core.groups.Tuple.tuple(802L, "DP-260914-0022")); verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(dropshipPallets, never()).palletCandidates(11L); + verify(fbaPricingService, times(1)).recalculateUnlockedContainerCustomerFees( + eq(99L), eq(9L), anyString()); } @Test @@ -839,7 +843,7 @@ class SmartLoadingServiceTest { when(dropshipPallets.sealedPalletLines(801L)).thenReturn(List.of()); Plan result = service.recommend(1L, - new RecommendRequest(0, "recommend-stale-pallet"), 7L, "u"); + new RecommendRequest(0, "recommend-stale-pallet", null, null), 7L, "u"); assertThat(result.items()).isEmpty(); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); @@ -888,7 +892,7 @@ class SmartLoadingServiceTest { when(coloadGroups.findByInbound(9L, 12L)).thenReturn(group); Plan result = service.recommend(1L, - new RecommendRequest(0, "recommend-refresh-group-capacity"), 7L, "u"); + new RecommendRequest(0, "recommend-refresh-group-capacity", null, null), 7L, "u"); assertThat(result.version()).isEqualTo(2); assertThat(saved).extracting(LoadingPlanItemEntity::getInboundId, @@ -1397,9 +1401,9 @@ class SmartLoadingServiceTest { @Test void replaysRecommendation() { - RecommendRequest request = new RecommendRequest(0, "recommend-replay"); + RecommendRequest request = new RecommendRequest(0, "recommend-replay", null, null); when(repository.operation("recommend-replay")).thenReturn(history(1, null, - "SMART_RECOMMEND", sha256("RECOMMEND|1|0"))); + "SMART_RECOMMEND", sha256("RECOMMEND|1|0|null|null"))); when(repository.plan(1)).thenReturn(plan("LOADING", 8)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index ff17e07cf..10747e214 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1344,18 +1344,19 @@ export class AcceptanceRun { } } const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) - // One-click dropship has a physically indivisible sealed pallet. The phase-three loading - // fixture normally creates four one-carton SKUs so it can exercise partial loading, but that - // would intentionally leave three cartons outside the sealed pallet and make the later - // cross-region handover invalid. Keep this producer variant to one carton only; the separate - // STANDARD incremental fixture below continues to cover SKIP/PARTIAL/dynamic mutations. + // Keep the primary one-click producer deliberately multi-pallet. This is the regression + // fixture for the real business shape where one inbound has more than one sealed DP pallet: + // every pallet must remain independently assignable while the handover stays indivisible. + // The separate STANDARD incremental fixture below continues to cover SKIP/PARTIAL mutations. const primaryDropship = this.context.orderFulfillmentType === 'ONE_CLICK_DROPSHIP' const hadWaitSplitVariant = Object.hasOwn(this.context, 'waitSplitVariant') const savedWaitSplitVariant = this.context.waitSplitVariant + const primaryDropshipVariant = { + ...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 4, + packageCounts: [4], palletCount: 2, + } if (primaryDropship) { - this.context.waitSplitVariant = { - ...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 1, packageCounts: [1], - } + this.context.waitSplitVariant = primaryDropshipVariant } try { await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this)) @@ -1368,11 +1369,13 @@ export class AcceptanceRun { this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null, Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`, { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }) - const requiredPalletCount = Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1)) + const requiredPalletCount = primaryDropship ? primaryDropshipVariant.palletCount + : Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1)) const isolation = requireSharedOverflow ? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount) : { forced: false, disabled: [] } this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow + if (primaryDropship) this.context.waitSplitVariant = primaryDropshipVariant try { const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this)) @@ -1380,6 +1383,8 @@ export class AcceptanceRun { if (requireSharedOverflow) { await this.restoreAvailableLocationsAfterA0(warehouseId, isolation) } + if (hadWaitSplitVariant) this.context.waitSplitVariant = savedWaitSplitVariant + else delete this.context.waitSplitVariant } const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders const fixtureOrder = this.createdResources.orders || {} diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index ec940ace6..af324b574 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -48,7 +48,7 @@ export async function run(context) { 一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。 -该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,必须先将全部原箱码经海外 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。 +该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,其中主生产者固定为 4 箱 2 个已封 DP 托盘:先逐托人工分配并确认首托加入后次托仍可选,再反证单条删除受阻、整托移除成功,随后执行真实智能推荐并核对两托完整加入,最终恢复精确两托继续装柜。海外侧必须先将全部原箱码经 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入逐来源托盘实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。 - `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。 - `requestBinary(method, path)`:文件下载,不把二进制写入报告。 @@ -181,7 +181,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s `remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查 plan/item 版本、分配箱数及总数投影全部零漂移。脚本不从当前已分配数量反推订单总箱数。 -`phase3-loading` 复用本轮一件代发 fixture,核对 task items 与 inventory units 的 `unitType=SEALED_PALLET/palletId/palletNo/indivisible`,整托统一通过 `/batch` 提交 `PICK_PALLET + palletNo`。合法整托行混入非法普通行必须整批零写;普通 `PICK/PARTIAL/SKIP` 不得绕过整托限制。同参同键重放不得增加版本或审计,同键更换托盘码必须冲突。以上接入现有全量链,不建立独立 HTTP 入口。 +`phase3-loading` 复用本轮双托一件代发 fixture,逐托核对 task items 与 inventory units 的 `unitType=SEALED_PALLET/palletId/palletNo/indivisible`,两托统一通过同一次 `/batch` 提交各自的 `PICK_PALLET + palletNo`。合法整托行混入非法普通行必须整批零写;普通 `PICK/PARTIAL/SKIP` 不得绕过整托限制。同参同键重放不得增加版本或审计,同键更换托盘码必须冲突。以上接入现有全量链,不建立独立 HTTP 入口。 `phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。 diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 5c804fa85..2d559d74a 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -229,9 +229,11 @@ export async function run(context) { && Number(item.availablePackageCount) > 0) : [] let candidate = inboundCandidates[0] let dropshipCandidates = [] + let dropshipCandidatePath = null if (source?.fulfillmentType === 'ONE_CLICK_DROPSHIP') { const group = channelGroup(initial?.summary?.channelCode) const candidatePath = `${smartLoadingBase}/inbound-candidates?channelGroupCodes=${encodeURIComponent(group)}&warehouseId=${encodeURIComponent(warehouseId)}&customerId=${encodeURIComponent(tenantId)}&inboundCode=${encodeURIComponent(initial?.summary?.inboundCode || source?.inboundCode || '')}&orderStatuses=WAIT_SPLIT&page=1&size=20` + dropshipCandidatePath = candidatePath const palletCandidatesResponse = await context.request('GET', candidatePath) const palletCandidatePage = context.apiSuccess(MODULE, '查询一件代发整托装柜候选', palletCandidatesResponse, '', OPTIONS) dropshipCandidates = Array.isArray(palletCandidatePage?.records) @@ -255,24 +257,112 @@ export async function run(context) { if (!loadingPlanId) return const dropshipAssignment = source?.fulfillmentType === 'ONE_CLICK_DROPSHIP' const initialAssignmentCount = dropshipAssignment - ? dropshipCandidates.filter((item) => id(item.palletId) === id(candidate.palletId)) - .reduce((total, item) => total + Number(item.availablePackageCount || 0), 0) : 1 - const assignmentPath = dropshipAssignment - ? `${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments` - : `${smartLoadingBase}/plans/${loadingPlanId}/assignments` - const assignmentBody = dropshipAssignment - ? { palletId: id(candidate.palletId), expectedVersion: Number(loadingPlan.version), - operationId: `${context.marker}-${loadingPlanId}-bridge-assign-pallet`.slice(0, 128) } - : { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: initialAssignmentCount, - expectedVersion: Number(loadingPlan.version), operationId: `${context.marker}-${loadingPlanId}-bridge-assign`.slice(0, 128) } - const assignmentResponse = await request(context, 'POST', assignmentPath, assignmentBody) - const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS) + ? dropshipCandidates.reduce((total, item) => total + Number(item.availablePackageCount || 0), 0) : 1 + let assignmentResponse + let assignedLoadingPlan + if (dropshipAssignment) { + const assignmentPath = `${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments` + let workingPlan = loadingPlan + for (const [index, pallet] of dropshipCandidates.entries()) { + const body = { palletId: id(pallet.palletId), expectedVersion: Number(workingPlan.version), + operationId: `${context.marker}-${loadingPlanId}-bridge-assign-pallet-${id(pallet.palletId)}`.slice(0, 128) } + assignmentResponse = await request(context, 'POST', assignmentPath, body) + workingPlan = context.apiSuccess(MODULE, `逐托分配一件代发托盘${index + 1}/${dropshipCandidates.length}`, + assignmentResponse, '', OPTIONS) + if (!workingPlan) return + if (index === 0 && dropshipCandidates.length > 1 && dropshipCandidatePath) { + const remainingResponse = await context.request('GET', dropshipCandidatePath) + const remainingPage = context.apiSuccess(MODULE, '分配首托后刷新同票剩余整托候选', + remainingResponse, '', OPTIONS) + const remainingIds = new Set((remainingPage?.records || []).map((item) => id(item?.palletId))) + const expectedRemaining = dropshipCandidates.slice(1).map((item) => id(item.palletId)) + context.assert(MODULE, '一件代发分配首托后其余托盘仍可独立分配', remainingResponse, + expectedRemaining.every((palletId) => remainingIds.has(palletId)), + `remaining=${[...remainingIds].join(',') || 'missing'}, expected=${expectedRemaining.join(',')}`, OPTIONS) + } + } + assignedLoadingPlan = workingPlan + } else { + const assignmentPath = `${smartLoadingBase}/plans/${loadingPlanId}/assignments` + const assignmentBody = { inboundId, inboundDetailId: id(candidate.inboundDetailId), + packageCount: initialAssignmentCount, expectedVersion: Number(loadingPlan.version), + operationId: `${context.marker}-${loadingPlanId}-bridge-assign`.slice(0, 128) } + assignmentResponse = await request(context, 'POST', assignmentPath, assignmentBody) + assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS) + } context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse, assignedLoadingPlan?.items?.filter?.((item) => id(item.inboundId) === inboundId) .reduce((total, item) => total + Number(item.packageCount || 0), 0) === initialAssignmentCount && Number(assignedLoadingPlan?.version) >= 1, `loadingPlanId=${loadingPlanId}, assignedPackages=${initialAssignmentCount}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS) + if (dropshipAssignment && dropshipCandidates.length > 1) { + const firstPalletItem = assignedLoadingPlan?.items?.find?.((item) => + id(item.inboundId) === inboundId && id(item.palletId) === id(dropshipCandidates[0].palletId)) + const singleDelete = await request(context, 'DELETE', + `${smartLoadingBase}/plans/${loadingPlanId}/items/${id(firstPalletItem?.id)}`, { + expectedPlanVersion: Number(assignedLoadingPlan?.version), + expectedItemVersion: Number(firstPalletItem?.version), + operationId: `${context.marker}-${loadingPlanId}-reject-single-pallet-line-delete`.slice(0, 128), + }) + context.assert(MODULE, '已封托托盘拒绝按单条装柜明细删除', singleDelete, + singleDelete.http === 200 && codeOf(singleDelete) === 'A0409', + `HTTP=${singleDelete.http}, code=${codeOf(singleDelete)}`, OPTIONS) + + let emptiedPlan = assignedLoadingPlan + for (const [index, pallet] of dropshipCandidates.entries()) { + const removedResponse = await request(context, 'DELETE', + `${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments/${id(pallet.palletId)}`, { + expectedPlanVersion: Number(emptiedPlan.version), + operationId: `${context.marker}-${loadingPlanId}-remove-whole-pallet-${id(pallet.palletId)}`.slice(0, 128), + }) + emptiedPlan = context.apiSuccess(MODULE, `整托移除一件代发托盘${index + 1}/${dropshipCandidates.length}`, + removedResponse, '', OPTIONS) + if (!emptiedPlan) return + } + context.assert(MODULE, '逐托移除后本票装柜明细归零', assignmentResponse, + !emptiedPlan.items?.some?.((item) => id(item.inboundId) === inboundId), + `remaining=${emptiedPlan.items?.filter?.((item) => id(item.inboundId) === inboundId).length ?? 'missing'}`, OPTIONS) + + const recommendResponse = await request(context, 'POST', + `${smartLoadingBase}/plans/${loadingPlanId}/recommend`, { + expectedVersion: Number(emptiedPlan.version), + operationId: `${context.marker}-${loadingPlanId}-recommend-multi-pallet`.slice(0, 128), + customerId: tenantId, + inboundCode: initial?.summary?.inboundCode || source?.inboundCode, + }) + const recommendedPlan = context.apiSuccess(MODULE, '智能推荐按整托重新加入一件代发多托', + recommendResponse, '', OPTIONS) + const expectedPalletIds = new Set(dropshipCandidates.map((item) => id(item.palletId))) + const recommendedPalletIds = new Set((recommendedPlan?.items || []) + .filter((item) => id(item.inboundId) === inboundId).map((item) => id(item.palletId))) + context.assert(MODULE, '智能推荐保留同票全部封托托盘且不按箱拆分', recommendResponse, + expectedPalletIds.size === dropshipCandidates.length + && [...expectedPalletIds].every((palletId) => recommendedPalletIds.has(palletId)), + `expected=${[...expectedPalletIds].join(',')}, actual=${[...recommendedPalletIds].join(',') || 'missing'}`, OPTIONS) + if (!recommendedPlan || ![...expectedPalletIds].every((palletId) => recommendedPalletIds.has(palletId))) return + + const clearedResponse = await request(context, 'DELETE', + `${smartLoadingBase}/plans/${loadingPlanId}/unpicked-items`, { + expectedPlanVersion: Number(recommendedPlan.version), + operationId: `${context.marker}-${loadingPlanId}-clear-recommendation`.slice(0, 128), + }) + let exactPlan = context.apiSuccess(MODULE, '移除未拣并释放智能推荐临时分配', + clearedResponse, '', OPTIONS) + if (!exactPlan) return + for (const [index, pallet] of dropshipCandidates.entries()) { + assignmentResponse = await request(context, 'POST', + `${smartLoadingBase}/plans/${loadingPlanId}/pallet-assignments`, { + palletId: id(pallet.palletId), expectedVersion: Number(exactPlan.version), + operationId: `${context.marker}-${loadingPlanId}-final-pallet-${id(pallet.palletId)}`.slice(0, 128), + }) + exactPlan = context.apiSuccess(MODULE, `智能推荐回归后恢复本票整托${index + 1}/${dropshipCandidates.length}`, + assignmentResponse, '', OPTIONS) + if (!exactPlan) return + } + assignedLoadingPlan = exactPlan + } + const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId) const orderRemarks = source || {} context.assert(MODULE, '装柜明细客户备注与最新内部备注来源精确且不冒用货物备注', assignmentResponse, @@ -690,16 +780,18 @@ export async function run(context) { if (dropshipAssignment) { const palletUnits = Array.isArray(inventory?.units) ? inventory.units : [] + const expectedPallets = new Map(dropshipCandidates.map((item) => + [id(item.palletId), item.palletNo])) const taskUnitsValid = canonicalPickItems.every((item) => item.unitType === 'SEALED_PALLET' - && id(item.palletId) === id(candidate.palletId) && item.palletNo === candidate.palletNo + && expectedPallets.get(id(item.palletId)) === item.palletNo && /^DP-[0-9]{6}-[0-9]{4}$/.test(item.palletNo) && item.indivisible === true) const inventoryUnitsValid = canonicalPickItems.every((item) => palletUnits.some((unit) => id(unit.pickItemId) === id(item.id) && id(unit.loadingPlanItemId) === id(item.loadingPlanItemId) && id(unit.inboundId) === id(item.inboundId) && unit.unitType === 'SEALED_PALLET' && id(unit.palletId) === id(item.palletId) && unit.palletNo === item.palletNo && unit.indivisible === true)) context.assert(MODULE, 'PDA task与inventory明确返回SEALED_PALLET及DP整托四字段', inventoryResponse, - taskUnitsValid && inventoryUnitsValid, - `taskUnits=${canonicalPickItems.length}, inventoryUnits=${palletUnits.length}`, OPTIONS) + taskUnitsValid && inventoryUnitsValid && canonicalPickItems.length === expectedPallets.size, + `taskUnits=${canonicalPickItems.length}, inventoryUnits=${palletUnits.length}, expectedPallets=${expectedPallets.size}`, OPTIONS) if (!taskUnitsValid || !inventoryUnitsValid) return } diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index adbcf58cb..be5a6a9c1 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -92,6 +92,7 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) + assert.match(entry, /packageCounts: \[4\], palletCount: 2/) assert.match(entry, /sealedSourcePalletNos: Array\.isArray\(fixture\.sealedSourcePalletNos\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) @@ -102,6 +103,12 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(entry, /skuLines: Array\.isArray\(fixtureOrder\.skuLines\) \? fixtureOrder\.skuLines : \[\]/) const pda = read('phase4-overseas-pda.mjs') assert.match(loading, /pallet-assignments/) + assert.match(loading, /一件代发分配首托后其余托盘仍可独立分配/) + assert.match(loading, /已封托托盘拒绝按单条装柜明细删除/) + assert.match(loading, /智能推荐按整托重新加入一件代发多托/) + assert.match(loading, /智能推荐保留同票全部封托托盘且不按箱拆分/) + assert.match(loading, /customerId:\s*tenantId/) + assert.match(loading, /inboundCode:\s*initial\?\.summary\?\.inboundCode\s*\|\|\s*source\?\.inboundCode/) assert.match(loading, /canonicalPickItems\.map/) assert.match(loading, /pickedPackageCount: Number\(item\.plannedPackageCount\)/) assert.match(loading, /const dropshipWholePallet = source\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'/) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 0c2b126a5..965e482c7 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -527,7 +527,10 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au assert.match(loading, /inbound-candidates\?channelGroupCodes=/) assert.match(loading, /unitType === 'SEALED_PALLET'/) assert.match(loading, /indivisible === true/) - assert.match(loading, /palletId: id\(candidate\.palletId\)/) + assert.match(loading, /palletId: id\(pallet\.palletId\)/) + assert.match(loading, /一件代发分配首托后其余托盘仍可独立分配/) + assert.match(loading, /智能推荐保留同票全部封托托盘且不按箱拆分/) + assert.match(loading, /customerId:\s*tenantId/) assert.doesNotMatch(loading, /bridge-assign-inbound/) assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) assert.match(convergence, /expectedBatchCount = fixture\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP' \? 1 : 4/)