From 45e7c98b3f8a7437be801af6267890d243dfc213 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 18:33:17 +0800 Subject: [PATCH] feat: receive dropship inbound by source pallet --- lastleg-service/AGENTS.md | 2 +- .../ReceivingContainerController.java | 8 +++ .../pda/dto/ReceivingContainerRequests.java | 2 + .../pda/dto/ReceivingContainerResponses.java | 2 +- .../service/ReceivingContainerService.java | 46 +++++++++++++- .../openapi/overseas-business-v1.yaml | 17 ++++- .../pda/ReceivingContainerContractTest.java | 5 +- ...ReceivingContainerServiceBehaviorTest.java | 32 ++++++++++ .../inbound-measurement.mjs | 5 +- .../phase3-loading-finalize.mjs | 1 + .../admin-test-env-modules/phase3-loading.mjs | 1 + .../phase4-overseas-pda-contract.test.mjs | 2 +- .../phase4-overseas-pda.mjs | 63 +++++++++++++++++-- 13 files changed, 174 insertions(+), 12 deletions(-) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 2bcd0a619..99ef44763 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -28,7 +28,7 @@ | 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`,合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index 2cb0fa4c2..a05442781 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -134,6 +134,14 @@ public class ReceivingContainerController { return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); } + @PostMapping("/current/receipts/pallet") + public ApiResponse receiveWholePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.WholePalletReceive request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.receiveWholePallet(identity.operatorKey(), identity.operatorName(), request)); + } + @PostMapping("/current/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 6d06a96c9..613a7d18e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -23,4 +23,6 @@ public final class ReceivingContainerRequests { @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, @NotBlank @Size(max = 255) String idempotencyKey) { } + public record WholePalletReceive(@NotBlank String sourcePalletNo, + @NotBlank @Size(max = 255) String idempotencyKey) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 332a2034c..b24602126 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -56,7 +56,7 @@ public final class ReceivingContainerResponses { String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages, boolean assigned, boolean mergeAllowed, boolean urgent, - String mergeHint) { } + String mergeHint, String orderType, boolean wholePalletInbound) { } public record CartonResolved(String cartonMark, String inboundCode, String destinationType, String overseasWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 31ed2e281..741d5eeae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -361,6 +361,46 @@ public class ReceivingContainerService { } } + @Transactional + public ReceivingContainerResponses.Receipt receiveWholePallet(String operatorKey, String operatorName, + ReceivingContainerRequests.WholePalletReceive request) { + ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + List matches = new ArrayList<>(); + for (InboundRef ref : refs(customerId, session.getContainerNo())) { + if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; + List sourcePallets = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, customerId) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId()) + .eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo())); + for (InboundPlanPallet pallet : sourcePallets) { + List palletAllocations = allocations.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, customerId) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId()) + .eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())); + palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation))); + } + } + if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container"); + if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container"); + DropshipPalletRef match = matches.get(0); + int accepted = value(match.allocation().getPlannedPackageCount()); + workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(), + new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName)); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(match.ref()); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(), + accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(), + inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint()); + save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result); + return result; + } + @Transactional(isolation = Isolation.READ_COMMITTED) public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Action request) { @@ -705,6 +745,9 @@ public class ReceivingContainerService { private record PalletSelection(InboundPlanPallet pallet, WarehouseResourceGateway.WarehousePalletReference resource) { } + private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet, + InboundPlanPalletAllocation allocation) { } + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() .map(this::inbound).toList(); @@ -729,7 +772,8 @@ public class ReceivingContainerService { ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received), - assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan())); + assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), + ref.order().getOrderType(), "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())); } private boolean assigned(InboundPlan plan) { diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 0a6161332..ad4963ad1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -121,6 +121,13 @@ paths: security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/pallet: + post: + summary: 一件代发按完整来源托盘收货 + description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描国内封托后冻结的来源托盘号,一次接收该托全部箱和 SKU 快照;不允许逐箱或拆分接收。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } } /api/pda/v1/last-leg/receiving-containers/current/complete: post: summary: 完成当前柜收货并入库存 @@ -1518,6 +1525,12 @@ components: cartonMark: { type: string, minLength: 1 } palletCode: { type: string, minLength: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingWholePallet: + type: object + required: [sourcePalletNo, idempotencyKey] + properties: + sourcePalletNo: { type: string, minLength: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } ReceivingApiResponseBase: type: object required: [code, message, timestamp] @@ -1529,7 +1542,7 @@ components: timestamp: { type: string, format: date-time } ReceivingInbound: type: object - required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, assigned, mergeAllowed, urgent] + required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, assigned, mergeAllowed, urgent, wholePalletInbound] properties: inboundCode: { type: string } destinationType: { type: string, enum: [FBA, OVERSEAS] } @@ -1641,6 +1654,8 @@ components: mergeAllowed: { type: boolean, description: 现场可选择合托,不作为实收或完成门禁 } urgent: { type: boolean } mergeHint: { type: string, nullable: true } + orderType: { type: string, nullable: true } + wholePalletInbound: { type: boolean, description: 一件代发必须按完整来源托盘扫描收货 } ReceivingCandidateListApiResponse: allOf: - { $ref: '#/components/schemas/ReceivingApiResponseBase' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index f95f2cdf4..aa3d9ac73 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -128,14 +128,15 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); assertFalse(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); + assertTrue(yaml.contains(prefix + "/current/receipts/pallet:")); assertTrue(yaml.contains(prefix + "/current/complete:")); assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:")); assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(16, successResponses.size()); - assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(17, successResponses.size()); + assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index eec9810df..2209e5f16 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -743,6 +743,38 @@ class ReceivingContainerServiceBehaviorTest { verify(cartons, never()).insert(any(InboundPlanCarton.class)); } + @Test + void dropshipReceivesTheCompleteFrozenSourcePalletThroughTheContainerSession() { + stubCurrent("OVERSEAS", "CONT-A"); + LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectById(1L)).thenReturn(dropship); + InboundPlanPallet pallet = planPallet(); + pallet.setPalletNo("PAL-PHYSICAL-1"); pallet.setSourcePalletNo("DP-260909-0001"); + InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation(); + pending.setId(30L); pending.setCustomerId("100"); pending.setPlanId(2L); + pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(2); + InboundPlanPalletAllocation received = new InboundPlanPalletAllocation(); + received.setId(30L); received.setCustomerId("100"); received.setPlanId(2L); + received.setLineId(10L); received.setPalletId(20L); received.setPlannedPackageCount(2); + received.setPickedPackageCount(2); + when(pallets.selectList(any())).thenReturn(List.of(pallet)); + when(allocations.selectList(any())).thenReturn(List.of(pending), List.of(received)); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + + var result = service.receiveWholePallet("7", "receiver", + new ReceivingContainerRequests.WholePalletReceive("DP-260909-0001", "whole-key")); + + assertThat(result.acceptedPackages()).isEqualTo(2); + assertThat(result.receivedPackages()).isEqualTo(2); + assertThat(result.pendingPackages()).isZero(); + assertThat(result.palletCode()).isEqualTo("PAL-PHYSICAL-1"); + verify(workflow).pickWholePallet(org.mockito.ArgumentMatchers.eq("100"), + org.mockito.ArgumentMatchers.eq("PLAN-1"), org.mockito.ArgumentMatchers.eq(30L), + any(com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests.WholePalletOperation.class)); + verifyNoInteractions(warehouse); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 5862bf9c2..5d24a901b 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1067,11 +1067,14 @@ export async function run(context) { `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS) let sealedCartonMarks = [] + let sealedSourcePalletNos = [] if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') { const sealed = await completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement, }) sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : [] + sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED' + ? (sealed.pallets || []).map((item) => item?.palletNo).filter(Boolean) : [] completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10) finalResponse = completed.response finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse, @@ -1093,7 +1096,7 @@ export async function run(context) { OPTIONS) context.createdResources.inboundMeasurement = { - sealedCartonMarks, + sealedCartonMarks, sealedSourcePalletNos, marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId), boxCode, finalStatus: finalMeasurement?.measurementStatus || null, processStatus: finalMeasurement?.processStatus || null, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index ff95e185d..a80373bbf 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -506,6 +506,7 @@ export async function run(context) { cartonMark: fixture.cartonMark || null, inboundCode: fixture.inboundCode || null, sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [], + sealedSourcePalletNos: Array.isArray(fixture.sealedSourcePalletNos) ? fixture.sealedSourcePalletNos : [], cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [], skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [], diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index f15c690e0..47d0eca4a 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1607,6 +1607,7 @@ export async function run(context) { context.createdResources.phase3Loading = { inboundCode: initial?.summary?.inboundCode || source?.inboundCode, sealedCartonMarks: Array.isArray(source?.sealedCartonMarks) ? source.sealedCartonMarks : [], + sealedSourcePalletNos: Array.isArray(source?.sealedSourcePalletNos) ? source.sealedSourcePalletNos : [], tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, cartonMark: source?.boxCode || source?.cartonMark || null, cartonMarks: stableLoadedCartonMarks(stagedTask?.items), diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index a8051009c..3170d1755 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -86,7 +86,7 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne test('PDA module covers the container-number receiving contract and excludes outbound domains', () => { for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', '/current/cartons/resolve', '/current/pallets/resolve', - '/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) + '/current/receipts/bulk', '/current/receipts/carton', '/current/receipts/pallet', '/current/complete']) assert.ok(source.includes(token), token) for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) { assert.ok(source.includes(token), token) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 40cd6f37d..04a7c437a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -564,6 +564,37 @@ async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures return true } +async function receiveDropship(context, token, producer, inbounds) { + const sourcePalletNos = Array.isArray(producer?.sealedSourcePalletNos) + ? producer.sealedSourcePalletNos.map(id).filter(nonBlank) : [] + const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) + context.assert(MODULE, '一件代发来源托盘快照已从国内封托完整传递', null, + sourcePalletNos.length > 0 && new Set(sourcePalletNos).size === sourcePalletNos.length, + `sourcePallets=${sourcePalletNos.length}, pendingPackages=${expectedPending}`, OPTIONS) + if (!sourcePalletNos.length) return false + let accepted = 0 + for (const [index, sourcePalletNo] of sourcePalletNos.entries()) { + const body = { sourcePalletNo, idempotencyKey: key(context, `dropship-pallet-${index}`) } + const first = await pda(context, token, 'POST', `一件代发整托实收${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body) + if (!success(first.response)) return false + accepted += Number(first.data?.acceptedPackages || 0) + const replay = await pda(context, token, 'POST', `一件代发整托${index + 1}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body) + context.assert(MODULE, `一件代发整托${index + 1}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `一件代发整托${index + 1}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', + { ...body, sourcePalletNo: `${sourcePalletNo}-MUTATED` }, conflict) + await expected(context, token, 'POST', `一件代发来源托盘重复收货被拒绝${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', + { ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) }, conflict) + } + context.assert(MODULE, '一件代发整托实收箱数与待收箱数守恒', null, + accepted === expectedPending, `accepted=${accepted}, expected=${expectedPending}`, OPTIONS) + return accepted === expectedPending +} + async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { if (runAuthorizationProbes && context.overseasToken) { const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) @@ -666,7 +697,8 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe } const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') - if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { + const strictDropship = preferredProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP' + if (!strictDropship && fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { const firstFba = fbaInbounds[0] const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA' && item.inbound.inboundCode === firstFba.inboundCode) @@ -681,10 +713,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe for (const [index, inbound] of fbaInbounds.entries()) { await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index) } - } else if (preferredProducer?.destinationType !== 'OVERSEAS') { + } else if (!strictDropship && preferredProducer?.destinationType !== 'OVERSEAS') { blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') } - if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { + if (!strictDropship && overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { const firstOverseas = overseasInbounds[0] const pallet = pallets.get(firstOverseas.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { @@ -692,9 +724,32 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/)) await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures) - } else if (preferredProducer?.destinationType !== 'FBA') { + } else if (!strictDropship && preferredProducer?.destinationType !== 'FBA') { blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') } + if (strictDropship) { + const dropshipInbounds = [...fbaInbounds, ...overseasInbounds] + const firstInbound = dropshipInbounds[0] + const pallet = firstInbound ? pallets.get(firstInbound.inboundCode) : null + if (!firstInbound || !pallet) { + blockerGap(context, '一件代发整托链', '当前一件代发柜没有带预留实体托盘的入仓码') + return + } + if (firstInbound.destinationType === 'OVERSEAS') { + await expected(context, token, 'POST', '一件代发硬拒绝逐箱收货', + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { + cartonMark: cartonFixtures[0]?.cartonMark, + palletCode: pallet.palletCode, idempotencyKey: key(context, 'dropship-carton-forbidden'), + }, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/)) + } else { + await expected(context, token, 'POST', '一件代发硬拒绝按箱数收货', + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { + inboundCode: firstInbound.inboundCode, palletCode: pallet.palletCode, + packageCount: 1, idempotencyKey: key(context, 'dropship-bulk-forbidden'), + }, (response) => a0409Message(response, /^One-click dropship inbound must be picked by complete source pallet$/)) + } + await receiveDropship(context, token, preferredProducer, dropshipInbounds) + } const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response)