From d1a00b8e01bf92a75b1c3d54d96787558f4bd500 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 14:30:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.overseas.yml | 2 ++ lastleg-service/AGENTS.md | 2 +- .../controller/DispatchController.java | 2 +- .../dispatch/dto/DispatchRequests.java | 11 +++--- .../dispatch/service/DispatchService.java | 33 ++++++++++++----- .../openapi/overseas-business-v1.yaml | 4 +-- .../SmartDispatchOpenApiContractTest.java | 6 ++++ .../SmartDispatchControllerTest.java | 2 +- .../dispatch/service/DispatchServiceTest.java | 35 +++++++++++++++++-- scripts/admin-test-env-modules/README.md | 2 ++ .../dropship-pallet-matrix.mjs | 7 ++-- ...ixed-container-receiving-contract.test.mjs | 18 ++++++++-- .../mixed-container-receiving.mjs | 12 +++++++ .../phase4-overseas-pda-contract.test.mjs | 4 ++- scripts/overseas-deployment-contract.test.mjs | 12 +++++++ warehouse-overseas-service/AGENTS.md | 2 +- .../service/PalletLabelPrintService.java | 3 +- .../service/PalletLabelPrintServiceTest.java | 14 ++++++++ 18 files changed, 141 insertions(+), 30 deletions(-) diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 33709eb4a..fbde2d580 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -81,6 +81,8 @@ services: WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + # 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。 + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} event-bridge-service: <<: *overseas-common diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 6330a5332..f105f0184 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -52,7 +52,7 @@ | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核通过进入 `CUSTOMER_CONFIRM_PENDING`,客户查看照片确认后进入 `READY_FOR_OUTBOUND`:仅卡车派送为 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | -| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” | +| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | | 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index 9e6701f27..fe78e7aca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -29,7 +29,7 @@ public class DispatchController { @PutMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") - @Operation(summary = "Edit vehicle, driver, appointment and route data") + @Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 2c6ed8808..eb2564c80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -20,18 +20,18 @@ public final class DispatchRequests { } public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, - @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, + @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus, @Size(max = 64) String draftToken, @Size(max = 64) String relabelNo) { - public PreArrange(List planPalletIds, String route, String fbaAppointmentNo, + public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus) { - this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); + this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); } - public PreArrange(List planPalletIds, String route, String fbaAppointmentNo, + public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) { - this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null); + this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null); } } @@ -51,7 +51,6 @@ public final class DispatchRequests { public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, - @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 90aabd930..8689fc9fe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -305,7 +305,7 @@ public class DispatchService { dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); } - dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); + dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); @@ -427,7 +427,7 @@ public class DispatchService { LocalDateTime now = LocalDateTime.now(); DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); + dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); @@ -502,18 +502,19 @@ public class DispatchService { "Only a pending dispatch can be edited"); } String customerId = dispatch.getCustomerId(); + List assignedItems = items.findByDispatchOrder(customerId, dispatch.getId()); dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); dispatch.setDriverName(trimToNull(request.driverName())); dispatch.setDriverPhone(trimToNull(request.driverPhone())); dispatch.setCarrierName(trimToNull(request.carrierName())); - dispatch.setRoute(trimToNull(request.route())); + dispatch.setRoute(itemRoute(assignedItems)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); dispatch.setUpdatedAt(LocalDateTime.now()); orders.updateById(dispatch); - return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + return response(dispatch, assignedItems); } @Transactional @@ -545,6 +546,7 @@ public class DispatchService { dispatch.setPalletCount(remaining.size()); dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) .mapToInt(Integer::intValue).sum()); + dispatch.setRoute(itemRoute(remaining)); dispatch.setUpdatedAt(now); orders.updateById(dispatch); return response(dispatch, remaining); @@ -608,7 +610,7 @@ public class DispatchService { String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) .distinct().collect(Collectors.joining(",")); return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), - order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), + order.getDriverPhone(), order.getCarrierName(), orderNos, itemRoute(itemList), order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(), order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(), order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0), @@ -625,9 +627,24 @@ public class DispatchService { bolLineList == null ? List.of() : bolLineList); } - private static String route(List selected) { - return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText) - .distinct().collect(Collectors.joining(" -> ")); + private static String candidateRoute(List selected) { + return selected.stream().map(DispatchService::candidateFbaCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(",")); + } + + private static String itemRoute(List assignedItems) { + return assignedItems.stream().map(DispatchService::itemFbaCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(",")); + } + + private static String candidateFbaCode(DispatchPalletCandidate candidate) { + return StringUtils.hasText(candidate.fbaWarehouseCode()) + ? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode()); + } + + private static String itemFbaCode(DispatchItem item) { + return StringUtils.hasText(item.getFbaWarehouseCode()) + ? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode()); } private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) { 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 7bb8f3de0..b0cc51a5b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -2113,7 +2113,6 @@ components: required: [planPalletIds] properties: planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } - route: { type: string, nullable: true, maxLength: 255 } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } @@ -2160,13 +2159,12 @@ components: palletIds: { type: array, items: { type: string } } DispatchUpdate: type: object - description: 普通编辑字段;不包含 status 或 departAt。 + description: 普通编辑字段;不包含 status、departAt 或路线,路线由已排托盘关联的 FBA 仓自动汇总。 properties: vehiclePlate: { type: string, nullable: true, maxLength: 64 } driverName: { type: string, nullable: true, maxLength: 128 } driverPhone: { type: string, nullable: true, maxLength: 64 } carrierName: { type: string, nullable: true, maxLength: 255 } - route: { type: string, nullable: true, maxLength: 255 } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 2e6f27c46..24fac6a54 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -31,6 +31,12 @@ class SmartDispatchOpenApiContractTest { assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch")); assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchCandidate")); + String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"), + yaml.indexOf(" SmartDispatchDraftPallet:")); + String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"), + yaml.indexOf(" DeliveryTransfer:")); + assertFalse(preArrangeSchema.contains("route:")); + assertFalse(updateSchema.contains("route:")); assertTrue(yaml.contains("name: planIds")); String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"), yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index aef9e536f..4fb109ff9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -53,7 +53,7 @@ class SmartDispatchControllerTest { @Test void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( - List.of(10L, 11L), "ONT8 -> LAX9", "APT-1", "SUCCESS", "PENDING"); + List.of(10L, 11L), "APT-1", "SUCCESS", "PENDING"); when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); controller.preArrange(platform(), request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 59b49b718..f40235374 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -101,6 +101,7 @@ class DispatchServiceTest { assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class, DispatchRequests.PreArrange.class).getAnnotation(Transactional.class)); when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(item(11L, "ONT8"))); var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)); @@ -119,6 +120,22 @@ class DispatchServiceTest { verify(items).insert(any(DispatchItem.class)); } + @Test + void preArrangeBuildsCommaSeparatedDistinctFbaRouteFromAssignedPallets() { + when(candidates.lockSelected("100", List.of(10L, 11L, 12L))).thenReturn(List.of( + candidate(10L, null, "ONT8"), candidate(11L, null, "ABE4"), candidate(12L, null, "ONT8"))); + when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of( + item(21L, "ONT8"), item(22L, "ABE4"), item(23L, "ONT8"))); + + DispatchResponse result = service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L, 11L, 12L), null, null, null)); + + assertEquals("ONT8,ABE4", result.route()); + ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); + verify(orders).insert(dispatch.capture()); + assertEquals("ONT8,ABE4", dispatch.getValue().getRoute()); + } + @Test void movingPalletIntoDraftImmediatelyCreatesExpiringExclusiveLock() { when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); @@ -197,7 +214,7 @@ class DispatchServiceTest { when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); when(drafts.lockActive("draft-1", "7")).thenReturn(draft); - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1")); + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, "draft-1")); assertNotNull(lock.getDispatchOrderId()); assertEquals("draft-1", lock.getDraftToken()); @@ -326,13 +343,18 @@ class DispatchServiceTest { dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); dispatch.setStatus(DispatchService.PENDING_DEPARTURE); when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, null, + DispatchItem ont8 = item(11L, "ONT8"); + DispatchItem abe4 = item(12L, "ABE4"); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8"))); + DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, null, null, null, LocalDateTime.now()); DispatchResponse result = service.update("PC1", request); assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); assertEquals("ABC-1", result.vehiclePlate()); + assertEquals("ONT8,ABE4", result.route()); + assertEquals("ONT8,ABE4", dispatch.getRoute()); verify(orders).updateById(dispatch); verify(items).findByDispatchOrder("100", 1L); } @@ -343,7 +365,7 @@ class DispatchServiceTest { dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); dispatch.setStatus("DEPARTED"); when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, + DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, null, null, null); ResponseStatusException exception = assertThrows(ResponseStatusException.class, @@ -442,4 +464,11 @@ class DispatchServiceTest { "LIP1-P001", destination, destination, destination, destination, packageCount, new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); } + + private DispatchItem item(long id, String fbaWarehouseCode) { + DispatchItem item = new DispatchItem(); + item.setId(id); + item.setFbaWarehouseCode(fbaWarehouseCode); + return item; + } } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 1345d5b68..d3c9d31b5 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -37,6 +37,8 @@ export async function run(context) { ## 可用上下文 +混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。 + `mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。 一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。 diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index d0a96fc11..de3f09c97 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -334,16 +334,19 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals return data } let summary + let summaryResponse for (let attempt = 0; attempt < 60; attempt++) { const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`) + summaryResponse = response summary = response?.body?.code === '200' ? response.body.data : null if (summary?.orders?.length === group.length) break if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000)) } - check(`${label}跨区事件逐票建单完整`, null, + check(`${label}跨区事件逐票建单完整`, summaryResponse, summary?.orders?.length === group.length && group.every((record) => summary.orders.some((order) => order.inboundCode === record.source.inboundCode - && String(order.customerId) === String(record.order.tenantId)))) + && String(order.customerId) === String(record.order.tenantId))), + `containerNo=${loading.containerNo}, expectedOrders=${group.length}, actualOrders=${summary?.orders?.length ?? 'unavailable'}`) const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`) check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length) const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion), diff --git a/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs index 1a159b375..189c78773 100644 --- a/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs +++ b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs @@ -37,7 +37,8 @@ test('schedule interleaves three modes and uses FBA cumulative count rather than assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2))) }) -function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false } = {}) { +function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false, repeatIncrement = false, + changedPayloadAccepted = false } = {}) { const group = fixtures() const expected = new Map(group.map(r => [r.source.inboundCode, 0])) const schedule = mixedReceivingSchedule(group) @@ -50,7 +51,14 @@ function driver({ earlyComplete = false, wrongModeAccepted = false, drift = fals inbounds: [...expected].map(([inboundCode, receivedPackages]) => ({ inboundCode, totalPackages: 4, receivedPackages, pendingPackages: 4 - receivedPackages })) }) return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name), - request: async (_method, path) => ({ body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } }), + request: async (_method, path, body) => { + if (body.idempotencyKey.endsWith('-duplicate')) { + return { body: path.endsWith('/carton') ? { code: 'A0409' } + : { code: '200', data: { result: 'ALREADY_RECEIVED', acceptedPackages: repeatIncrement ? 1 : 0 } } } + } + if (seen.has(body.idempotencyKey)) return { body: { code: changedPayloadAccepted ? '200' : 'A0409' } } + return { body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } } + }, call: async (_name, method, path, body) => { if (method === 'GET') return snapshot() if (seen.has(body.idempotencyKey)) return structuredClone(seen.get(body.idempotencyKey)) @@ -82,3 +90,9 @@ test('mixed receiving fails if overseas or dropship can bypass pallet/carton rec test('mixed receiving detects correct aggregate count allocated to the wrong inbound', async () => { await assert.rejects(receiveMixedContainer(driver({ drift: true }))) }) +test('mixed receiving rejects a new-key rescan that accepts another box', async () => { + await assert.rejects(receiveMixedContainer(driver({ repeatIncrement: true }))) +}) +test('mixed receiving rejects same-key different-payload success', async () => { + await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true }))) +}) diff --git a/scripts/admin-test-env-modules/mixed-container-receiving.mjs b/scripts/admin-test-env-modules/mixed-container-receiving.mjs index e260ed2cb..f204aba7e 100644 --- a/scripts/admin-test-env-modules/mixed-container-receiving.mjs +++ b/scripts/admin-test-env-modules/mixed-container-receiving.mjs @@ -58,6 +58,18 @@ export async function receiveMixedContainer({ group, call, check, request, marke expected.find(row => row.inboundCode === step.record.source.inboundCode).received += step.count const after = await call('混柜每步后逐票回查', 'GET', base) check('混柜交替收货不串票不重计', null, receivingProgressMatches(after, expected)) + const changedBody = step.path === 'pallet' ? { ...body, sourcePalletNo: `${body.sourcePalletNo}-CHANGED` } + : step.path === 'bulk' ? { ...body, packageCount: body.packageCount + 1 } + : { ...body, palletCode: `${body.palletCode}-CHANGED` } + const changed = await request('POST', path, changedBody) + check('混柜收货同键异载荷必须冲突', changed, changed.body?.code === 'A0409') + const duplicate = await request('POST', path, { ...body, idempotencyKey: `${body.idempotencyKey}-duplicate` }) + check('混柜同货物新键重复操作不重复收货', duplicate, step.path === 'carton' + ? duplicate.body?.code === 'A0409' + : duplicate.body?.code === '200' && duplicate.body?.data?.result === 'ALREADY_RECEIVED' + && Number(duplicate.body?.data?.acceptedPackages) === 0) + const afterRetries = await call('混柜重复和冲突请求后回查全部票', 'GET', base) + check('混柜冲突及新键重复后全柜事实不变', null, JSON.stringify(afterRetries) === JSON.stringify(after)) } const body = { allowDifference: false, idempotencyKey: `${marker}-mixed-complete` } const completed = await call('混柜全部核平后正常完成入库', 'POST', `${base}/complete`, body) 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 0b6799f4d..79db67ffe 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 @@ -64,8 +64,10 @@ test('current loading event binds each pallet carton group to an explicit source const producer = fs.readFileSync(path.join(here, '../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java'), 'utf8') assert.match(model, /record SkuPackageSnapshot\(String sourceInboundDetailId, String skuCode/) assert.match(model, /sourceInboundDetailId == null \|\| sourceInboundDetailId\.isBlank\(\)/) - assert.match(producer, /SkuPackageSnapshot\(detail\.detailId\(\)\.toString\(\), detail\.skuCode\(\)/) assert.match(producer, /SkuPackageSnapshot\(detail\.getSourceInboundDetailId\(\)\.toString\(\), detail\.getSkuCode\(\)/) + assert.match(producer, /groupingBy\(PalletRow::getSourceInboundDetailId/) + assert.match(producer, /map\(PalletRow::getActualSkuQuantity\)/) + assert.match(producer, /cartonQuantities\(detailRows\.getValue\(\), marks\)/) const measurement = fs.readFileSync(path.join(here, 'inbound-measurement.mjs'), 'utf8') const loading = fs.readFileSync(path.join(here, 'phase3-loading.mjs'), 'utf8') const finalize = fs.readFileSync(path.join(here, 'phase3-loading-finalize.mjs'), 'utf8') diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 8d981d746..229328bc9 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -155,6 +155,18 @@ test('domestic event bridge SQL converges legacy tenant columns to the customer assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) }) +test('both overseas PDF consumers explicitly connect to the existing CN Print service', async () => { + const compose = await read('deploy/docker-compose.overseas.yml') + for (const service of ['lastleg-service', 'warehouse-overseas-service']) { + const block = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|^networks:)`, 'm'))?.[1] + assert.ok(block, `${service} must have a deployment definition`) + assert.match(block, /PRINT_SERVICE_URL: \$\{PRINT_SERVICE_URL:-http:\/\/print:9207\}/, + `${service} cannot discover the CN Print service from its overseas Nacos namespace`) + const config = await read(`${service}/src/main/resources/application.yml`) + assert.match(config, /print:\s*\n\s+url: \$\{PRINT_SERVICE_URL:\}/) + } +}) + test('domestic unified SQL creates the overseas hold version before queuing its comment', async () => { const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') const addColumn = incremental.indexOf('CALL add_inbound_overseas_hold_version();') diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 09d788868..72157a55e 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -17,7 +17,7 @@ | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | -| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由;`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,未配置时使用服务发现 | +| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/warehouse/` | diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java index 878d38673..4b884eed3 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java @@ -9,6 +9,7 @@ import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.List; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -49,7 +50,7 @@ public class PalletLabelPrintService { .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(fileName, StandardCharsets.UTF_8).build().toString()) .header(HttpHeaders.CACHE_CONTROL, "no-store").body(body); - } catch (FeignException exception) { + } catch (FeignException | NoFallbackAvailableException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); } } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java index c3894d2cd..dce619ee6 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.warehouse.entity.WarehousePalletEntity; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.util.ReflectionTestUtils; @@ -66,6 +67,19 @@ class PalletLabelPrintServiceTest { verifyNoInteractions(print); } + @Test + void mapsCircuitBreakerWrappedPrintFailureToServiceUnavailable() { + when(print.generateInboundMarks(anyString(), anyString(), any())) + .thenThrow(new NoFallbackAvailableException("No fallback available.", + new IllegalStateException("No instances available for print-service"))); + + assertThatThrownBy(() -> service.print(200L, 9L, "request-1")) + .isInstanceOfSatisfying(BizException.class, exception -> { + assertThat(exception.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + assertThat(exception.getMessage()).contains("打印服务暂不可用"); + }); + } + @Test void rejectsJsonErrorInsteadOfDownloadingItAsPdf() { when(print.generateInboundMarks(anyString(), anyString(), any()))