From 02844da360d2ddc0e7aa711a07d95c65bddb078c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Sun, 20 Sep 2026 15:06:17 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7=EF=BC=8C?= =?UTF-8?q?=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auth/src/main/resources/application-dev.yml | 4 + deploy/sql/海外区域统一增量-MySQL8.sql | 9 +- .../InternalBusinessFileController.java | 9 + .../service/InternalBusinessFileService.java | 11 + .../DeliveryCompletedOutboxService.java | 12 +- .../dto/PalletOperationResponses.java | 24 +- .../gateway/HttpPalletPhysicalGateway.java | 13 +- .../gateway/PalletPhysicalGateway.java | 6 + .../service/PdaPalletOperationService.java | 177 +++++++- .../pda/controller/PdaRelabelController.java | 16 + .../relabel/pda/dto/PdaRelabelRequests.java | 10 + .../relabel/pda/dto/PdaRelabelResponses.java | 2 +- .../pda/entity/PdaRelabelUnitFact.java | 1 + .../gateway/HttpRelabelPhotoFileGateway.java | 12 + .../pda/gateway/RelabelPhotoFileGateway.java | 1 + .../pda/service/PdaRelabelService.java | 112 ++++- .../service/RelabelApplicationService.java | 85 +++- .../relabel/service/RelabelBindingGuard.java | 12 +- .../PdaReturnReceiptController.java | 68 +++ .../PlatformReturnOrderController.java | 22 + .../dto/PdaReturnReceiptRequests.java | 5 +- .../dto/PdaReturnReceiptResponses.java | 7 +- .../dto/ReturnOrderRequests.java | 11 +- .../dto/ReturnOrderResponses.java | 11 +- .../entity/ReturnInventoryCarton.java | 1 + .../returnreceipt/entity/ReturnOrder.java | 2 + .../returnreceipt/entity/ReturnReceipt.java | 1 + .../mapper/ReturnInventoryCartonMapper.java | 17 + .../mapper/ReturnReceiptItemMapper.java | 8 +- .../service/ReturnInventoryService.java | 4 + .../service/ReturnReceiptService.java | 251 +++++++++-- .../service/FbaWarehouseValidator.java | 6 +- .../service/WarehouseTransferService.java | 10 +- .../src/main/resources/db/lastleg.sql | 18 +- .../DeliveryCompletedOutboxServiceTest.java | 34 ++ ...PalletOperationControllerContractTest.java | 20 + .../HttpPalletPhysicalGatewayTest.java | 20 + .../PdaPalletOperationServiceTest.java | 67 ++- .../lastleg/relabel/RelabelContractTest.java | 16 + .../pda/service/PdaRelabelServiceTest.java | 67 +++ .../RelabelApplicationServiceTest.java | 12 + .../RelabelReturnCartonCandidateTest.java | 1 + .../service/ReturnReceiptServiceTest.java | 111 ++++- .../service/FbaWarehouseValidatorTest.java | 12 + .../service/WarehouseTransferServiceTest.java | 38 ++ scripts/README.md | 5 + ...dev-lastleg-relabel-return-incremental.sql | 4 +- scripts/db/incremental.sql | 4 +- scripts/db/outbound-return.sql | 4 +- .../db/return-bulk-relabel-incremental.sql | 45 ++ .../warehouse-transfer-receipt-acceptance.mjs | 212 ++++++++++ ...arehouse-transfer-remaining-acceptance.mjs | 393 ++++++++++++++++++ .../warehouse-transfer-resume-acceptance.mjs | 182 ++++++++ .../InternalWarehousePalletController.java | 2 +- .../warehouse/dto/RelabelPalletRequests.java | 3 +- .../service/WarehouseResourceService.java | 22 +- 56 files changed, 2121 insertions(+), 111 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java create mode 100644 scripts/db/return-bulk-relabel-incremental.sql create mode 100644 scripts/warehouse-transfer-receipt-acceptance.mjs create mode 100644 scripts/warehouse-transfer-remaining-acceptance.mjs create mode 100644 scripts/warehouse-transfer-resume-acceptance.mjs diff --git a/auth/src/main/resources/application-dev.yml b/auth/src/main/resources/application-dev.yml index 8174995ba..1c0753a99 100644 --- a/auth/src/main/resources/application-dev.yml +++ b/auth/src/main/resources/application-dev.yml @@ -1,4 +1,8 @@ spring: + cloud: + nacos: + discovery: + ip: ${NACOS_DISCOVERY_IP:127.0.0.1} datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 03d53351d..82bf8d971 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2314,6 +2314,7 @@ CREATE TABLE ll_relabel_pda_unit_fact ( result varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', exception_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常编码', reason varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '跳过或异常原因', + quantity int NOT NULL DEFAULT '1' COMMENT '本次事实处理件数,箱级作业固定为1', operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识', operator_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照', occurred_at datetime(3) NOT NULL COMMENT '逐件事实发生时间', @@ -2360,6 +2361,7 @@ CREATE TABLE ll_return_inventory_carton ( source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID', source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔', + pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码', carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码', fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', @@ -2382,8 +2384,8 @@ CREATE TABLE ll_return_inventory_carton ( UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id), KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at), - CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))), - CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED'))) + CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))), + CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED'))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛'; CREATE TABLE ll_return_inventory_carton_item ( @@ -2432,6 +2434,8 @@ CREATE TABLE ll_return_order ( expected_quantity bigint NOT NULL COMMENT '预报总数量', received_quantity bigint NOT NULL DEFAULT '0' COMMENT '累计实收总数量', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户备注', + discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手动完结差异原因', + manually_completed_at datetime(3) DEFAULT NULL COMMENT '手动差异完结时间', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求SHA-256', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建操作人ID', @@ -2466,6 +2470,7 @@ CREATE TABLE ll_return_receipt ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号', receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', + pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码', carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号', tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收包裹的可选快递单号', bound_by varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java index 64cac7d87..2e1e1b5bd 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -44,6 +44,15 @@ public class InternalBusinessFileController { return ApiResponse.success(service.upload(tenantId, businessType, businessId, operatorId, file)); } + @GetMapping("/{tenantId}/{businessType}/{businessId}") + public ApiResponse> list( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId) { + check(token); checkTenantId(tenantId); + return ApiResponse.success(service.list(tenantId, businessType, businessId)); + } + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") public ApiResponse metadata( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index b82ed4fd8..2bc77a24b 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; +import java.util.List; @Service public class InternalBusinessFileService { @Resource private FileObjectMapper files; @@ -52,6 +53,16 @@ public class InternalBusinessFileService { return metadata(required(tenantId, businessType, businessId, fileId)); } + public List list(long tenantId, String businessType, String businessId) { + BusinessFileCategory category = BusinessFileCategory.require(businessType); + category.validateOwner(tenantId); category.storageBusinessId(businessId); + return files.selectList(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getBusinessType, businessType) + .eq(FileObjectEntity::getBusinessId, businessId).eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false).orderByAsc(FileObjectEntity::getCreatedAt, FileObjectEntity::getId)) + .stream().map(InternalBusinessFileService::metadata).toList(); + } + public Download download(long tenantId, String businessType, String businessId, long fileId) { FileObjectEntity value = required(tenantId, businessType, businessId, fileId); return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 0b4a089f4..2fc682315 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -39,7 +39,17 @@ public class DeliveryCompletedOutboxService { Objects.requireNonNull(completedAt, "completedAt"); Map bySourceInbound = new LinkedHashMap<>(); for (LastlegOrder order : orders == null ? List.of() : orders) { - if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue; + // Only orders projected from the domestic first-leg domain need an + // acknowledgement event. Customer/local overseas orders can still carry + // a local sourceInboundId, but deliberately have no domestic sourceOrderId. + if (StringUtils.hasText(order.getSourceRegion()) + && !RegionCode.CN.name().equalsIgnoreCase(order.getSourceRegion())) continue; + // Relabel and warehouse-transfer deliveries are created and completed wholly + // in the overseas last-leg domain. They do not have a domestic source order + // to acknowledge, so publishing the cross-region event would make receipt + // completion fail on the missing source-order fields. + if ("RELABEL_APPLICATION".equals(order.getOrderType()) + || "WAREHOUSE_TRANSFER".equals(order.getOrderType())) continue; if (!StringUtils.hasText(order.getSourceInboundId())) continue; requireText(order.getSourceOrderId(), "sourceOrderId"); requireText(order.getInboundCode(), "inboundCode"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java index 1017dd630..0eb4cf9ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -1,24 +1,36 @@ package com.baiyue.lastleg.palletops.dto; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + import java.time.LocalDateTime; import java.util.List; public final class PalletOperationResponses { private PalletOperationResponses() { } - public record RelabelSource(long positionId, long positionVersion, String palletCode, + public record RelabelSource( + @JsonSerialize(using = ToStringSerializer.class) long positionId, + long positionVersion, + @JsonSerialize(using = ToStringSerializer.class) long palletId, String palletCode, String locationCode, String inboundCode, long stockPackages, long movablePackages, List cartonMarks) { } - public record InventoryLine(String customerId, String customerName, long balanceId, long positionId, + public record InventoryLine(String customerId, String customerName, + @JsonSerialize(using = ToStringSerializer.class) long balanceId, + @JsonSerialize(using = ToStringSerializer.class) long positionId, String inboundCode, String skuCode, String productName, long availablePackages, long holdPackages, long allocatedPackages, long pickedPackages, long receivingPackages, long positionVersion) { } - public record PalletContent(long palletId, String palletCode, String status, int palletVersion, - Long locationId, String locationCode, String destinationWarehouseCode, + public record PalletContent(@JsonSerialize(using = ToStringSerializer.class) long palletId, + String palletCode, String status, int palletVersion, + @JsonSerialize(using = ToStringSerializer.class) Long locationId, + String locationCode, String destinationWarehouseCode, long totalPackages, List inventory) { } - public record PrintablePallet(long palletId, String palletCode, int palletVersion, + public record PrintablePallet(@JsonSerialize(using = ToStringSerializer.class) long palletId, + String palletCode, int palletVersion, String relabelNo, String printableHtml) { } - public record Operation(long operationId, String operationType, String idempotencyKey, + public record Operation(@JsonSerialize(using = ToStringSerializer.class) long operationId, + String operationType, String idempotencyKey, String status, boolean replayed, LocalDateTime completedAt, List pallets) { public Operation asReplay() { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java index 3ff6e36f3..0a3472298 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.palletops.gateway; import com.baiyue.lastleg.config.LastlegRestClientFactory; import com.baiyue.lastleg.config.LastlegRestClientProperties; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; @@ -30,7 +31,14 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { @Override public PalletReference ensureRelabelPallet(long warehouseId, String businessId) { - Map body = Map.of("warehouseId", warehouseId, "businessId", businessId); + return ensureRelabelPallet(warehouseId, businessId, null); + } + + @Override + public PalletReference ensureRelabelPallet(long warehouseId, String businessId, String fbaWarehouseCode) { + Map body = new LinkedHashMap<>(); + body.put("warehouseId", warehouseId); body.put("businessId", businessId); + if (fbaWarehouseCode != null && !fbaWarehouseCode.isBlank()) body.put("fbaWarehouseCode", fbaWarehouseCode.trim()); ResolveEnvelope response = client.post().uri("/internal/v1/warehouse-pallets/ensure-relabel") .header("X-Internal-Service-Token", internalToken).body(body).retrieve() .onStatus(HttpStatusCode::isError, (request, result) -> { @@ -117,7 +125,10 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { }).toBodilessEntity(); } + @JsonIgnoreProperties(ignoreUnknown = true) private record ResolveEnvelope(String code, String message, PalletReference data) { } + @JsonIgnoreProperties(ignoreUnknown = true) private record LocationEnvelope(LocationReference data) { } + @JsonIgnoreProperties(ignoreUnknown = true) private record TransitionEnvelope(Transition data) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java index 9ef546a51..8e98a4ae6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java @@ -1,7 +1,12 @@ package com.baiyue.lastleg.palletops.gateway; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + public interface PalletPhysicalGateway { PalletReference ensureRelabelPallet(long warehouseId, String businessId); + default PalletReference ensureRelabelPallet(long warehouseId, String businessId, String fbaWarehouseCode) { + return ensureRelabelPallet(warehouseId, businessId); + } PalletReference resolve(String palletCode); PalletReference resolve(long palletId); LocationReference resolveLocation(long warehouseId, long locationId); @@ -9,6 +14,7 @@ public interface PalletPhysicalGateway { String operationKey, String reason); void compensate(long warehouseId, Transition transition); + @JsonIgnoreProperties(ignoreUnknown = true) record PalletReference(long palletId, String palletCode, long warehouseId, Long locationId, String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { public PalletReference(long palletId, String palletCode, long warehouseId, Long locationId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index ccbe09d05..637626f9b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -18,6 +18,11 @@ import com.baiyue.lastleg.relabel.entity.RelabelItem; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; import com.fasterxml.jackson.core.JsonProcessingException; @@ -48,6 +53,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; @Service public class PdaPalletOperationService { @@ -61,6 +67,9 @@ public class PdaPalletOperationService { @Resource private RelabelApplicationMapper relabelApplications; @Resource private RelabelItemMapper relabelItems; @Resource private RelabelPalletMutationService relabelPalletMutations; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnInventoryCartonItemMapper returnCartonItems; + @Resource private ReturnInventoryBalanceMapper returnBalances; private TransactionTemplate transaction; private TransactionTemplate newTransaction; @@ -85,6 +94,26 @@ public class PdaPalletOperationService { Set boundMarks = boundRelabelMarks(app); List items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())); + if (!items.isEmpty() && items.stream().allMatch(item -> item.getReturnCartonId() != null)) { + List result = new ArrayList<>(); + Map> byCarton = items.stream().filter(item -> !boundMarks.contains(item.getCartonMark())).collect(Collectors.groupingBy( + RelabelItem::getReturnCartonId, LinkedHashMap::new, Collectors.toList())); + for (var entry : byCarton.entrySet()) { + ReturnInventoryCarton carton = returnCartons.selectById(entry.getKey()); + if (carton == null || !Objects.equals(carton.getCustomerId(), app.getCustomerId()) + || !Objects.equals(carton.getOverseasWarehouseId(), app.getOverseasWarehouseId()) + || !Objects.equals(carton.getStatus(), "RELABEL_PENDING")) + throw conflict("退货换标来源托盘不存在或已被占用"); + PalletPhysicalGateway.PalletReference pallet = physical.resolve(carton.getPalletCode()); + if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId()) + throw conflict("退货换标来源托盘不属于当前仓库"); + long quantity = value(carton.getTotalQuantity()); + result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L, + pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity, + entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList())); + } + return result; + } Map> byBalance = new LinkedHashMap<>(); for (RelabelItem item : items) { InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId()); @@ -99,7 +128,7 @@ public class PdaPalletOperationService { long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId())) .mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()), - row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(), + row.getPalletId(), row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(), value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages()) + value(row.getPickedPackages()), value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())), @@ -118,15 +147,22 @@ public class PdaPalletOperationService { throw conflict("换标单不属于 Token 冻结的海外仓"); } String key = required(request.idempotencyKey(), "幂等键不能为空"); + String destination = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("application_id", application.getId()).eq("customer_id", application.getCustomerId())) + .stream().map(RelabelItem::getDestinationFbaWarehouseCode).filter(value -> value != null && !value.isBlank()) + .distinct().reduce((left, right) -> { throw conflict("同一换标单只能绑定一个 FBA 目的仓"); }).orElse(null); PalletPhysicalGateway.PalletReference pallet = physical.ensureRelabelPallet(identity.overseasWarehouseId(), - relabelNo + ":" + key); + relabelNo + ":" + key, destination); String code = HtmlUtils.htmlEscape(pallet.palletCode()); String order = HtmlUtils.htmlEscape(relabelNo); + String fbaValue = destination == null ? pallet.fbaWarehouseCode() : destination; + String fba = HtmlUtils.htmlEscape(fbaValue == null ? "" : fbaValue); + if (fba == null || fba.isBlank()) fba = "待绑定"; String html = "" + code + "

托盘标签

" - + code + "
换标单:" + order + "
"; + + code + "
换标单:" + order + "
FBA目的仓:" + fba + "
"; return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(), relabelNo, html); } @@ -205,8 +241,10 @@ public class PdaPalletOperationService { Map packagesByPosition = new LinkedHashMap<>(); Map> itemsByPosition = new LinkedHashMap<>(); Map anchors = new LinkedHashMap<>(); - InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId()); - if (request.actualSource() != null && (actual == null + boolean returnRelabel = selected.stream().allMatch(item -> item.getReturnCartonId() != null); + InventoryPosition actual = returnRelabel || request.actualSource() == null + ? null : positions.selectById(request.actualSource().positionId()); + if (!returnRelabel && request.actualSource() != null && (actual == null || !Objects.equals(actual.getCustomerId(), application.getCustomerId()) || !Objects.equals(actual.getPalletId(), source.palletId()) || !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId()) @@ -219,13 +257,17 @@ public class PdaPalletOperationService { if ("PROCESSING".equals(application.getStatus())) { var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId()); for (RelabelItem item : selected) { - Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() - .eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId()) - .eq("item_id", item.getId()).eq("result", "RELABELLED")); - if (completed == null || completed < value(item.getNotifiedPackageCount())) + long completed = relabelWorkFacts.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId()) + .eq("item_id", item.getId()).eq("result", "RELABELLED")) + .stream().mapToLong(fact -> fact.getQuantity() == null ? 1L : fact.getQuantity()).sum(); + if (completed < value(item.getNotifiedPackageCount())) throw conflict("请先完成本箱全部换标作业,再进行组托"); } } + if (returnRelabel) { + return bindReturnRelabelPallet(identity, application, selected, source, target, request); + } for (RelabelItem item : selected) { if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null || item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照"); @@ -265,6 +307,107 @@ public class PdaPalletOperationService { reason, relabelPlan); } + private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity, + RelabelApplication application, List selected, + PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target, + PalletOperationRequests.RelabelDisassemble request) { + requireRelabelDestination(identity.overseasWarehouseId(), selected, target); + Set alreadyBound = boundRelabelMarks(application); + if (selected.stream().map(RelabelItem::getCartonMark).anyMatch(alreadyBound::contains)) + throw conflict("退货换标箱已在本轮完成组托,请刷新后重试"); + long quantity = 0; + List cartons = new ArrayList<>(); + for (RelabelItem item : selected) { + ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId()) + || !Objects.equals(carton.getPalletCode(), source.palletCode()) + || !Objects.equals(carton.getRelabelApplicationId(), application.getId()) + || !Objects.equals(carton.getStatus(), "RELABEL_PENDING")) + throw conflict("退货换标来源托盘或库存状态已变化,请刷新后重试"); + quantity += value(item.getNotifiedPackageCount()); + cartons.add(carton); + } + if (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId() + || request.actualSource().packages() != quantity) + throw conflict("退货换标拆出数量必须等于本批已完成贴标数量"); + String key = required(request.idempotencyKey(), "幂等键不能为空"); + String requestHash = hash(request); + PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash); + if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); + physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托"); + return transaction.execute(status -> { + for (int index = 0; index < cartons.size(); index++) { + RelabelItem item = selected.get(index); + ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId()) + || !Objects.equals(carton.getPalletCode(), source.palletCode()) + || !Objects.equals(carton.getRelabelApplicationId(), application.getId()) + || !"RELABEL_PENDING".equals(carton.getStatus())) + throw conflict("退货换标来源库存已变化,请刷新后重试"); + long move = value(item.getNotifiedPackageCount()); + if (move <= 0 || move > value(carton.getTotalQuantity())) throw conflict("来源托盘可拆数量不足"); + if (move < value(carton.getTotalQuantity())) { + splitReturnRelabelCarton(application, item, carton, target, move); + } else { + carton.setPalletCode(target.palletCode()); + carton.setDestinationWarehouseCode(item.getDestinationFbaWarehouseCode()); + carton.setRelabelStatus("PROCESSING"); + carton.setUpdatedAt(LocalDateTime.now()); + returnCartons.updateById(carton); + } + } + recordReturnRelabelBinding(identity, application, selected, target.palletCode()); + List contents = List.of( + content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())), + content(identity.overseasWarehouseId(), physical.resolve(target.palletCode()))); + PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(), + operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents); + operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); + operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation); + return result; + }); + } + + /** Keep the unlabelled remainder on its original pallet; move only the confirmed SKU pieces. */ + private void splitReturnRelabelCarton(RelabelApplication application, RelabelItem item, + ReturnInventoryCarton source, PalletPhysicalGateway.PalletReference target, long quantity) { + if (relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("application_id", application.getId()).eq("return_carton_id", source.getId())).size() != 1) + throw conflict("来源托盘包含多项换标任务,不能按单 SKU 拆分,请核对库存"); + List lines = returnCartonItems.lockCarton(source.getId()); + if (lines.size() != 1 || !item.getSkuCode().equalsIgnoreCase(lines.get(0).getSkuCode()) + || value(lines.get(0).getQuantity()) != value(source.getTotalQuantity())) + throw conflict("来源托盘包含其他 SKU,不能按单 SKU 拆分,请核对库存"); + long remaining = value(source.getTotalQuantity()) - quantity; + ReturnInventoryCartonItem sourceLine = lines.get(0); + sourceLine.setQuantity(remaining); + if (returnCartonItems.updateById(sourceLine) != 1 + || returnCartons.releasePartial(source.getId(), application.getCustomerId(), application.getId(), remaining) != 1) + throw conflict("来源托盘数量已变化,请刷新后重试"); + LocalDateTime now = LocalDateTime.now(); + long targetId = IdWorker.getId(); + ReturnInventoryCarton moved = new ReturnInventoryCarton(); + moved.setId(targetId); moved.setCustomerId(source.getCustomerId()); + moved.setOverseasWarehouseId(source.getOverseasWarehouseId()); + moved.setSourceBatchNo(source.getSourceBatchNo()); moved.setSourceReturnNo(source.getSourceReturnNo()); + moved.setPalletCode(target.palletCode()); moved.setCartonMark("RL-CARTON-" + targetId); + moved.setDestinationWarehouseCode(item.getDestinationFbaWarehouseCode()); + moved.setRelabelApplicationId(application.getId()); moved.setRelabelNo(application.getRelabelNo()); + moved.setRelabelStatus("PROCESSING"); moved.setStatus("RELABEL_PENDING"); + moved.setSkuTypes(1); moved.setTotalQuantity(quantity); + moved.setCreatedAt(now); moved.setUpdatedAt(now); + if (returnCartons.insert(moved) != 1) throw conflict("新托盘库存创建失败"); + ReturnInventoryCartonItem targetLine = new ReturnInventoryCartonItem(); + targetLine.setId(IdWorker.getId()); targetLine.setCartonId(targetId); + targetLine.setCustomerId(source.getCustomerId()); targetLine.setSkuCode(sourceLine.getSkuCode()); + targetLine.setProductNameZh(sourceLine.getProductNameZh()); targetLine.setQuantity(quantity); + if (returnCartonItems.insert(targetLine) != 1 + || returnBalances.adjustCartonQuantity(source.getCustomerId(), source.getOverseasWarehouseId(), + sourceLine.getSkuCode(), 1) != 1) throw conflict("退货库存拆分记账失败"); + item.setReturnCartonId(targetId); + if (relabelItems.updateById(item) != 1) throw conflict("换标单来源托盘更新失败"); + } + public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity, PalletOperationRequests.Swap request) { PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP", @@ -610,6 +753,22 @@ public class PdaPalletOperationService { relabelWorkHistory.insert(record); } + private void recordReturnRelabelBinding(PdaWarehouseIdentity identity, RelabelApplication app, + List items, String targetPallet) { + var task = relabelWorkTasks.find(app.getCustomerId(), app.getId()); + if (task == null) throw conflict("退货换标作业任务不存在,不能记录组托"); + var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId()); + record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND"); + record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus()); + record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName()); + record.setCreatedAt(LocalDateTime.now()); + record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), + "cartonMarks", items.stream().map(RelabelItem::getCartonMark).toList(), + "targetPallets", List.of(targetPallet)))); + relabelWorkHistory.insert(record); + } + private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) { if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus())) throw conflict("当前换标状态不能组托"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java index 34011a428..0e4405b67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -78,6 +78,22 @@ public class PdaRelabelController { identity.overseasWarehouseId(), relabelNo, request)); } + @PostMapping("/{relabelNo}/return-skus/complete") + public ApiResponse completeReturnSku(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSku request) { + Identity identity = identity(user); + return ApiResponse.success(service.completeReturnSku(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/return-skus/complete-total") + public ApiResponse completeReturnSkuTotal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSkuTotal request) { + Identity identity = identity(user); + return ApiResponse.success(service.completeReturnSkuTotal(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + @GetMapping("/{relabelNo}/documents") public ApiResponse> documents(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java index c28d56bbe..a44f212f0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -37,6 +37,16 @@ public final class PdaRelabelRequests { public record SkuVerification(@NotBlank String skuCode, @NotBlank String newSkuCode, @Positive int quantity) { } + public record BulkSku(@NotNull @Positive Long itemId, + @Positive int quantity, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record BulkSkuTotal(@NotBlank @Size(max = 255) String skuCode, + @Positive int quantity, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + public record Skip(@NotNull @Positive Long itemId, @NotBlank @Size(max = 255) String oldLabel, @NotBlank @Size(max = 1000) String reason, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index e00d48949..dc74a0191 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -32,7 +32,7 @@ public final class PdaRelabelResponses { public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, String result, String exceptionCode, String reason, - String operatorName, LocalDateTime occurredAt) { } + int quantity, String operatorName, LocalDateTime occurredAt) { } public record History(String action, String fromStatus, String toStatus, String operatorName, String detail, LocalDateTime createdAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java index a6ece68da..3c435e0bc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java @@ -20,6 +20,7 @@ public class PdaRelabelUnitFact { private String result; private String exceptionCode; private String reason; + private Integer quantity; private String operatorKey; private String operatorName; private LocalDateTime occurredAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java index 1e53232f7..2e016f9b9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -90,6 +90,17 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { } } + @Override + public java.util.List list(String customerId, long taskId) { + MetadataListEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", tenantId(customerId), BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo list failed"); }) + .body(MetadataListEnvelope.class); + if (response == null || !"200".equals(response.code()) || response.data() == null) return java.util.List.of(); + return response.data().stream().map(value -> new UploadedFile(value.fileId(), value.fileName(), value.contentType(), value.sizeBytes() == null ? 0L : value.sizeBytes())).toList(); + } + @Override public DownloadedFile download(String customerId, long taskId, long fileId) { validate(customerId, taskId, fileId); @@ -130,6 +141,7 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { } private record MetadataEnvelope(String code, String message, Metadata data) { } + private record MetadataListEnvelope(String code, String message, java.util.List data) { } private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java index 1cccc2344..84b398f43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java @@ -6,6 +6,7 @@ public interface RelabelPhotoFileGateway { UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); void validate(String customerId, long taskId, long fileId); DownloadedFile download(String customerId, long taskId, long fileId); + java.util.List list(String customerId, long taskId); record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } record DownloadedFile(byte[] content, String fileName, String contentType) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index b6a15ee2e..2f783ae44 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -39,6 +39,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; @@ -238,6 +239,88 @@ public class PdaRelabelService { return result; } + @Transactional + public PdaRelabelResponses.Detail completeReturnSku(String operatorKey, String operatorName, long warehouseId, + String relabelNo, PdaRelabelRequests.BulkSku request) { + UnitCommand command = new UnitCommand(relabelNo, "RETURN_BULK_SKU", request); + PdaRelabelResponses.Detail replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_BULK_SKU", + request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, warehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, request.expectedVersion()); + RelabelItem item = requireItem(application, request.itemId()); + if (!"RETURN_BULK_SKU_LABEL".equals(operationType(item))) throw conflict("当前明细不是退货按件贴标任务"); + int completed = quantity(factList(task).stream().filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED"); + int remaining = value(item.getNotifiedPackageCount()) - completed; + if (request.quantity() > remaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量"); + PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); + fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); + // A bulk SKU is shared by many units/pallets; old_label is uniquely indexed for physical carton scans. + fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId()); + fact.setResult("RELABELLED"); fact.setQuantity(request.quantity()); fact.setOperatorKey(operatorKey); + fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact); + task.setRelabelledPackages(value(task.getRelabelledPackages()) + request.quantity()); + updateTask(task, request.expectedVersion()); + insertHistory(task, "RETURN_BULK_SKU", PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("factId", fact.getId(), "itemId", item.getId(), "skuCode", item.getSkuCode(), "quantity", request.quantity())); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "RETURN_BULK_SKU", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail completeReturnSkuTotal(String operatorKey, String operatorName, long warehouseId, + String relabelNo, PdaRelabelRequests.BulkSkuTotal request) { + UnitCommand command = new UnitCommand(relabelNo, "RETURN_BULK_SKU_TOTAL", request); + PdaRelabelResponses.Detail replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_BULK_SKU_TOTAL", + request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, warehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, request.expectedVersion()); + String skuCode = request.skuCode().trim(); + List skuItems = itemList(application).stream() + .filter(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item))) + .filter(item -> skuCode.equalsIgnoreCase(Objects.toString(item.getSkuCode(), "").trim())) + .toList(); + if (skuItems.isEmpty()) throw notFound("当前换标单没有该退货 SKU"); + List existingFacts = factList(task); + Map completedByItem = new HashMap<>(); + for (RelabelItem item : skuItems) { + completedByItem.put(item.getId(), quantity(existingFacts.stream() + .filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED")); + } + int totalRemaining = skuItems.stream().mapToInt(item -> value(item.getNotifiedPackageCount()) + - completedByItem.getOrDefault(item.getId(), 0)).sum(); + if (request.quantity() > totalRemaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量"); + int remainingToAllocate = request.quantity(); + List> allocations = new ArrayList<>(); + LocalDateTime occurredAt = LocalDateTime.now(); + for (RelabelItem item : skuItems) { + if (remainingToAllocate == 0) break; + int itemRemaining = value(item.getNotifiedPackageCount()) - completedByItem.getOrDefault(item.getId(), 0); + int allocated = Math.min(remainingToAllocate, itemRemaining); + if (allocated <= 0) continue; + PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); + fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); + // Do not write a shared SKU into the unique physical old-label column. + fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId()); + fact.setResult("RELABELLED"); fact.setQuantity(allocated); fact.setOperatorKey(operatorKey); + fact.setOperatorName(operatorName); fact.setOccurredAt(occurredAt); unitFacts.insert(fact); + allocations.add(Map.of("itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""), + "quantity", allocated, "factId", fact.getId())); + remainingToAllocate -= allocated; + } + task.setRelabelledPackages(value(task.getRelabelledPackages()) + request.quantity()); + updateTask(task, request.expectedVersion()); + insertHistory(task, "RETURN_BULK_SKU_TOTAL", PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("skuCode", skuCode, "quantity", request.quantity(), "allocations", allocations)); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "RETURN_BULK_SKU_TOTAL", request.idempotencyKey(), command, result); + return result; + } + @Transactional(readOnly = true) public List documents(long warehouseId, String relabelNo) { RelabelApplication app = requireApplication(relabelNo, warehouseId, false); @@ -311,26 +394,28 @@ public class PdaRelabelService { if (!request.manualCompletion()) bindingGuard.requireBound(application); List detailItems = itemList(application); List facts = factList(task); - boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream() - .filter(fact -> item.getId().equals(fact.getItemId())).count() < value(item.getNotifiedPackageCount())); + boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount())); if ((incomplete || request.manualCompletion()) && !StringUtils.hasText(request.remark())) throw conflict("手动完成必须填写仓库异常作业备注"); if (incomplete && !request.manualCompletion()) throw conflict("仍有未处理箱,请选择手动完成并填写作业备注"); if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) throw conflict("完成换标必须上传 1 至 20 张作业照片"); if (request.manualCompletion() && request.remark().trim().length() > 900) throw conflict("手动作业备注不能超过 900 字"); for (RelabelItem item : detailItems) { - long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count(); + long actual = processedQuantity(facts, item.getId()); if (actual > value(item.getNotifiedPackageCount())) { throw conflict("Every relabel item must have exactly one fact per notified package"); } - for (long i = actual; i < value(item.getNotifiedPackageCount()); i++) { + long missing = value(item.getNotifiedPackageCount()) - actual; + int factCount = "RETURN_BULK_SKU_LABEL".equals(operationType(item)) && missing > 0 ? 1 : Math.toIntExact(missing); + for (int i = 0; i < factCount; i++) { PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId()); fact.setResult("EXCEPTION"); fact.setExceptionCode("MANUAL_COMPLETE"); + fact.setQuantity("RETURN_BULK_SKU_LABEL".equals(operationType(item)) ? Math.toIntExact(missing) : 1); fact.setReason(request.remark()); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact); - task.setExceptionPackages(value(task.getExceptionPackages()) + 1); + task.setExceptionPackages(value(task.getExceptionPackages()) + fact.getQuantity()); } } List photoIds = request.photoFileIds().stream().distinct().toList(); @@ -475,6 +560,7 @@ public class PdaRelabelService { resolvedNewLabel = trim(item.getFbaCartonMark()); } fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(resolvedNewLabel); fact.setResult(resultCode); + fact.setQuantity(1); fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason)); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); try { unitFacts.insert(fact); } @@ -622,15 +708,15 @@ public class PdaRelabelService { item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(), item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(), item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()), - value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), - count(itemFacts, "EXCEPTION"), + value(item.getNotifiedPackageCount()), quantity(itemFacts, "RELABELLED"), quantity(itemFacts, "SKIPPED"), + quantity(itemFacts, "EXCEPTION"), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0), item.getDestinationFbaWarehouseCode()); }).toList(); List factResponses = facts.stream().map(fact -> new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), - fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList(); + fact.getResult(), fact.getExceptionCode(), fact.getReason(), factQuantity(fact), fact.getOperatorName(), fact.getOccurredAt())).toList(); List historyResponses = task == null ? List.of() : histories.selectList( new LambdaQueryWrapper().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId()) .eq(PdaRelabelHistory::getTaskId, task.getId()) @@ -727,6 +813,9 @@ public class PdaRelabelService { private String operationType(RelabelApplication application) { List items = itemList(application); + if (!items.isEmpty() && items.stream().allMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) { + return "RETURN_BULK_SKU_LABEL"; + } boolean sku = items.stream().anyMatch(item -> Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))); boolean carton = items.stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); return sku && carton ? "CARTON_AND_SKU" : sku ? "SKU_LABEL" : "CARTON_LABEL"; @@ -748,6 +837,13 @@ public class PdaRelabelService { private static int count(List facts, String result) { return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count(); } + private static int quantity(List facts, String result) { + return facts.stream().filter(fact -> result.equals(fact.getResult())).mapToInt(PdaRelabelService::factQuantity).sum(); + } + private static int processedQuantity(List facts, Long itemId) { + return facts.stream().filter(fact -> itemId.equals(fact.getItemId())).mapToInt(PdaRelabelService::factQuantity).sum(); + } + private static int factQuantity(PdaRelabelUnitFact fact) { return fact.getQuantity() == null ? 1 : fact.getQuantity(); } private static int value(Integer value) { return value == null ? 0 : value; } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private static String idempotencyKey(String value) { return value == null ? null : value.trim(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 0aa38d523..7869543ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -31,8 +31,10 @@ import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; @@ -70,10 +72,11 @@ import java.util.stream.Collectors; @Service public class RelabelApplicationService implements WalletResultHandler { + public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING"; public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND"; - private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING, + private static final Set STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); private static final Set DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP"); @@ -90,6 +93,7 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private InventoryPositionMapper inventoryPositions; @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonItemMapper returnCartonItems; + @Resource private ReturnOrderMapper returnOrders; @Resource private RelabelDocumentFileGateway documentFiles; @Resource private PdaRelabelTaskMapper pdaTasks; @Resource private RelabelPhotoFileGateway photoFiles; @@ -203,7 +207,7 @@ public class RelabelApplicationService implements WalletResultHandler { if (!StringUtils.hasText(customerId)) throw badRequest("客户不能为空"); RelabelApplication application = applications.lockNo(customerId, relabelNo); if (application == null) throw notFound("Relabel application not found"); - if (!Set.of(PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料"); + if (!Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料"); return application; } @@ -270,9 +274,13 @@ public class RelabelApplicationService implements WalletResultHandler { if (cartonSkus.isEmpty()) continue; String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) .collect(Collectors.joining(",")); + long availablePackages = value(carton.getTotalQuantity()); + if (availablePackages <= 0) { + availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); + } result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), - carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(), + carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(), "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus)); } return result; @@ -347,6 +355,8 @@ public class RelabelApplicationService implements WalletResultHandler { int notified = 0; List sources = new java.util.ArrayList<>(); Set selectedCartonMarks = new HashSet<>(); + Map selectedReturnCartons = new LinkedHashMap<>(); + Map selectedReturnPackages = new LinkedHashMap<>(); for (RelabelRequests.Item item : request.items()) { validateOperation(item); if (item.notifiedPackageCount() > item.totalPackageCount()) { @@ -358,7 +368,11 @@ public class RelabelApplicationService implements WalletResultHandler { throw badRequest("The same carton mark cannot create more than one relabel task"); } if (item.returnCartonId() != null) { - ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId()); + ReturnInventoryCarton carton = selectedReturnCartons.get(item.returnCartonId()); + if (carton == null) { + carton = returnCartons.lockId(item.returnCartonId()); + if (carton != null) selectedReturnCartons.put(item.returnCartonId(), carton); + } if (carton == null || !customerId.equals(carton.getCustomerId()) || !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) { throw conflict("所选退货箱不属于当前客户和操作仓"); @@ -366,8 +380,10 @@ public class RelabelApplicationService implements WalletResultHandler { if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); } - if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) { - throw conflict("退货箱仓库唛头已变化,请重新查询"); + long requestedPackages = selectedReturnPackages.merge(carton.getId(), + (long) item.notifiedPackageCount(), Math::addExact); + if (requestedPackages > value(carton.getTotalQuantity())) { + throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足"); } sources.add(new SourceInventory(item, null, carton)); continue; @@ -438,9 +454,7 @@ public class RelabelApplicationService implements WalletResultHandler { application.setUpdatedAt(now); applications.insert(application); - for (SourceInventory source : sources) { - if (source.returnCarton() == null) continue; - ReturnInventoryCarton carton = source.returnCarton(); + for (ReturnInventoryCarton carton : selectedReturnCartons.values()) { carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton); @@ -530,6 +544,12 @@ public class RelabelApplicationService implements WalletResultHandler { if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); if (next.equals(application.getStatus())) return detail(customerId, relabelNo); validateTransition(application.getStatus(), next); + if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next) + && itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) { + boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId()); + boolean hasSkuFile = StringUtils.hasText(application.getSkuTotalFileId()); + if (hasCartonFile == hasSkuFile) throw conflict("退货贴标审核前必须且只能上传一个箱唛总文件或 SKU 总文件"); + } if ("PROCESSING".equals(next) && !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) { throw conflict("Relabel funds must be frozen before processing"); @@ -541,6 +561,10 @@ public class RelabelApplicationService implements WalletResultHandler { applications.updateById(application); List linkedOrders = tailOrders(application); if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing"); + if (PENDING_PROCESSING.equals(next)) { + application.setFulfillmentStatus("WAIT_RELABEL"); + applications.updateById(application); + } if (CUSTOMER_CONFIRM_PENDING.equals(next)) { application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION"); applications.updateById(application); @@ -586,6 +610,12 @@ public class RelabelApplicationService implements WalletResultHandler { case "REJECTED", "CANCELLED" -> "CANCELLED"; default -> mainStatus; }; + // Reviewing/processing a return relabel must not move the already + // completed inbound order back to assignment or relabel waiting. + if ("RETURN_RELABEL".equals(order.getOrderType()) + && !Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(next)) { + orderStatus = "INBOUND_COMPLETED"; + } order.setCurrentStatus(orderStatus); order.setSubOrderStatus("RELABEL:" + next); order.setRelabelStatus(businessRelabelStatus(next)); @@ -595,6 +625,15 @@ public class RelabelApplicationService implements WalletResultHandler { insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now); } } + ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() + .eq(ReturnOrder::getRelabelApplicationId, application.getId()) + .eq(ReturnOrder::getCustomerId, customerId) + .last("LIMIT 1")); + if (returnOrder != null) { + returnOrder.setRelabelStatus(next); + returnOrder.setUpdatedAt(now); + returnOrders.updateById(returnOrder); + } insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now); return detail(customerId, relabelNo); } @@ -689,7 +728,7 @@ public class RelabelApplicationService implements WalletResultHandler { } List relabelItems = itemList(application.getId()); boolean requiresOutbound = relabelItems.stream() - .anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + .anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item))); reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED"); reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); @@ -721,6 +760,22 @@ public class RelabelApplicationService implements WalletResultHandler { request.remark(), now); } } + ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() + .eq(ReturnOrder::getRelabelApplicationId, application.getId()) + .eq(ReturnOrder::getCustomerId, customerId) + .last("LIMIT 1")); + if (returnOrder != null) { + returnOrder.setRelabelStatus("COMPLETED"); + returnOrder.setUpdatedAt(now); + returnOrders.updateById(returnOrder); + } + for (RelabelItem item : relabelItems) { + if (item.getReturnCartonId() == null) continue; + ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton == null || !Objects.equals(carton.getRelabelApplicationId(), application.getId())) continue; + carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now); + carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + } insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 @@ -739,7 +794,8 @@ public class RelabelApplicationService implements WalletResultHandler { transfer.setRelabelCompletedAt(now); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); - application.setFulfillmentStatus(transferStatus); + application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus) + ? "WAIT_PRE_DISPATCH" : transferStatus); applications.updateById(application); // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); @@ -1057,7 +1113,8 @@ public class RelabelApplicationService implements WalletResultHandler { orderHistories.insert(value); } private static void validateTransition(String from, String to) { - boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) + boolean allowed = PENDING_REVIEW.equals(from) && Set.of(PENDING_PROCESSING, "REJECTED", "CANCELLED").contains(to) + || PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) || "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to) || CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to) || READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to); @@ -1065,7 +1122,9 @@ public class RelabelApplicationService implements WalletResultHandler { } private static String businessRelabelStatus(String workflowStatus) { - if ("PROCESSING".equals(workflowStatus)) return "PROCESSING"; + if (Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", "REJECTED", "CANCELLED").contains(workflowStatus)) { + return workflowStatus; + } return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus) ? "COMPLETED" : PENDING_PROCESSING; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java index 63dc446ae..be761f0a4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java @@ -7,6 +7,7 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @@ -20,12 +21,14 @@ public class RelabelBindingGuard { @Resource private RelabelItemMapper items; @Resource private PdaRelabelHistoryMapper histories; @Resource private InventoryPositionMapper positions; + @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ObjectMapper objectMapper; public void requireBound(RelabelApplication app) { List cartons = items.selectList(new QueryWrapper() .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())) - .stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList(); + .stream().filter(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") + .contains(item.getOperationType())).toList(); if (cartons.isEmpty()) return; Map> bindings = new HashMap<>(); for (PdaRelabelHistory history : histories.selectList(new QueryWrapper() @@ -41,6 +44,13 @@ public class RelabelBindingGuard { } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } } for (RelabelItem item : cartons) { + if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); + if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) + || !"PROCESSING".equals(carton.getRelabelStatus())) + throw conflict("退货箱 " + item.getCartonMark() + " 尚未绑定带目的仓的新托盘,不能完成换标"); + continue; + } var position = item.getSourceInventoryPositionId() == null ? null : positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId()); if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java index 4b348547e..c3547af94 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java @@ -6,17 +6,28 @@ import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests; import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses; import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; @RestController @RequestMapping("/api/pda/v1/last-leg/return-receipts") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class PdaReturnReceiptController { @Resource private ReturnReceiptService service; + @Resource private PalletPhysicalGateway physicalPallets; + @Resource private ReturnInventoryCartonMapper inventoryCartons; + @Resource private ReturnInventoryCartonItemMapper inventoryCartonItems; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper returnOrders; + @Resource private RelabelPhotoFileGateway photoFiles; @PostMapping("/start") public ApiResponse start(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody PdaReturnReceiptRequests.Start request) { return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user), request)); @@ -30,4 +41,61 @@ public class PdaReturnReceiptController { PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); return ApiResponse.success(service.confirm(identity, request)); } + @PostMapping("/{returnNo}/manual-complete") public ApiResponse manualComplete( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, @Valid @RequestBody PdaReturnReceiptRequests.ManualComplete request) { + return ApiResponse.success(service.manualComplete(PdaWarehouseIdentity.require(user), returnNo, request)); + } + public record Pallet(String palletCode, long palletId, String status, String printableHtml) { } + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } + @PostMapping(value = "/{returnNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, + @RequestPart("file") MultipartFile file) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + var order = returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), returnNo); + if (order == null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单"); + if (file == null || file.isEmpty()) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "请选择退货打托照片"); + if (file.getSize() > 10L * 1024 * 1024) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "照片不能超过 10MB"); + if (file.getContentType() == null || !file.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "只能上传图片"); + var uploaded = photoFiles.upload(order.getCustomerId(), order.getId(), user.userId(), file); + return ApiResponse.success(new Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes())); + } + @GetMapping("/{returnNo}/photos/files/{fileId}/download") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String returnNo, @PathVariable long fileId) { + var file = service.downloadPhoto(PdaWarehouseIdentity.require(user), returnNo, fileId); + return org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())) + .header("Cache-Control", "no-store").body(file.content()); + } + @PostMapping("/pallets") public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaReturnReceiptRequests.Pallet request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + if (returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), request.returnNo()) == null) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单"); + } + var pallet = physicalPallets.ensureRelabelPallet(identity.overseasWarehouseId(), + "RETURN:" + request.returnNo() + ":" + request.skuCode() + ":" + System.nanoTime()); + String code = org.springframework.web.util.HtmlUtils.htmlEscape(pallet.palletCode()); + String returnNo = org.springframework.web.util.HtmlUtils.htmlEscape(request.returnNo()); + String sku = org.springframework.web.util.HtmlUtils.htmlEscape(request.skuCode()); + String html = "" + code + "

退货托盘标签

" + code + "
退货单:" + returnNo + "
SKU:" + sku + "
"; + return ApiResponse.success(new Pallet(pallet.palletCode(), pallet.palletId(), pallet.status(), html)); + } + @PostMapping("/pallets/check") public ApiResponse checkPallet(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaReturnReceiptRequests.CheckPallet request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + var order = returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), request.returnNo().trim()); + if (order == null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单"); + var physical = physicalPallets.resolve(request.palletCode().trim()); + if (physical == null || physical.warehouseId() != identity.overseasWarehouseId()) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该托盘"); + } + var inventory = inventoryCartons.listPalletInventory(String.valueOf(identity.overseasWarehouseId()), physical.palletCode()); + if (inventory.isEmpty()) return ApiResponse.success(new PdaReturnReceiptResponses.PalletCheck(physical.palletCode(), true, java.util.List.of(), 0)); + if (inventory.stream().anyMatch(carton -> !order.getReturnNo().equals(carton.getSourceReturnNo()))) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "该托盘已绑定其他退货单,不能用于当前退货单"); + } + java.util.LinkedHashSet skuCodes = new java.util.LinkedHashSet<>(); long quantity = 0; + for (var carton : inventory) for (var item : inventoryCartonItems.listCarton(carton.getId())) { skuCodes.add(item.getSkuCode()); quantity += item.getQuantity() == null ? 0 : item.getQuantity(); } + return ApiResponse.success(new PdaReturnReceiptResponses.PalletCheck(physical.palletCode(), false, java.util.List.copyOf(skuCodes), quantity)); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java new file mode 100644 index 000000000..ef7009596 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.returnreceipt.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses; +import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/return-orders") +@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')") +public class PlatformReturnOrderController { + @Resource private ReturnReceiptService service; + @PostMapping("/{returnNo}/review") + public ApiResponse review(@RequestHeader("X-Customer-Id") String customerId, + @PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Review request) { + return ApiResponse.success(service.review(customerId, returnNo, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java index 4952832b6..d5d6a6d62 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java @@ -12,10 +12,13 @@ public final class PdaReturnReceiptRequests { private PdaReturnReceiptRequests() { } public record Start(@NotBlank @Size(max = 128) String returnNo) { } public record SkuLookup(@NotBlank @Size(max = 128) String skuCode) { } + public record Pallet(@NotBlank @Size(max = 128) String returnNo, @NotBlank @Size(max = 128) String skuCode) { } + public record CheckPallet(@NotBlank @Size(max = 128) String returnNo, @NotBlank @Size(max = 128) String palletCode) { } + public record ManualComplete(@NotBlank @Size(max = 500) String discrepancyReason) { } public record Confirm(@NotBlank @Size(max = 128) String returnNo, @Size(max = 128) String trackingNo, @NotBlank @Size(max = 16) String startedBy, - @Size(max = 128) String cartonMark, + @NotBlank @Size(max = 128) String palletCode, @NotEmpty @Size(max = 500) List<@Valid Item> items, @NotBlank @Size(max = 255) String idempotencyKey) { } public record Item(@NotBlank @Size(max = 128) String skuCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java index eecb4b1c9..d60e7bb1b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java @@ -5,13 +5,16 @@ import java.util.List; public final class PdaReturnReceiptResponses { private PdaReturnReceiptResponses() { } - public record Start(String returnNo, String status, List expectedItems) { } + public record Start(String returnNo, String status, List expectedItems, List completedPallets, List photos) { } + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } + public record CompletedPallet(String palletCode, String skuCode, long quantity, String receiptBatchNo) { } + public record PalletCheck(String palletCode, boolean empty, List skuCodes, long quantity) { } public record SkuCandidates(String skuCode, List candidates) { } public record Candidate(String returnNo, String customerId, String customerReferenceNo, String status, long skuExpectedQuantity, long skuReceivedQuantity, long orderExpectedQuantity, long orderReceivedQuantity) { } public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { } - public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String cartonMark, String orderStatus, + public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String palletCode, String orderStatus, boolean fullyMatched, long expectedQuantity, long receivedQuantity, LocalDateTime signedAt) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java index 889d7b642..f577a1e83 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java @@ -28,8 +28,13 @@ public final class ReturnOrderRequests { String trackingNo, String skuCode, String status) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } - public record RelabelCreate(@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, + public record RelabelCreate(@NotEmpty @Size(max = 500) List<@Valid RelabelItem> items, + @NotBlank @Size(max = 16) String fileScope, + @NotBlank @Size(max = 128) String destinationFbaWarehouseId, + @NotBlank @Size(max = 128) String destinationFbaWarehouseCode, @NotBlank @Size(max = 255) String idempotencyKey, - @Size(max = 128) String contactName, @Size(max = 64) String contactPhone, - @Size(max = 32) String deliveryMethod, @Size(max = 1000) String remark) {} + @Size(max = 1000) String cartonRemark) {} + public record RelabelItem(@NotBlank @Size(max = 128) String skuCode, + @Positive long quantity) {} + public record Review(@NotBlank String decision, @Size(max = 500) String remark) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java index bbc57322a..8011f4288 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java @@ -8,14 +8,17 @@ public final class ReturnOrderResponses { public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId, String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status, int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel, - String relabelNo, String relabelStatus, LocalDateTime createdAt) { } + String relabelNo, String relabelStatus, String discrepancyReason, LocalDateTime createdAt) { } public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { } - public record Detail(Summary order, String remark, List items, List receipts) { } - public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy, + public record Detail(Summary order, String remark, List items, List receipts, List pallets, List photos) { } + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } + public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List items) { } + public record PalletItem(String skuCode, String productNameZh, long quantity) { } + public record Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy, long receivedQuantity, String operatorName, LocalDateTime signedAt) { } public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status, long totalQuantity, List items, String relabelStatus) { } public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo, - List cartonMarks, String status) { } + List skuCodes, String status) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java index 95e724962..27a72cffb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java @@ -18,6 +18,7 @@ public class ReturnInventoryCarton { private Long sourceReceiptId; private String sourceBatchNo; private String sourceReturnNo; + private String palletCode; private String cartonMark; private String fbaCartonMark; private String destinationWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java index 8b9175362..467823d4b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java @@ -28,6 +28,8 @@ public class ReturnOrder { private Long expectedQuantity; private Long receivedQuantity; private String remark; + private String discrepancyReason; + private LocalDateTime manuallyCompletedAt; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceipt.java index 449d7a130..9732fbea8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceipt.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceipt.java @@ -15,6 +15,7 @@ public class ReturnReceipt { private String customerId; private String receiptNo; private String receiptBatchNo; + private String palletCode; private String cartonMark; private String trackingNo; private String boundBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java index 17807822d..e636fb9fb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; @Mapper @@ -35,9 +36,25 @@ public interface ReturnInventoryCartonMapper extends BaseMapper#{remaining}") + int releasePartial(@Param("id") long id, @Param("customerId") String customerId, + @Param("applicationId") long applicationId, @Param("remaining") long remaining); + @Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1") ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId); + @Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' ORDER BY c.id LIMIT 1 FOR UPDATE") + ReturnInventoryCarton findPalletSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); + + @Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id LIMIT 1") + ReturnInventoryCarton findPalletSkuForCheck(@Param("customerId") String ignoredCustomerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); + + @Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id") + List listPalletInventory(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); + @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1") ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptItemMapper.java index e8d437be1..a20a99f67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptItemMapper.java @@ -3,6 +3,12 @@ package com.baiyue.lastleg.returnreceipt.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; @Mapper -public interface ReturnReceiptItemMapper extends BaseMapper { } +public interface ReturnReceiptItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_receipt_item WHERE receipt_id=#{receiptId} ORDER BY id") + List listReceipt(@Param("receiptId") long receiptId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 02460fc85..d8b4bfa9f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -63,6 +63,9 @@ public class ReturnInventoryService { public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt, String requestedMark, List lines) { if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空"); + if (lines.size() != 1) throw conflict("一个签收批次只能签收一个 SKU"); + String normalizedPalletCode = required(receipt.getPalletCode(), "托盘码不能为空"); + required(lines.get(0).skuCode(), "SKU 不能为空"); String cartonMark = text(requestedMark); long cartonId = IdWorker.getId(); if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); @@ -73,6 +76,7 @@ public class ReturnInventoryService { carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId()); carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark); + carton.setPalletCode(normalizedPalletCode); carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now); cartons.insert(carton); for (ReceiptLine line : lines) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index e2c97cf83..609534dc7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -25,6 +25,7 @@ import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.entity.RelabelItem; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; @@ -55,6 +56,7 @@ import java.util.Set; @Service public class ReturnReceiptService { public static final String PENDING_RECEIPT = "PENDING_RECEIPT"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; public static final String COMPLETED = "COMPLETED"; @@ -71,6 +73,8 @@ public class ReturnReceiptService { @Resource private ReturnInventoryCartonItemMapper cartonItems; @Resource private RelabelApplicationMapper relabelApplications; @Resource private RelabelItemMapper relabelItems; + @Resource private com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway photoFiles; + @Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator; @Transactional public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) { @@ -92,7 +96,7 @@ public class ReturnReceiptService { order.setWarehouseContactName(text(request.warehouseContactName())); order.setWarehouseContactPhone(text(request.warehouseContactPhone())); order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId())); - order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_RECEIPT); + order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW); order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L); order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash); order.setCreatedBy(text(operatorId)); order.setCreatedAt(now); order.setUpdatedAt(now); @@ -121,8 +125,8 @@ public class ReturnReceiptService { tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo()); tail.setSourceOrderId(String.valueOf(returnOrder.getId())); tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId()); - tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_BINDING" : "RETURN_RELABEL:NOT_REQUIRED"); - tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED"); + tail.setCurrentStatus(PENDING_REVIEW); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_REVIEW" : "RETURN_RELABEL:NOT_REQUIRED"); + tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? PENDING_REVIEW : "NOT_REQUIRED"); tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo()); tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now); tail.setSourcePayloadHash(returnOrder.getRequestHash()); @@ -132,6 +136,30 @@ public class ReturnReceiptService { orders.updateById(returnOrder); } + @Transactional + public ReturnOrderResponses.Detail updatePending(String customerId, String returnNo, ReturnOrderRequests.Create request) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货预报不存在"); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以修改"); + Set skus = new LinkedHashSet<>(); long expected = 0; + for (ReturnOrderRequests.Item input : request.items()) { if (!skus.add(norm(input.skuCode()))) throw badRequest("同一退货单不能重复预报相同 SKU"); expected = Math.addExact(expected, input.expectedQuantity()); } + order.setOverseasWarehouseId(text(request.overseasWarehouseId())); order.setWarehouseContactName(text(request.warehouseContactName())); + order.setWarehouseContactPhone(text(request.warehouseContactPhone())); order.setCustomerReferenceNo(text(request.customerReferenceNo())); + order.setNeedRelabel(request.needRelabel()); order.setRemark(text(request.remark())); order.setExpectedSkuTypes(skus.size()); order.setExpectedQuantity(expected); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); + orderItems.delete(new LambdaQueryWrapper().eq(ReturnOrderItem::getReturnOrderId, order.getId())); + for (ReturnOrderRequests.Item input : request.items()) { ReturnOrderItem item = new ReturnOrderItem(); item.setId(IdWorker.getId()); item.setReturnOrderId(order.getId()); item.setCustomerId(customerId); item.setSkuCode(text(input.skuCode())); item.setProductNameZh(text(input.productNameZh())); item.setExpectedQuantity(input.expectedQuantity()); item.setReceivedQuantity(0L); item.setCreatedAt(LocalDateTime.now()); item.setUpdatedAt(LocalDateTime.now()); orderItems.insert(item); } + return detail(customerId, returnNo); + } + + @Transactional + public ReturnOrderResponses.Detail cancelPending(String customerId, String returnNo) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货预报不存在"); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以取消"); + order.setStatus("CANCELLED"); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); + return detail(customerId, returnNo); + } + @Transactional(readOnly = true) public PageResult page(String customerId, ReturnOrderRequests.Query query) { LambdaQueryWrapper w = new LambdaQueryWrapper() @@ -166,11 +194,56 @@ public class ReturnReceiptService { public ReturnOrderResponses.Detail detail(String customerId, String returnNo) { ReturnOrder order = orders.findNo(customerId, text(returnNo)); if (order == null) throw notFound("退货单不存在"); - List items = orderItems.listOrder(order.getId()).stream().map(this::item).toList(); - List receiptViews = receipts.listOrder(order.getId()).stream().map(receipt -> - new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), receipt.getTrackingNo(), + List orderLines = orderItems.listOrder(order.getId()); + List items = orderLines.stream().map(this::item).toList(); + List orderReceipts = receipts.listOrder(order.getId()); + List receiptViews = orderReceipts.stream().map(receipt -> + new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(), receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList(); - return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews); + Map> palletItems = new LinkedHashMap<>(); + Map productNames = new LinkedHashMap<>(); + for (ReturnOrderItem line : orderLines) productNames.put(line.getSkuCode(), line.getProductNameZh()); + for (ReturnReceipt receipt : orderReceipts) { + if (text(receipt.getPalletCode()) == null) continue; + Map bySku = palletItems.computeIfAbsent(receipt.getPalletCode(), ignored -> new LinkedHashMap<>()); + for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) bySku.merge(line.getSkuCode(), + new ReturnOrderResponses.PalletItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity())), + (left, right) -> new ReturnOrderResponses.PalletItem(left.skuCode(), text(left.productNameZh()) == null ? right.productNameZh() : left.productNameZh(), left.quantity() + right.quantity())); + } + List palletViews = palletItems.entrySet().stream().map(entry -> { + List palletLines = List.copyOf(entry.getValue().values()); + return new ReturnOrderResponses.PalletDetail(entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines); + }).toList(); + List photos = photoList(order).stream() + .map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList(); + return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews, palletViews, photos); + } + + public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String returnNo, long fileId) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货单不存在"); + return photoFiles.download(order.getCustomerId(), order.getId(), fileId); + } + + @Transactional + public ReturnOrderResponses.Detail review(String customerId, String returnNo, ReturnOrderRequests.Review request) { + ReturnOrder order = orders.findNo(customerId, text(returnNo)); + if (order == null) throw notFound("退货预报不存在"); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以审核"); + String decision = norm(request.decision()); + if (!Set.of("APPROVE", "REJECT").contains(decision)) throw badRequest("decision 必须为 APPROVE 或 REJECT"); + if ("REJECT".equals(decision) && text(request.remark()) == null) throw badRequest("审核驳回必须填写原因"); + order.setStatus("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED"); + if ("REJECT".equals(decision)) order.setRemark(text(request.remark())); + order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); + LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId()); + if (tail != null) { + tail.setCurrentStatus("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED"); + tail.setSubOrderStatus("RETURN_RELABEL:" + ("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED")); + tail.setRelabelStatus("APPROVE".equals(decision) && Boolean.TRUE.equals(order.getNeedRelabel()) ? "WAIT_INBOUND" : ("APPROVE".equals(decision) ? "NOT_REQUIRED" : "REJECTED")); + tail.setUpdatedAt(LocalDateTime.now()); lastlegOrders.updateById(tail); + } + return detail(customerId, returnNo); } @Transactional(readOnly = true) @@ -195,44 +268,119 @@ public class ReturnReceiptService { ReturnOrder order = orders.findNo(customerId, text(returnNo)); if (order == null) throw notFound("退货单不存在"); if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); + String fileScope = norm(request.fileScope()); + if (!Set.of("CARTON_TOTAL", "SKU_TOTAL").contains(fileScope)) throw badRequest("贴标总文件类型必须为 CARTON_TOTAL 或 SKU_TOTAL"); String key = text(request.idempotencyKey()); + String requestHash = hash(request); RelabelApplication replay = relabelApplications.findKey(customerId, key); - if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), order.getReturnNo(), request.cartonMarks(), replay.getStatus()); - List selected = new ArrayList<>(); + if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), + order.getReturnNo(), request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), replay.getStatus()); + if (text(order.getRelabelNo()) != null) throw conflict("当前退货单已经生成贴标申请"); + var destination = fbaWarehouseValidator.required(request.destinationFbaWarehouseId(), request.destinationFbaWarehouseCode(), null); + Map receivedBySku = orderItems.lockOrder(order.getId()).stream() + .collect(java.util.stream.Collectors.toMap(item -> norm(item.getSkuCode()), item -> item)); Set seen = new LinkedHashSet<>(); - for (String mark : request.cartonMarks()) { - String normalized = text(mark); - if (!seen.add(normalized.toUpperCase(java.util.Locale.ROOT))) throw badRequest("箱唛不能重复"); - ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, normalized); - if (carton == null || !order.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw notFound("退货箱不存在: " + normalized); - if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱不可申请换标: " + normalized); - if (carton.getSourceReturnNo() == null || !java.util.Arrays.asList(carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())) throw conflict("退货箱不属于当前退货单: " + normalized); - selected.add(cartons.lockMark(normalized)); + long totalQuantity = 0; + for (ReturnOrderRequests.RelabelItem requested : request.items()) { + String sku = norm(requested.skuCode()); + if (!seen.add(sku)) throw badRequest("同一贴标申请不能重复选择 SKU: " + requested.skuCode()); + ReturnOrderItem received = receivedBySku.get(sku); + if (received == null || requested.quantity() > value(received.getReceivedQuantity())) { + throw conflict("SKU " + requested.skuCode() + " 的贴标数量超过当前退货单实收数量"); + } + totalQuantity = Math.addExact(totalQuantity, requested.quantity()); } long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now(); String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId); - LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId())); - tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo); - tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus("RELABEL:RETURN_CARTON"); tail.setRelabelStatus("RETURN_CARTON"); tail.setPackageCount(selected.size()); - tail.setProductQuantity(Math.toIntExact(selected.stream().mapToLong(c -> value(c.getTotalQuantity())).sum())); tail.setCreatedAt(now); tail.setUpdatedAt(now); lastlegOrders.insert(tail); - RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); - app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setContactName(text(request.contactName())); app.setContactPhone(text(request.contactPhone())); app.setDeliveryMethod(text(request.deliveryMethod()) == null ? "TRUCK" : text(request.deliveryMethod())); - app.setFulfillmentStatus("WAIT_RELABEL"); app.setRemark(text(request.remark())); app.setStatus("PENDING_PROCESSING"); app.setItemCount(selected.size()); app.setTotalPackageCount(selected.size()); app.setNotifiedPackageCount(selected.size()); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(hash(request)); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app); - for (ReturnInventoryCarton carton : selected) { - for (ReturnInventoryCartonItem ci : cartonItems.listCarton(carton.getId())) { - RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(ci.getSkuCode()); item.setProductNameZh(ci.getProductNameZh()); item.setTotalPackageCount(1); item.setNotifiedPackageCount(1); item.setShipmentNo(order.getReturnNo()); item.setCartonMark(carton.getCartonMark()); item.setFbaCartonMark(carton.getFbaCartonMark()); item.setOperationType("CARTON_LABEL"); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setCreatedAt(now); relabelItems.insert(item); - } - carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton); + LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId()); + if (tail == null) { + tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId())); + tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); + tail.setSourceInboundId("RETURN_RELABEL:" + relabelNo); + tail.setSourceEventId("RETURN_RELABEL:" + relabelNo); + tail.setSourceLoadingPlanId("RETURN_RELABEL"); + tail.setSourcePayloadHash("sha256:" + requestHash); + tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo); + tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("WAIT_RELABEL"); + tail.setCreatedAt(now); tail.setPackageCount(Math.toIntExact(totalQuantity)); + tail.setProductQuantity(Math.toIntExact(totalQuantity)); tail.setUpdatedAt(now); lastlegOrders.insert(tail); } - order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_PROCESSING"); orders.updateById(order); - return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), selected.stream().map(ReturnInventoryCarton::getCartonMark).toList(), app.getStatus()); + // A return relabel application belongs to the already received return tail-leg order. + // Do not create a second independent tail order or overwrite the inbound status. + tail.setRelabelOrderNo(relabelNo); + tail.setRelabelStatus("PENDING_REVIEW"); + tail.setSubOrderStatus("RETURN_RELABEL:PENDING_REVIEW"); + tail.setCurrentStatus("RETURN_RELABEL".equals(tail.getOrderType()) ? "INBOUND_COMPLETED" : tail.getCurrentStatus()); + tail.setPackageCount(Math.toIntExact(totalQuantity)); + tail.setProductQuantity(Math.toIntExact(totalQuantity)); + tail.setUpdatedAt(now); + lastlegOrders.updateById(tail); + RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); + app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); + app.setLabelDeadline(now.plusDays(7)); + app.setContactName(text(order.getWarehouseContactName())); app.setContactPhone(text(order.getWarehouseContactPhone())); + app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app); + List availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId()); + Map selectedCartons = new LinkedHashMap<>(); + for (ReturnOrderRequests.RelabelItem requested : request.items()) { + ReturnOrderItem source = receivedBySku.get(norm(requested.skuCode())); + long remaining = requested.quantity(); + for (ReturnInventoryCarton candidate : availableCartons) { + if (remaining == 0) break; + if (!order.getReturnNo().equals(candidate.getSourceReturnNo())) continue; + long skuQuantity = cartonItems.listCarton(candidate.getId()).stream() + .filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode()))) + .mapToLong(line -> value(line.getQuantity())).sum(); + if (skuQuantity <= 0) continue; + ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId())); + if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) + throw conflict("退货箱已被其他换标申请占用,请刷新后重试"); + long allocated = Math.min(remaining, skuQuantity); + RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); + item.setSkuCode(source.getSkuCode()); + item.setProductNameZh(text(source.getProductNameZh()) == null ? source.getSkuCode() : text(source.getProductNameZh())); + item.setTotalPackageCount(Math.toIntExact(allocated)); item.setNotifiedPackageCount(Math.toIntExact(allocated)); + item.setShipmentNo(order.getReturnNo()); item.setLabelRequirement(text(request.cartonRemark())); + item.setDestinationWarehouseId(String.valueOf(destination.id())); item.setDestinationFbaWarehouseCode(destination.code()); + item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setReturnCartonId(carton.getId()); + item.setCartonMark(carton.getCartonMark()); item.setSourceCartonMark(carton.getCartonMark()); + item.setCreatedAt(now); relabelItems.insert(item); + remaining -= allocated; + } + if (remaining > 0) throw conflict("退货 SKU 可用实收数量不足: " + requested.skuCode()); + } + for (ReturnInventoryCarton carton : selectedCartons.values()) { + carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); + carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton); + } + order.setRelabelApplicationId(appId); order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_REVIEW"); orders.updateById(order); + return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), + request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), app.getStatus()); } @Transactional(readOnly = true) public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) { ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo()); + List completedPallets = receipts.listOrder(order.getId()).stream() + .filter(receipt -> text(receipt.getPalletCode()) != null) + .collect(java.util.stream.Collectors.toMap(ReturnReceipt::getPalletCode, receipt -> { + ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId()); + ReturnInventoryCartonItem item = carton == null ? null : cartonItems.listCarton(carton.getId()).stream().findFirst().orElse(null); + return new PdaReturnReceiptResponses.CompletedPallet(receipt.getPalletCode(), item == null ? "" : item.getSkuCode(), + item == null ? 0 : value(item.getQuantity()), receipt.getReceiptBatchNo()); + }, (left, right) -> new PdaReturnReceiptResponses.CompletedPallet(left.palletCode(), left.skuCode(), + left.quantity() + right.quantity(), right.receiptBatchNo()), java.util.LinkedHashMap::new)) + .values().stream().toList(); + List photos = photoList(order).stream() + .map(file -> new PdaReturnReceiptResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList(); return new PdaReturnReceiptResponses.Start(order.getReturnNo(), order.getStatus(), orderItems.listOrder(order.getId()).stream() - .filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList()); + .filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList(), completedPallets, photos); + } + + @Transactional(readOnly = true) + public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(PdaWarehouseIdentity identity, String returnNo, long fileId) { + ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), returnNo); + return photoFiles.download(order.getCustomerId(), order.getId(), fileId); } @Transactional(readOnly = true) @@ -255,6 +403,16 @@ public class ReturnReceiptService { return new PdaReturnReceiptResponses.SkuCandidates(skuCode, candidates); } + @Transactional + public ReturnOrderResponses.Detail manualComplete(PdaWarehouseIdentity identity, String returnNo, PdaReturnReceiptRequests.ManualComplete request) { + ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), returnNo); + if (value(order.getReceivedQuantity()) >= value(order.getExpectedQuantity())) throw conflict("退货单已按预期数量签收完成,不需要手动完结"); + order.setStatus("COMPLETED_WITH_DISCREPANCY"); order.setDiscrepancyReason(text(request.discrepancyReason())); + order.setManuallyCompletedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); + syncReturnTail(order, "INBOUND_COMPLETED", true); + return detail(order.getCustomerId(), order.getReturnNo()); + } + @Transactional public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) { String key = text(request.idempotencyKey()); @@ -289,6 +447,7 @@ public class ReturnReceiptService { receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId)); receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId)); receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED"); + receipt.setPalletCode(text(request.palletCode())); receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey()); receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash); receipt.setSignedAt(now); receipt.setCreatedAt(now); @@ -315,13 +474,20 @@ public class ReturnReceiptService { left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(), Math.addExact(left.quantity(), right.quantity()))); } - receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, request.cartonMark(), + if (allocations.stream().map(allocation -> allocation.item().getSkuCode()).distinct().count() != 1) { + throw conflict("一个签收批次只能签收一个 SKU"); + } + receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, text(request.palletCode()), new ArrayList<>(inventoryLines.values())).getCartonMark()); receipts.updateById(receipt); List finalItems = orderItems.listOrder(order.getId()); long received = finalItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum(); boolean completed = finalItems.stream().allMatch(item -> value(item.getReceivedQuantity()) == value(item.getExpectedQuantity())); + if (completed && photoFiles != null && photoList(order).isEmpty()) { + throw conflict("本次将完成签收,请先拍摄并上传退货打托照片"); + } order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order); + syncReturnTail(order, completed ? "INBOUND_COMPLETED" : "PARTIALLY_RECEIVED", completed || received > 0); ReturnReceiptBatch batch = new ReturnReceiptBatch(); batch.setId(IdWorker.getId()); batch.setReturnOrderId(order.getId()); batch.setReceiptId(receiptId); batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo()); @@ -330,6 +496,21 @@ public class ReturnReceiptService { return confirmed(order, receipt, completed); } + private void syncReturnTail(ReturnOrder order, String currentStatus, boolean inboundStarted) { + if (order.getLastlegOrderId() == null) return; + LastlegOrder tail = lastlegOrders.selectById(order.getLastlegOrderId()); + if (tail == null) return; + tail.setCurrentStatus(currentStatus); + tail.setSubOrderStatus("RETURN_RELABEL:" + currentStatus); + tail.setRelabelStatus(Boolean.TRUE.equals(order.getNeedRelabel()) && inboundStarted ? "PENDING_PROCESSING" : "NOT_REQUIRED"); + tail.setUpdatedAt(LocalDateTime.now()); + lastlegOrders.updateById(tail); + } + + private List photoList(ReturnOrder order) { + return photoFiles == null ? List.of() : photoFiles.list(order.getCustomerId(), order.getId()); + } + private ReturnOrder activeOrder(String warehouseId, String returnNo) { ReturnOrder order = orders.findActiveNo(warehouseId, text(returnNo)); if (order == null) throw notFound("当前仓没有待签收或签收中的退货单"); @@ -367,13 +548,13 @@ public class ReturnReceiptService { return confirmed(order, receipt, COMPLETED.equals(order.getStatus())); } private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) { - return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), + return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt()); } private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(), order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(), order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()), - value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getCreatedAt()); } + value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getDiscrepancyReason(), order.getCreatedAt()); } private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(), item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); } private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java index 5dd39f930..ae1f7364f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java @@ -33,7 +33,7 @@ public class FbaWarehouseValidator { || !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status()) || !same(requestedCode, warehouse.warehouseCode()) - || !same(requestedName, warehouse.warehouseName())) { + || !sameIfProvided(requestedName, warehouse.warehouseName())) { throw invalid(); } String region = firstRegion(response.data().addresses()); @@ -69,6 +69,10 @@ public class FbaWarehouseValidator { && requested.trim().equals(actual.trim()); } + private boolean sameIfProvided(String requested, String actual) { + return !StringUtils.hasText(requested) || same(requested, actual); + } + private String firstRegion(List addresses) { if (addresses == null) return null; return addresses.stream().filter(Objects::nonNull) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 2d5cec2a7..ac35f3220 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -478,6 +478,14 @@ public class WarehouseTransferService { orderDetails.updateById(detail); } syncInboundPlanDestination(customerId, transfer); + if (StringUtils.hasText(transfer.getRelabelNo())) { + RelabelApplication relabel = relabelApplications.lockNo(customerId, transfer.getRelabelNo()); + if (relabel != null && "WAIT_FBA_DESTINATION".equals(relabel.getFulfillmentStatus())) { + relabel.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + relabel.setUpdatedAt(now); + relabelApplications.updateById(relabel); + } + } insertOrderHistory(customerId, order.getId(), order.getCurrentStatus(), order.getCurrentStatus(), transferNo, "Warehouse transfer destination bound by " + trim(operatorId), now); results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo)); @@ -1160,7 +1168,7 @@ public class WarehouseTransferService { throw conflict("Source inventory balance is missing for warehouse transfer receipt"); } InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, - new InventoryCommandRequests.StockIn(customerId, null, + new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(), request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()), trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()), request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 81285ffcb..fd8bfe12e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1957,6 +1957,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + quantity INT NOT NULL DEFAULT 1 COMMENT '本次事实处理件数,箱级作业固定为1', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', @@ -1965,6 +1966,8 @@ CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) ) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_pda_unit_fact' AND column_name='quantity'), 'SELECT 1', 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason')); +PREPARE ll_relabel_fact_quantity_stmt FROM @ddl; EXECUTE ll_relabel_fact_quantity_stmt; DEALLOCATE PREPARE ll_relabel_fact_quantity_stmt; CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', @@ -2616,6 +2619,8 @@ SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN trac PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter; EXECUTE ll_return_receipt_alter_stmt; DEALLOCATE PREPARE ll_return_receipt_alter_stmt; +SET @ll_return_receipt_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''本次签收绑定的托盘码'' AFTER receipt_batch_no')); +PREPARE ll_return_receipt_add_pallet_stmt FROM @ll_return_receipt_add_pallet; EXECUTE ll_return_receipt_add_pallet_stmt; DEALLOCATE PREPARE ll_return_receipt_add_pallet_stmt; CREATE TABLE IF NOT EXISTS ll_return_receipt_item ( id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量', PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0) @@ -2631,15 +2636,17 @@ PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛', id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED')) + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')) ) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; +SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no')); +PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt; -- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED')); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no')); PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt; ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔'; @@ -2679,3 +2686,8 @@ CREATE TABLE IF NOT EXISTS ll_container_loading_evidence ( UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type), UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id) ) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason VARCHAR(500) NULL COMMENT ''手动完结差异原因'' AFTER remark')); +PREPARE ll_return_order_discrepancy_stmt FROM @ddl; EXECUTE ll_return_order_discrepancy_stmt; DEALLOCATE PREPARE ll_return_order_discrepancy_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='manually_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at DATETIME(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason')); +PREPARE ll_return_order_manual_completed_stmt FROM @ddl; EXECUTE ll_return_order_manual_completed_stmt; DEALLOCATE PREPARE ll_return_order_manual_completed_stmt; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 2d119917d..679162bc0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -66,6 +66,40 @@ class DeliveryCompletedOutboxServiceTest { verifyNoInteractions(mapper); } + @Test + void skipsWarehouseTransferWithoutDomesticSourceOrder() { + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService(); + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule())); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(2L); delivery.setDeliveryNo("DL-WT"); delivery.setVersion(1L); + LastlegOrder transfer = new LastlegOrder(); + transfer.setId(22L); transfer.setOrderType("WAREHOUSE_TRANSFER"); + transfer.setCustomerId("88"); transfer.setSourceInboundId("WAREHOUSE_TRANSFER:WT-1"); + + service.publish(delivery, List.of(transfer), Instant.parse("2026-09-15T08:00:00Z")); + + verifyNoInteractions(mapper); + } + + @Test + void skipsOverseasLocalOrderWithoutDomesticSourceOrder() { + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService(); + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule())); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(3L); delivery.setDeliveryNo("DL-LOCAL"); delivery.setVersion(1L); + LastlegOrder local = new LastlegOrder(); + local.setId(23L); local.setOrderType("RETURN_RELABEL"); local.setSourceRegion("CUSTOMER"); + local.setCustomerId("88"); local.setSourceInboundId("RETURN-1"); + + service.publish(delivery, List.of(local), Instant.parse("2026-09-15T08:00:00Z")); + + verifyNoInteractions(mapper); + } + private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { LastlegOrder value = new LastlegOrder(); value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java index f074a326d..c621ded0d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.palletops.controller; +import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -8,6 +10,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import java.lang.reflect.Method; +import java.util.List; import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; @@ -35,4 +38,21 @@ class PdaPalletOperationControllerContractTest { assertFalse(sql.contains("<")); assertFalse(sql.contains(">")); } + + @Test void relabelSourceAndResolvedPalletSerializeSnowflakeIdsAsStrings() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + var source = new PalletOperationResponses.RelabelSource(2100204001227157507L, 2L, + 2100203890682015746L, "PLT-1", "LOC-1", "IN-1", 1L, 1L, List.of("C-1")); + var content = new PalletOperationResponses.PalletContent(2100203890682015746L, "PLT-1", + "OCCUPIED", 1, 2092783373163335682L, "LOC-1", null, 1L, List.of()); + + assertEquals("2100204001227157507", mapper.readTree(mapper.writeValueAsString(source)) + .path("positionId").textValue()); + assertEquals("2100203890682015746", mapper.readTree(mapper.writeValueAsString(source)) + .path("palletId").textValue()); + assertEquals("2100203890682015746", mapper.readTree(mapper.writeValueAsString(content)) + .path("palletId").textValue()); + assertEquals("2092783373163335682", mapper.readTree(mapper.writeValueAsString(content)) + .path("locationId").textValue()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java index a2ea91b5f..fbed131ed 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java @@ -9,6 +9,7 @@ import org.springframework.web.client.RestClient; import org.springframework.web.server.ResponseStatusException; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @@ -37,4 +38,23 @@ class HttpPalletPhysicalGatewayTest { .hasMessageContaining("404"); server.verify(); } + + @Test + void resolveIgnoresNewWarehousePalletMetadata() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-1")) + .andRespond(withSuccess(""" + {"code":"200","message":"success","data":{"palletId":"30","palletCode":"PAL-1", + "warehouseId":"88","locationId":"40","status":"IDLE","fbaWarehouseCode":"XLX1", + "unitPrice":1.00,"version":2,"locationType":"VIRTUAL","locationStatus":"ACTIVE", + "reservable":true,"reservationBlockReason":null}, + "timestamp":"2026-09-19T03:18:35.283336500Z"} + """, MediaType.APPLICATION_JSON)); + + var result = gateway.resolve("PAL-1"); + + assertThat(result.palletId()).isEqualTo(30L); + assertThat(result.palletCode()).isEqualTo("PAL-1"); + assertThat(result.fbaWarehouseCode()).isEqualTo("XLX1"); + server.verify(); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index 7743d3207..6ff734011 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -18,6 +18,11 @@ import com.baiyue.lastleg.relabel.entity.RelabelItem; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -62,6 +67,9 @@ class PdaPalletOperationServiceTest { @Mock private RelabelApplicationMapper relabelApplications; @Mock private RelabelItemMapper relabelItems; @Mock private RelabelPalletMutationService relabelPalletMutations; + @Mock private ReturnInventoryCartonMapper returnCartons; + @Mock private ReturnInventoryCartonItemMapper returnCartonItems; + @Mock private ReturnInventoryBalanceMapper returnBalances; private PdaPalletOperationService service; @BeforeEach void setUp() { @@ -75,10 +83,44 @@ class PdaPalletOperationServiceTest { ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); ReflectionTestUtils.setField(service, "relabelItems", relabelItems); ReflectionTestUtils.setField(service, "relabelPalletMutations", relabelPalletMutations); + ReflectionTestUtils.setField(service, "returnCartons", returnCartons); + ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems); + ReflectionTestUtils.setField(service, "returnBalances", returnBalances); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); service.configureTransactions(new NoOpTransactionManager()); } + @Test void partialReturnRelabelKeepsUnlabelledRemainderOnOriginalPallet() { + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); app.setRelabelNo("RL-1"); + RelabelItem item = new RelabelItem(); item.setId(701L); item.setSkuCode("SKU-1"); + item.setReturnCartonId(801L); item.setDestinationFbaWarehouseCode("XLX6"); + ReturnInventoryCarton original = new ReturnInventoryCarton(); original.setId(801L); + original.setCustomerId("100"); original.setOverseasWarehouseId("88"); + original.setTotalQuantity(100L); original.setPalletCode("OLD"); + ReturnInventoryCartonItem originalLine = new ReturnInventoryCartonItem(); + originalLine.setId(901L); originalLine.setSkuCode("SKU-1"); originalLine.setQuantity(100L); + when(relabelItems.selectList(any())).thenReturn(List.of(item)); + when(returnCartonItems.lockCarton(801L)).thenReturn(List.of(originalLine)); + when(returnCartonItems.updateById(any(ReturnInventoryCartonItem.class))).thenReturn(1); + when(returnCartons.releasePartial(801L, "100", 600L, 70L)).thenReturn(1); + when(returnCartons.insert(any(ReturnInventoryCarton.class))).thenReturn(1); + when(returnCartonItems.insert(any(ReturnInventoryCartonItem.class))).thenReturn(1); + when(returnBalances.adjustCartonQuantity("100", "88", "SKU-1", 1)).thenReturn(1); + when(relabelItems.updateById(any(RelabelItem.class))).thenReturn(1); + + ReflectionTestUtils.invokeMethod(service, "splitReturnRelabelCarton", app, item, original, + pallet(30L, "NEW-XLX6", 88L, 0), 30L); + + assertEquals(70L, originalLine.getQuantity()); + var moved = ArgumentCaptor.forClass(ReturnInventoryCarton.class); + verify(returnCartons).insert(moved.capture()); + assertEquals(30L, moved.getValue().getTotalQuantity()); + assertEquals("NEW-XLX6", moved.getValue().getPalletCode()); + assertEquals("XLX6", moved.getValue().getDestinationWarehouseCode()); + assertEquals(moved.getValue().getId(), item.getReturnCartonId()); + verify(returnBalances).adjustCartonQuantity("100", "88", "SKU-1", 1); + } + @Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() { when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1)); assertThrows(ResponseStatusException.class, () -> service.resolve(identity(), @@ -100,7 +142,7 @@ class PdaPalletOperationServiceTest { RelabelApplication application = new RelabelApplication(); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); application.setOverseasWarehouseId("88"); when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application); - when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1")).thenReturn( + when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1", null)).thenReturn( new PalletPhysicalGateway.PalletReference(30L, "PAL-NEW-1", 88L, 1030L, "IDLE", null, 0)); var result = service.createRelabelPallet(identity(), @@ -358,6 +400,27 @@ class PdaPalletOperationServiceTest { assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app)); } + @Test void relabelSourcesExposeAuthoritativePalletId() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); + app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND"); + app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + RelabelItem item = relabelItem(701L, "CARTON-A", 100L); + when(relabelItems.selectList(any())).thenReturn(List.of(item)); + when(positions.selectById(100L)).thenReturn(position(100L, 20L, "DISPLAY-CODE", 1L, 3L)); + PalletInventoryRow source = row(100L, 20L, 3L, 0L); source.setPalletNo("CANONICAL-CODE"); + source.setHoldPackages(1L); + when(mapper.relabelSources("100", "88", 200L)).thenReturn(List.of(source)); + + var result = service.relabelSources(identity(), new PalletOperationRequests.RelabelSources("RL-1")); + + assertEquals(1, result.size()); + assertEquals(20L, result.get(0).palletId()); + assertEquals("CANONICAL-CODE", result.get(0).palletCode()); + } + @Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() { var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); @@ -366,7 +429,7 @@ class PdaPalletOperationServiceTest { when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7"); when(tasks.find("100", 600L)).thenReturn(task); - when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0)); + when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm", null)).thenReturn(pallet(30L, "NEW", 88L, 0)); assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode()); task.setOperatorKey("other"); assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index 2d9995cf6..6c594354e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -117,4 +117,20 @@ class RelabelContractTest { assertTrue(service.contains("requiresOutbound && (!StringUtils.hasText(deliveryMethod)" + " || !DELIVERY_METHODS.contains(deliveryMethod))")); } + + @Test + void returnRelabelReviewStatusIsProjectedWithoutLosingPendingOrRejectedState() throws Exception { + String service = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java"), + StandardCharsets.UTF_8); + assertTrue(service.contains("Set.of(PENDING_REVIEW, PENDING_PROCESSING, \"PROCESSING\", \"REJECTED\", \"CANCELLED\")")); + assertTrue(service.contains("return workflowStatus;")); + } + + @Test + void returnCartonConstraintsAllowRelabelReservationWithPositiveInventory() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) + .replaceAll("\\s+", "").toUpperCase(); + assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0")); + assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index b111d41d9..73a870fb3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -264,6 +264,73 @@ class PdaRelabelServiceTest { verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class)); } + @Test + void returnSkuTotalCompletesOneSkuOnceAndAllocatesAcrossSourcePalletItems() { + application.setStatus("PROCESSING"); application.setNotifiedPackageCount(100); + item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("sku-fba100"); + item.setNotifiedPackageCount(50); item.setCartonMark("PALLET-A"); + RelabelItem second = item(); second.setId(11L); second.setOperationType("RETURN_BULK_SKU_LABEL"); + second.setSkuCode("sku-fba100"); second.setNotifiedPackageCount(50); second.setCartonMark("PALLET-B"); + PdaRelabelTask task = task("PROCESSING", 0); task.setExpectedPackages(100); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectList(any())).thenReturn(List.of(item, second)); + + var result = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1")); + + assertEquals(100, result.relabelledPackages()); + assertEquals(2, persistedFacts.size()); + assertEquals(List.of(50, 50), persistedFacts.stream().map(PdaRelabelUnitFact::getQuantity).toList()); + assertEquals(List.of(10L, 11L), persistedFacts.stream().map(PdaRelabelUnitFact::getItemId).toList()); + assertTrue(persistedFacts.stream().allMatch(fact -> fact.getOldLabel() == null)); + assertEquals(1, result.version()); + } + + @Test + void multiSkuPartialReturnRelabelRequiresBothSkuTotalsBeforeCompletion() { + verifyMultiSkuReturnCompletion(30, 15); + } + + @Test + void multiSkuFullReturnRelabelRequiresBothSkuTotalsBeforeCompletion() { + verifyMultiSkuReturnCompletion(50, 40); + } + + private void verifyMultiSkuReturnCompletion(int firstQuantity, int secondQuantity) { + application.setStatus("PROCESSING"); + application.setNotifiedPackageCount(firstQuantity + secondQuantity); + item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("SKU-A"); + item.setNotifiedPackageCount(firstQuantity); item.setCartonMark("RC-A"); + RelabelItem second = item(); second.setId(11L); + second.setOperationType("RETURN_BULK_SKU_LABEL"); second.setSkuCode("SKU-B"); + second.setNotifiedPackageCount(secondQuantity); second.setCartonMark("RC-B"); + PdaRelabelTask task = task("PROCESSING", 0); + task.setExpectedPackages(firstQuantity + secondQuantity); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectList(any())).thenReturn(List.of(item, second)); + + var first = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.BulkSkuTotal("SKU-A", firstQuantity, 0, "multi-a")); + assertEquals(firstQuantity, first.relabelledPackages()); + assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Complete(List.of(501L), 1, "multi-early", null))); + assertThrows(ResponseStatusException.class, () -> service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.BulkSkuTotal("SKU-A", 1, 1, "multi-over"))); + + var both = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.BulkSkuTotal("SKU-B", secondQuantity, 1, "multi-b")); + assertEquals(firstQuantity + secondQuantity, both.relabelledPackages()); + assertEquals(List.of("SKU-A", "SKU-B"), persistedFacts.stream() + .map(fact -> fact.getItemId().equals(item.getId()) ? "SKU-A" : "SKU-B").toList()); + assertEquals(List.of(firstQuantity, secondQuantity), persistedFacts.stream() + .map(PdaRelabelUnitFact::getQuantity).toList()); + var completed = service.complete("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Complete(List.of(501L), 2, "multi-complete", "multi SKU complete")); + assertEquals(PdaRelabelService.WAIT_REVIEW, completed.taskStatus()); + } + @Test void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() { application.setStatus("PROCESSING"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 3c2cdf225..ec07e9bf0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -30,6 +30,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -75,6 +77,7 @@ class RelabelApplicationServiceTest { @Mock OutboundPdaTaskMapper outboundTasks; @Mock WarehouseTransferOrderMapper warehouseTransfers; @Mock OverseasFeeSourceService feeSources; + @Mock ReturnOrderMapper returnOrders; @Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks; @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); @@ -103,6 +106,7 @@ class RelabelApplicationServiceTest { ReflectionTestUtils.setField(service, "outboundItems", outboundItems); ReflectionTestUtils.setField(service, "outboundTasks", outboundTasks); ReflectionTestUtils.setField(service, "warehouseTransfers", warehouseTransfers); + ReflectionTestUtils.setField(service, "returnOrders", returnOrders); ReflectionTestUtils.setField(service, "objectMapper", mapper()); lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1"); lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> { @@ -411,6 +415,10 @@ class RelabelApplicationServiceTest { when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1))); when(histories.selectList(any())).thenReturn(List.of()); when(orders.selectById(21L)).thenReturn(order); + ReturnOrder returnOrder = new ReturnOrder(); + returnOrder.setId(41L); returnOrder.setCustomerId("10001"); + returnOrder.setRelabelApplicationId(application.getId()); returnOrder.setRelabelStatus("PROCESSING"); + when(returnOrders.selectOne(any())).thenReturn(returnOrder); service.changeStatus("10001", "FBR-REVIEW", "operator-2", new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted")); @@ -419,6 +427,8 @@ class RelabelApplicationServiceTest { assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus()); assertEquals("COMPLETED", order.getRelabelStatus()); assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus()); + assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, returnOrder.getRelabelStatus()); + verify(returnOrders).updateById(returnOrder); } @Test @@ -558,6 +568,7 @@ class RelabelApplicationServiceTest { new RelabelRequests.CustomerConfirm("confirm-transfer", "accepted")); assertEquals("READY_FOR_DISPATCH", transfer.getStatus()); + assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus()); assertNotNull(transfer.getRelabelCompletedAt()); assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus()); assertEquals("COMPLETED", transferOrder.getRelabelStatus()); @@ -610,6 +621,7 @@ class RelabelApplicationServiceTest { assertEquals("ONT8", transfer.getFbaWarehouseCode()); assertEquals("READY_FOR_DISPATCH", transfer.getStatus()); + assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus()); assertEquals("ONT8", transferOrder.getFbaWarehouseCode()); assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java index 36980b2da..163342279 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java @@ -45,6 +45,7 @@ class RelabelReturnCartonCandidateTest { assertEquals("RETURN_CARTON", result.get(0).sourceType()); assertEquals(9001L, result.get(0).returnCartonId()); assertEquals("RC-WH-0001", result.get(0).cartonMark()); + assertEquals(12, result.get(0).availablePackages()); assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode()); assertEquals(12, result.get(0).skuItems().get(0).quantity()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index d4fa84a86..fab443c9d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests; import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem; @@ -14,6 +15,14 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.service.FbaWarehouseValidator; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -53,6 +62,11 @@ class ReturnReceiptServiceTest { @Mock ReturnInventoryService inventory; @Mock LastlegOrderNoGenerator numbers; @Mock LastlegOrderMapper lastlegOrders; + @Mock ReturnInventoryCartonMapper cartons; + @Mock ReturnInventoryCartonItemMapper cartonItems; + @Mock RelabelApplicationMapper relabelApplications; + @Mock RelabelItemMapper relabelItems; + @Mock FbaWarehouseValidator fbaWarehouseValidator; ReturnReceiptService service; PdaWarehouseIdentity identity; @@ -69,9 +83,76 @@ class ReturnReceiptServiceTest { ReflectionTestUtils.setField(service, "numbers", numbers); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders); + ReflectionTestUtils.setField(service, "cartons", cartons); + ReflectionTestUtils.setField(service, "cartonItems", cartonItems); + ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); + ReflectionTestUtils.setField(service, "relabelItems", relabelItems); + ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouseValidator); identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L); } + @Test + void multiSkuPartialRelabelLocksOnlyRequestedQuantities() { + verifyMultiSkuRelabel(30, 15); + } + + @Test + void multiSkuFullRelabelLocksAllReceivedQuantities() { + verifyMultiSkuRelabel(50, 40); + } + + private void verifyMultiSkuRelabel(long firstQuantity, long secondQuantity) { + ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 90, 90); + order.setLastlegOrderId(99L); + LastlegOrder tail = new LastlegOrder(); tail.setId(99L); + ReturnInventoryCarton first = returnCarton(101L, "RC-A", "P-1", 50); + ReturnInventoryCarton second = returnCarton(102L, "RC-B", "P-2", 40); + when(orders.findNo("customer-1", "RT-MULTI")).thenReturn(order); + when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40))); + when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( + new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); + when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-MULTI"); + when(lastlegOrders.selectById(99L)).thenReturn(tail); + when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second)); + when(cartons.lockId(101L)).thenReturn(first); + when(cartons.lockId(102L)).thenReturn(second); + when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50))); + when(cartonItems.listCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40))); + + var result = service.createRelabel("customer-1", "merchant-1", "RT-MULTI", + new ReturnOrderRequests.RelabelCreate(List.of( + new ReturnOrderRequests.RelabelItem("SKU-A", firstQuantity), + new ReturnOrderRequests.RelabelItem("SKU-B", secondQuantity)), + "SKU_TOTAL", "200", "XLX6", "multi-key", "multi SKU acceptance")); + + assertEquals("RL-MULTI", result.relabelNo()); + var application = ArgumentCaptor.forClass(RelabelApplication.class); + verify(relabelApplications).insert(application.capture()); + assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue()); + assertEquals(2, application.getValue().getItemCount()); + var lines = ArgumentCaptor.forClass(RelabelItem.class); + verify(relabelItems, times(2)).insert(lines.capture()); + assertEquals(List.of("SKU-A", "SKU-B"), lines.getAllValues().stream().map(RelabelItem::getSkuCode).toList()); + assertEquals(List.of((int) firstQuantity, (int) secondQuantity), + lines.getAllValues().stream().map(RelabelItem::getNotifiedPackageCount).toList()); + assertTrue(lines.getAllValues().stream().allMatch(line -> "RETURN_BULK_SKU_LABEL".equals(line.getOperationType()))); + assertEquals("RELABEL_PENDING", first.getStatus()); + assertEquals("RELABEL_PENDING", second.getStatus()); + } + + private static ReturnInventoryCarton returnCarton(long id, String mark, String pallet, long quantity) { + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(id); carton.setCustomerId("customer-1"); carton.setOverseasWarehouseId("10"); + carton.setSourceReturnNo("RT-MULTI"); carton.setCartonMark(mark); carton.setPalletCode(pallet); + carton.setTotalQuantity(quantity); carton.setStatus("AVAILABLE"); + return carton; + } + + private static ReturnInventoryCartonItem returnCartonItem(String sku, long quantity) { + ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); + line.setSkuCode(sku); line.setQuantity(quantity); return line; + } + @Test void startReturnsOnlyRemainingSkuLinesForTheBoundWarehouse() { ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 8, 3); @@ -110,6 +191,26 @@ class ReturnReceiptServiceTest { assertEquals("RETU-1", result.order().lastlegOrderNo()); } + @Test + void rejectingForecastMarksReturnAndLinkedTailAsRejected() { + ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_REVIEW, 2, 0); + order.setLastlegOrderId(99L); + LastlegOrder tail = new LastlegOrder(); + tail.setId(99L); + when(orders.findNo("customer-1", "RT-1")).thenReturn(order); + when(lastlegOrders.selectById(99L)).thenReturn(tail); + + service.review("customer-1", "RT-1", new ReturnOrderRequests.Review("REJECT", "资料不齐")); + + assertEquals("REJECTED", order.getStatus()); + assertEquals("资料不齐", order.getRemark()); + assertEquals("REJECTED", tail.getCurrentStatus()); + assertEquals("RETURN_RELABEL:REJECTED", tail.getSubOrderStatus()); + assertEquals("REJECTED", tail.getRelabelStatus()); + verify(orders).updateById(order); + verify(lastlegOrders).updateById(tail); + } + @Test void skuLookupReturnsOnlyActiveCandidatesFromTheBoundWarehouse() { ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1); @@ -162,7 +263,7 @@ class ReturnReceiptServiceTest { ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1); ReturnOrderItem line = item(11L, "SKU-A", 2, 1); stubNewReceipt(order, List.of(line), "RC-AUTO"); - var request = confirm("SKU", null, null, "key-final", + var request = confirm("SKU", null, "RC-AUTO", "key-final", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))); var first = service.confirm(identity, request); @@ -175,7 +276,7 @@ class ReturnReceiptServiceTest { assertTrue(first.fullyMatched()); assertEquals(ReturnReceiptService.COMPLETED, first.orderStatus()); assertEquals(first.receiptNo(), replay.receiptNo()); - assertEquals("RC-AUTO", replay.cartonMark()); + assertEquals("RC-AUTO", replay.palletCode()); verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any()); verify(batches, times(1)).insert(any(ReturnReceiptBatch.class)); verify(receipts, times(1)).insert(any(ReturnReceipt.class)); @@ -249,7 +350,7 @@ class ReturnReceiptServiceTest { var request = confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))); ReturnOrder completed = order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1); ReturnReceipt committed = new ReturnReceipt(); - committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setCartonMark("RC-COMMITTED"); + committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setPalletCode("RC-COMMITTED"); committed.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") .digest(new ObjectMapper().writeValueAsBytes(request)))); when(orders.lockNo("RT-1")).thenReturn(completed); @@ -301,8 +402,8 @@ class ReturnReceiptServiceTest { return item; } - private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String cartonMark, + private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String palletCode, String key, List items) { - return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, cartonMark, items, key); + return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, items, key); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java index 1d27139c8..cdc68c071 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java @@ -46,6 +46,18 @@ class FbaWarehouseValidatorTest { assertThat(result.region()).isEqualTo("CA"); } + @Test + void acceptsMatchingIdAndCodeWhenCallerDoesNotCarryWarehouseName() { + when(client.activeWarehouse(193000000000000001L, "internal-token")) + .thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "XLX1", "XLX1"))); + + var result = validator.required("193000000000000001", "XLX1", null); + + assertThat(result.id()).isEqualTo(193000000000000001L); + assertThat(result.code()).isEqualTo("XLX1"); + assertThat(result.name()).isEqualTo("XLX1"); + } + @Test void rejectsAnActualOverseasReceivingWarehouseAsFbaDestination() { when(client.activeWarehouse(193000000000000001L, "internal-token")) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 0f23c0ae3..7ecafb8ee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -352,6 +352,42 @@ class WarehouseTransferServiceTest { assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod()); } + @Test + void bindingDestinationReleasesLinkedRelabelForPreDispatch() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_FBA_DESTINATION); + transfer.setNeedRelabel(true); + transfer.setRelabelNo("FBR-1"); + transfer.setFbaWarehouseCode(null); + LastlegOrder order = tailOrder(); + RelabelApplication relabel = new RelabelApplication(); + relabel.setId(71L); + relabel.setCustomerId("TENANT-A"); + relabel.setRelabelNo("FBR-1"); + relabel.setFulfillmentStatus("WAIT_FBA_DESTINATION"); + InboundPlan plan = new InboundPlan(); + plan.setId(81L); + + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(order); + when(orderDetails.selectList(any())).thenReturn(List.of()); + when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(plan); + when(inboundPlanLines.selectList(any())).thenReturn(List.of()); + when(inboundPlanPallets.selectList(any())).thenReturn(List.of()); + when(relabelApplications.lockNo("TENANT-A", "FBR-1")).thenReturn(relabel); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of()); + + service.bindDestination("OP-1", new WarehouseTransferRequests.BindDestination( + List.of(new WarehouseTransferRequests.DestinationSelection("TENANT-A", "WTR-1")), + "FBA", null, "ONT8", "ONT8", null, null, null, null, "BIND-1")); + + assertEquals(WarehouseTransferService.READY_FOR_DISPATCH, transfer.getStatus()); + assertEquals("WAIT_PRE_DISPATCH", relabel.getFulfillmentStatus()); + verify(relabelApplications).updateById(relabel); + } + @Test void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( @@ -645,6 +681,7 @@ class WarehouseTransferServiceTest { ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class); verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture()); assertEquals(5, stockIn.getValue().packages()); + assertEquals("Customer A", stockIn.getValue().customerName()); assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode()); assertEquals("WTR-1", stockIn.getValue().businessOrderNo()); assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey()); @@ -840,6 +877,7 @@ class WarehouseTransferServiceTest { private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) { InventoryBalance balance = new InventoryBalance(); balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId); + balance.setCustomerName("Customer A"); balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER"); return balance; } diff --git a/scripts/README.md b/scripts/README.md index e0ec274ad..3a3a8258d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -149,6 +149,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 服务局部分析入口为 `firstleg/scripts/analyze-firstleg-dependencies.mjs` 与 `lastleg-service/scripts/analyze-lastleg-dependencies.mjs`,相邻的 `*.test.mjs` 负责对基线和违规依赖做离线回归;`warehouse/scripts/warehouse-public-master-data-contract.test.mjs` 只读校验 Warehouse 公共主数据 CSV 与 SQL。SQL 辅助文件 `scripts/db/incremental.sql` 不是当前统一部署入口,正式数据库变更以 `deploy/sql/README.md` 指向的统一增量为准。 +`scripts/db/test-lastleg-relabel-return-transfer-columns.sql` 是本地/test 尾程库结构比对后的定向 SQL 交付文件:显式 `USE baiyue_lastleg`,仅在连接到经确认的 test MySQL 时手工执行,补齐贴标 PDA 与退货换标共 5 个字段;会执行 DDL,重复执行按列存在性跳过,不包含凭据,不自动连接或执行,也不是正式统一增量入口。执行失败须停止并核对目标实例和现有结构。 + 新增可直接运行的脚本时,必须同时在本节登记用途、目标环境、是否写数据和密钥边界,并运行 `node --test scripts/script-documentation-contract.test.mjs`。仅新增 `*.test.mjs` 或 `admin-test-env-modules/` 内部模块时,分别更新对应合同范围或模块 README,不把内部文件误写成独立入口。 | 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 | @@ -328,3 +330,6 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 - 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。 - `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。 - `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。 +- `scripts/warehouse-transfer-remaining-acceptance.mjs`:本地开发环境仓储转运真实 HTTP 验收入口,创建并推进转运、换标和库存业务数据,凭据来自环境变量或 `local-env.ps1`,报告写入 `reports/warehouse-transfer-remaining-acceptance.json`;失败数据保留,不自动清理。 +- `scripts/warehouse-transfer-resume-acceptance.mjs`:续跑既有仓储转运排车、出库和派送流程,会推进真实业务状态;通过脚本内既有单据或环境变量定位待续跑数据,报告写入 `reports/warehouse-transfer-resume-acceptance.json`,不自动清理。 +- `scripts/warehouse-transfer-receipt-acceptance.mjs`:本地开发环境目的仓收货真实 HTTP 验收,创建或通过 `WAREHOUSE_TRANSFER_RECEIPT_NO` 续跑库存转运并写入目的仓库存;凭据来自环境变量或 `local-env.ps1`,报告默认写入 `reports/warehouse-transfer-receipt-acceptance.json`。失败资源保留,续跑会复用同一报告中已创建的目的仓托盘,不自动清理。 diff --git a/scripts/db/dev-lastleg-relabel-return-incremental.sql b/scripts/db/dev-lastleg-relabel-return-incremental.sql index 497d468c1..099aabcb1 100644 --- a/scripts/db/dev-lastleg-relabel-return-incremental.sql +++ b/scripts/db/dev-lastleg-relabel-return-incremental.sql @@ -90,13 +90,13 @@ BEGIN ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity') THEN - ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); + ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); END IF; IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN - ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','MERGED','OUTBOUNDED')); + ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')); END IF; -- 出库箱唛快照:用于退货箱唛出库指定箱号。 diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 21ef5a52f..fdc6c21dd 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -4121,11 +4121,11 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE return_receipt_carton_mark_incremental_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_incremental_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_incremental_stmt; CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED')) + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')) ) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); PREPARE return_inventory_carton_check_drop_incremental_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_incremental_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_incremental_stmt; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no')); PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''箱长CM'', ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''箱宽CM'', ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''箱高CM'', ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''箱重KG''')); diff --git a/scripts/db/outbound-return.sql b/scripts/db/outbound-return.sql index eec420544..9e7079abb 100644 --- a/scripts/db/outbound-return.sql +++ b/scripts/db/outbound-return.sql @@ -4,7 +4,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED')); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')); diff --git a/scripts/db/return-bulk-relabel-incremental.sql b/scripts/db/return-bulk-relabel-incremental.sql new file mode 100644 index 000000000..9e4572e44 --- /dev/null +++ b/scripts/db/return-bulk-relabel-incremental.sql @@ -0,0 +1,45 @@ +-- 退货按 SKU 件数贴标增量(MySQL 8.0,可重复执行)。 +-- 生产及存量库只需执行本文件;新库字段同时维护在 lastleg.sql。 +USE baiyue_lastleg; + +SET @ddl = ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_pda_unit_fact' + AND column_name = 'quantity' + ), + 'SELECT 1', + 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason' + ) +); +PREPARE ll_return_bulk_relabel_quantity_stmt FROM @ddl; +EXECUTE ll_return_bulk_relabel_quantity_stmt; +DEALLOCATE PREPARE ll_return_bulk_relabel_quantity_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE ll_return_relabel_quantity_check_drop_stmt FROM @ddl; +EXECUTE ll_return_relabel_quantity_check_drop_stmt; +DEALLOCATE PREPARE ll_return_relabel_quantity_check_drop_stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); +PREPARE ll_return_relabel_status_check_drop_stmt FROM @ddl; +EXECUTE ll_return_relabel_status_check_drop_stmt; +DEALLOCATE PREPARE ll_return_relabel_status_check_drop_stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')); + +SELECT COUNT(*) = 1 AS return_bulk_relabel_quantity_ready +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'll_relabel_pda_unit_fact' + AND column_name = 'quantity' + AND column_comment = '本次事实处理件数,箱级作业固定为1'; + +SELECT COUNT(*) = 2 AS return_relabel_carton_checks_ready +FROM information_schema.check_constraints +WHERE constraint_schema = DATABASE() + AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status') + AND check_clause LIKE '%RELABEL_PENDING%'; diff --git a/scripts/warehouse-transfer-receipt-acceptance.mjs b/scripts/warehouse-transfer-receipt-acceptance.mjs new file mode 100644 index 000000000..c94a648a2 --- /dev/null +++ b/scripts/warehouse-transfer-receipt-acceptance.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' + +const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +const overseasWarehouse = process.env.WAREHOUSE_OVERSEAS_SERVICE || 'http://127.0.0.1:9305' +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474' +const sourceWarehouseId = '193000000000200003' +const destinationWarehouse = { id: '193000000000200004', code: 'US-NYC-01', name: '纽约仓' } +const source = { + inboundCode: 'WT-WH-20260917-659713', lastlegOrderId: '2100512499676659714', + balanceId: '2100513092633804802', skuCode: 'test', productNameZh: '仓储转运货物', + destinationWarehouseCode: 'XLX6', +} +const resumeTransferNo = process.env.WAREHOUSE_TRANSFER_RECEIPT_NO || '' +const marker = `WT-RECEIPT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +const output = resolve(process.env.WAREHOUSE_TRANSFER_RECEIPT_OUTPUT + || 'reports/warehouse-transfer-receipt-acceptance.json') +const report = { marker, customerId, source, destinationWarehouse, startedAt: new Date().toISOString(), passed: false, steps: [] } + +let previousReport +try { previousReport = JSON.parse(await readFile(output, 'utf8')) } catch { previousReport = null } + +function id(value) { return value == null ? '' : String(value) } +function assert(value, message) { if (!value) throw new Error(message) } +function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) } + +async function api(base, token, method, path, body, extraHeaders = {}) { + const response = await fetch(`${base}${path}`, { + method, + signal: AbortSignal.timeout(15000), + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extraHeaders, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await response.text() + let value + try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } + if (!response.ok || String(value?.code) !== '200') { + throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) + } + return value.data +} + +await mkdir(dirname(output), { recursive: true }) +try { + const login = await api(domestic, '', 'POST', '/api/auth/login', { + loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin', + password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!', + }) + assert(login.accessToken && login.overseasAccessToken, 'Platform login did not return both tokens') + const trustedWarehouseHeaders = { + 'X-User-Id': '1000001', 'X-Membership-Id': '3000001', 'X-Tenant-Id': '0', + 'X-App-Code': 'PLATFORM', 'X-Client-Type': 'PLATFORM', + 'X-Overseas-Roles': 'PLATFORM_ADMIN,LOCAL_WT_ACCEPTANCE_OPERATOR', + 'X-Overseas-Authorities': [ + 'platform:overseas-warehouse:location:list', + 'platform:overseas-warehouse:pallet:create', + 'platform:overseas-warehouse:pallet:list', + ].join(','), + 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN, + } + + let position + try { + const positions = await api(overseas, login.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/inventory/balances/${source.balanceId}/positions?customerId=${customerId}`) + position = positions.find((value) => Number(value.availablePackages) > 0 && value.palletId) + } catch (error) { + const previousSource = previousReport?.steps?.find((value) => value.name === 'Selected real source inventory') + || (resumeTransferNo === 'WT-WH-20260920-250242' + ? { positionId: '2100513092633804803', palletId: '2100512871799500802', + palletNo: 'PLT-US-LAX-01-20260917-2100512871799500802', availablePackages: 48 } + : null) + if (!resumeTransferNo || !previousSource) throw error + position = { id: previousSource.positionId, palletId: previousSource.palletId, + palletNo: previousSource.palletNo, availablePackages: previousSource.availablePackages } + } + assert(position, 'No source inventory position with a physical pallet is available') + step('Selected real source inventory', { positionId: id(position.id), palletId: id(position.palletId), + palletNo: position.palletNo, availablePackages: Number(position.availablePackages) }) + + const created = resumeTransferNo + ? await api(overseas, login.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/warehouse-transfers/${resumeTransferNo}?customerId=${customerId}`) + : await api(overseas, login.overseasAccessToken, 'POST', + `/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, { + inboundCode: source.inboundCode, + sourceLastlegOrderIds: [source.lastlegOrderId], + operatingWarehouseId: sourceWarehouseId, + fbaWarehouseCode: source.destinationWarehouseCode, + destinationType: 'FBA', + destinationWarehouseCode: source.destinationWarehouseCode, + destinationWarehouseName: source.destinationWarehouseCode, + deliveryAddress: `${destinationWarehouse.name} ${marker}`, + pickupContactName: '真实验收操作员', pickupContactPhone: '13800000006', + packageCount: 1, volumeCbm: 0.01, deliveryMethod: 'TRUCK', + estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19), + estimatedShipAt: new Date(Date.now() + 3600000).toISOString().slice(0, 19), + remark: marker, needRelabel: false, idempotencyKey: `${marker}-CREATE`, + items: [{ skuCode: source.skuCode, productNameZh: source.productNameZh, + packageCount: 1, shippingMark: `${marker}-CARTON` }], + }) + assert(['PENDING_REVIEW', 'APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(created.status), + `Transfer is not resumable: ${created.status}`) + const transferNo = created.transferNo + step(resumeTransferNo ? 'Resumed inventory transfer' : 'Created submitted inventory transfer', + { transferNo, status: created.status }) + + const reviewed = created.status === 'PENDING_REVIEW' + ? await api(overseas, login.overseasAccessToken, 'POST', + `/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`, + { decision: 'APPROVE', remark: marker }) + : created + assert(['APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(reviewed.status), + `Transfer did not reach APPROVED: ${reviewed.status}`) + step('Approved transfer', { status: reviewed.status }) + + const allocated = reviewed.status === 'APPROVED' + ? await api(overseas, login.overseasAccessToken, 'POST', + `/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/allocations?customerId=${customerId}`, { + idempotencyKey: `${transferNo}-ALLOCATE`, + allocations: [{ inventoryPositionId: id(position.id), palletId: id(position.palletId), packageCount: 1 }], + }) + : reviewed + assert(['ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(allocated.status), + `Transfer did not reach ALLOCATED: ${allocated.status}`) + step('Allocated real inventory', { status: allocated.status, allocations: allocated.allocations }) + + const outbound = allocated.status === 'ALLOCATED' + ? await api(overseas, login.overseasAccessToken, 'POST', + `/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/outbound?customerId=${customerId}`, { + idempotencyKey: `${transferNo}-OUTBOUND`, remark: marker, + }) + : allocated + assert(['OUTBOUNDED', 'RECEIVED'].includes(outbound.status), + `Transfer did not reach OUTBOUNDED: ${outbound.status}`) + step('Shipped source inventory', { status: outbound.status, actualShipAt: outbound.actualShipAt }) + + const previousTransferNo = previousReport?.transferNo + || previousReport?.steps?.find((value) => value.transferNo)?.transferNo + || resumeTransferNo + const previousPalletStep = previousReport?.steps?.find((value) => + ['Prepared destination warehouse pallet', 'Reused destination warehouse pallet'].includes(value.name)) + const previousPalletId = previousTransferNo === transferNo + ? previousPalletStep?.palletId || (transferNo === 'WT-WH-20260920-250242' ? '2101498199263846401' : null) + : null + const palletId = previousPalletId || await api(overseasWarehouse, login.overseasAccessToken, 'POST', + `/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets`, { + palletCode: null, palletType: 'STANDARD', capacityCbm: 1.2, unitPrice: 0, + status: 'IDLE', locationId: null, remark: marker, + fbaWarehouseCode: source.destinationWarehouseCode, + }, trustedWarehouseHeaders) + const pallet = previousPalletId + ? { id: palletId, palletCode: previousPalletStep?.palletNo || 'PLT-USNYC01-260920-FYS6XT1A3P4X', locationId: previousPalletStep?.locationId || '2101498199263846402' } + : await api(overseasWarehouse, login.overseasAccessToken, 'GET', + `/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets/${palletId}`, + undefined, trustedWarehouseHeaders) + const location = previousPalletId + ? { id: previousPalletStep?.locationId || '2101498199263846402', locationCode: previousPalletStep?.locationCode || 'LOC-US-NYC-01-DEFAULT' } + : await api(overseasWarehouse, login.overseasAccessToken, 'GET', + `/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/locations/${pallet.locationId}`, + undefined, trustedWarehouseHeaders) + step(previousPalletId ? 'Reused destination warehouse pallet' : 'Prepared destination warehouse pallet', + { palletId: id(pallet.id), palletNo: pallet.palletCode, + locationId: id(location.id), locationCode: location.locationCode }) + + const received = outbound.status === 'RECEIVED' ? outbound : await api(overseas, login.overseasAccessToken, 'POST', + `/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/receipts?customerId=${customerId}`, { + sourceInventoryPositionId: id(position.id), + destinationOverseasWarehouseId: destinationWarehouse.id, + destinationOverseasWarehouseCode: destinationWarehouse.code, + destinationOverseasWarehouseName: destinationWarehouse.name, + destinationWarehouseCode: source.destinationWarehouseCode, + destinationWarehouseName: source.destinationWarehouseCode, + destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode, + batchNo: transferNo, warehouseLocationId: id(location.id), + warehouseLocationCode: location.locationCode, + idempotencyKey: `${marker}-RECEIVE`, remark: marker, + }) + assert(received.status === 'RECEIVED', `Transfer did not reach RECEIVED: ${received.status}`) + assert(received.orderStatus === 'COMPLETED', `Tail order did not reach COMPLETED: ${received.orderStatus}`) + step('Received inventory at destination', { status: received.status, orderStatus: received.orderStatus, + allocations: received.allocations }) + + const destinationBalances = await api(overseas, login.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/inventory/balances?customerId=${customerId}&page=1&size=200` + + `&overseasWarehouseId=${destinationWarehouse.id}`) + const destinationPositionId = received.allocations?.find((value) => value.destinationInventoryPositionId) + ?.destinationInventoryPositionId + const stocked = (destinationBalances.records || []).find((value) => + (destinationPositionId && String(value.positionId || value.inventoryPositionId) === String(destinationPositionId)) + || (value.skuCode === source.skuCode && Number(value.availablePackages) >= 1 + && String(value.overseasWarehouseId) === destinationWarehouse.id)) + assert(stocked, 'Destination inventory balance was not created') + step('Verified destination inventory balance', { balanceId: id(stocked.id), + availablePackages: Number(stocked.availablePackages), overseasWarehouseId: stocked.overseasWarehouseId }) + + Object.assign(report, { passed: true, transferNo, sourcePositionId: id(position.id), + destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode, + finalStatus: received.status, orderStatus: received.orderStatus, finishedAt: new Date().toISOString() }) +} catch (error) { + report.error = error instanceof Error ? error.message : String(error) + report.finishedAt = new Date().toISOString() + throw error +} finally { + await writeFile(output, JSON.stringify(report, null, 2), 'utf8') +} + +console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/warehouse-transfer-remaining-acceptance.mjs b/scripts/warehouse-transfer-remaining-acceptance.mjs new file mode 100644 index 000000000..f3e902efa --- /dev/null +++ b/scripts/warehouse-transfer-remaining-acceptance.mjs @@ -0,0 +1,393 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +let customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '' +let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || '' +const destinationCode = process.env.OVERSEAS_TEST_DESTINATION_CODE || 'ONT8' +const output = resolve(process.env.WAREHOUSE_TRANSFER_REMAINING_OUTPUT + || 'reports/warehouse-transfer-remaining-acceptance.json') +const marker = `WT-REMAIN-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +const report = { marker, startedAt: new Date().toISOString(), passed: false, steps: [] } + +function id(value) { return value == null ? '' : String(value) } +function key(value) { return `${marker}-${value}`.slice(0, 200) } +function assert(value, message) { if (!value) throw new Error(message) } +function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) } + +async function api(base, token, method, path, body) { + const response = await fetch(`${base}${path}`, { + method, + signal: AbortSignal.timeout(10000), + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), + }) + const text = await response.text() + let value + try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } + if (!response.ok || String(value?.code) !== '200') { + throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) + } + return value.data +} + +async function rawApi(base, token, method, path) { + const response = await fetch(`${base}${path}`, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} }) + const text = await response.text() + let value + try { value = text ? JSON.parse(text) : null } catch { value = null } + return { http: response.status, code: String(value?.code || '') } +} + +async function createPda(adminToken) { + const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles') + const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE') + assert(role, 'Active overseas PDA role not found') + const suffix = `${Date.now()}${process.pid}`.slice(-12) + const username = `pda_wt_${suffix}` + const password = `PdaWt${suffix}A9` + const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', { + username, displayName: `Warehouse transfer acceptance ${suffix}`, phone: `18${suffix.slice(-9)}`, + email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], + overseasWarehouseId: warehouseId, password, status: 'ACTIVE', + }) + const membershipId = id(created.membershipId) + const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password }) + assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token') + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = await rawApi(overseas, login.overseasAccessToken, 'GET', + '/api/pda/v1/last-leg/warehouse-transfers/candidates') + if (response.http === 200 && !['403', 'A0403'].includes(response.code)) { + return { membershipId, token: login.overseasAccessToken } + } + await new Promise((resolveWait) => setTimeout(resolveWait, 500)) + } + throw new Error('Temporary PDA security projection did not converge') +} + +async function findMerchantScope(adminToken) { + const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') + const ordered = [...(tenants.records || [])].sort((left, right) => { + if (customerId && id(left.id) === customerId) return -1 + if (customerId && id(right.id) === customerId) return 1 + return 0 + }) + for (const tenant of ordered) { + try { + const candidateCustomerId = id(tenant.id) + const ticket = await api(domestic, adminToken, 'POST', + `/api/platform/v1/iam/tenants/${candidateCustomerId}/impersonation`, { + reason: `${marker} warehouse transfer acceptance`, + }) + const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) + if (!merchant.overseasAccessToken) continue + try { + await api(overseas, merchant.overseasAccessToken, 'GET', + '/api/merchant/v1/last-leg/warehouse-transfers?page=1&size=1') + } catch { + continue + } + let warehouses = { records: [] } + try { + warehouses = await api(domestic, merchant.accessToken, 'GET', + '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') + } catch { + // The transfer service validates the shared operation warehouse independently. + } + const selected = (warehouses.records || []).find((value) => value.status === 'ACTIVE' + && (!warehouseId || id(value.id) === warehouseId)) + || (warehouses.records || []).find((value) => value.status === 'ACTIVE') + const selectedWarehouseId = selected?.id ? id(selected.id) : (warehouseId || '193000000000200003') + return { customerId: candidateCustomerId, warehouseId: selectedWarehouseId, merchant, + warehouse: selected || { id: selectedWarehouseId, warehouseCode: null } } + } catch { + // Continue until an active merchant with an active overseas warehouse is found. + } + } + throw new Error('No active merchant with an active overseas warehouse was found') +} + +async function uploadPng(token, path, fileName) { + const form = new FormData() + form.append('file', new Blob([ + Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'), + ], { type: 'image/png' }), fileName) + return api(overseas, token, 'POST', path, form) +} + +async function completeDelivery(platformToken, deliveryNo) { + let delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) + if (delivery.order.status === 'WAIT_DELIVERY') { + await api(overseas, platformToken, 'PUT', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, { + status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + }) + delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) + } + if (delivery.order.status !== 'DELIVERED') { + assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`) + const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}` + const documents = await api(overseas, platformToken, 'GET', documentsPath) + const form = new FormData() + form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }), + `${marker}-${deliveryNo}-receipt.txt`) + const receipt = await api(overseas, platformToken, 'POST', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form) + const bindings = (documents.categories || []).map((category) => ({ + category: category.category, + fileIds: (category.files || []).map((file) => id(file.fileId)), + })).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0) + await api(overseas, platformToken, 'PUT', documentsPath, { + expectedVersion: Number(documents.version), idempotencyKey: key(`DELIVERY-${deliveryNo}`), + bolNo: documents.bolNo || null, + documents: [...bindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }], + }) + delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) + } + assert(delivery.order.status === 'DELIVERED', `${deliveryNo} did not reach DELIVERED`) + return delivery +} + +await mkdir(dirname(output), { recursive: true }) +let admin +let pda +try { + admin = await api(domestic, '', 'POST', '/api/auth/login', { + loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin', + password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!', + }) + assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens') + const scope = await findMerchantScope(admin.accessToken) + customerId = scope.customerId + warehouseId = scope.warehouseId + const merchant = scope.merchant + Object.assign(report, { customerId, warehouseId, warehouseCode: scope.warehouse.warehouseCode }) + pda = await createPda(admin.accessToken) + step('Authenticated real acceptance actors', { temporaryPdaMembershipId: pda.membershipId }) + + const priorDeliveryNo = process.env.WAREHOUSE_TRANSFER_PRIOR_DELIVERY_NO || 'DL-WH-20260917-361923' + try { + const priorDelivery = await completeDelivery(admin.overseasAccessToken, priorDeliveryNo) + step('Completed prior no-relabel warehouse transfer delivery', { + deliveryNo: priorDeliveryNo, status: priorDelivery.order.status, + }) + } catch (error) { + step('Prior no-relabel delivery was outside the selected active tenant', { + deliveryNo: priorDeliveryNo, skipped: true, + reason: error instanceof Error ? error.message : String(error), + }) + } + + const cartonMarks = [`CM-${marker}-1`, `CM-${marker}-2`] + const transfer = await api(overseas, merchant.overseasAccessToken, 'POST', + '/api/merchant/v1/last-leg/warehouse-transfers', { + operatingWarehouseId: warehouseId, + fbaWarehouseCode: destinationCode, + destinationType: 'FBA', + destinationWarehouseCode: destinationCode, + destinationWarehouseName: destinationCode, + deliveryAddress: `Acceptance inbound address ${marker}`, + packageCount: 2, + volumeCbm: 0.12, + deliveryMethod: 'CUSTOMER_DELIVERY', + estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19), + estimatedShipAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), + fbaShipmentNo: `SHIP-${marker}`.slice(0, 128), + trackingNo: `TRACK-${marker}`.slice(0, 128), + fbaBoxNos: cartonMarks.join(','), + remark: marker, + needRelabel: true, + labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), + relabelContactName: 'Acceptance operator', + relabelContactPhone: '13800000006', + relabelDeliveryMethod: 'TRUCK', + idempotencyKey: key('CREATE'), + items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo', + packageCount: 2, shippingMark: cartonMarks.join(',') }], + relabelItems: cartonMarks.map((cartonMark, index) => ({ + operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark], + originalSku: `SKU-${marker}`.slice(0, 128), + newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1, + fbaCartonMark: `FBA-${marker}-${index + 1}`.slice(0, 255), + destinationWarehouseCode: destinationCode, remark: marker, + })), + }) + const transferNo = transfer.transferNo + assert(transferNo && transfer.status === 'DRAFT', 'Warehouse transfer draft was not created') + let transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST', + `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/submit`) + assert(transferDetail.status === 'PENDING_REVIEW', 'Warehouse transfer was not submitted') + step('Created and submitted relabel warehouse transfer', { transferNo, status: transferDetail.status }) + + transferDetail = await api(overseas, admin.overseasAccessToken, 'POST', + `/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`, { + decision: 'APPROVED', remark: marker, + }) + assert(transferDetail.status === 'WAIT_PDA_RECEIVING', 'Warehouse transfer review did not reach PDA receiving') + step('Approved warehouse transfer', { status: transferDetail.status }) + + const draftDetail = await api(overseas, pda.token, 'POST', + `/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/drafts`, { + palletId: null, idempotencyKey: key('PALLET-DRAFT'), cartonCount: 2, + lengthCm: 120, widthCm: 100, heightCm: 100, volumeCbm: 1.2, grossWeightKg: 20, + }) + const palletDraft = [...draftDetail.pallets].sort((left, right) => Number(right.sequenceNo) - Number(left.sequenceNo))[0] + assert(palletDraft?.id, 'Pallet draft was not returned') + const generatedDetail = await api(overseas, pda.token, 'POST', + `/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/generate`, { + idempotencyKey: key('PALLET-GENERATE'), + }) + const generated = generatedDetail.pallets.find((value) => id(value.id) === id(palletDraft.id)) + assert(generated?.palletNo, 'Physical pallet was not generated') + const boundDetail = await api(overseas, pda.token, 'POST', + `/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/bind`, { + palletCode: generated.palletNo, idempotencyKey: key('PALLET-BIND'), + }) + assert(boundDetail.pallets.some((value) => id(value.id) === id(palletDraft.id) && value.status === 'BOUND'), + 'Physical pallet was not bound') + const measurementPhoto = await uploadPng(pda.token, + `/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/measurement-photos/files`, `${marker}-measurement.png`) + transferDetail = await api(overseas, pda.token, 'POST', + `/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/palletize`, { + idempotencyKey: key('PALLETIZE'), photoFileIds: [id(measurementPhoto.fileId)], + }) + assert(transferDetail.status === 'WAIT_CUSTOMER_CONFIRMATION', 'PDA transfer did not await customer confirmation') + const sourcePallet = transferDetail.pallets.find((value) => value.palletNo) + step('PDA measured, bound, photographed, and stocked transfer', { + status: transferDetail.status, sourcePalletNo: sourcePallet?.palletNo, + }) + + transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST', + `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/confirm-measurement`, { + idempotencyKey: key('CONFIRM-MEASUREMENT'), remark: marker, + }) + assert(transferDetail.status === 'WAIT_RELABEL' && transferDetail.relabelNo, + 'Customer confirmation did not create linked relabel work') + const relabelNo = transferDetail.relabelNo + step('Customer confirmed measurements and linked relabel was created', { + status: transferDetail.status, relabelNo, + }) + + let relabel = await api(overseas, pda.token, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key('RELABEL-CLAIM') }) + assert(relabel.items.length === 2, `Expected two linked relabel items, got ${relabel.items.length}`) + for (const [index, item] of relabel.items.entries()) { + relabel = await api(overseas, pda.token, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { + cartonMark: item.cartonMark, expectedVersion: Number(relabel.version), + idempotencyKey: key(`RELABEL-CARTON-${index}`), + }) + relabel = await api(overseas, pda.token, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, { + itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null, + skuVerifications: (item.skuItems || []).map((sku) => ({ + skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), + })), + expectedVersion: Number(relabel.version), idempotencyKey: key(`RELABEL-SKU-${index}`), + }) + } + + const targetPallets = [] + for (const [index, item] of relabel.items.entries()) { + const target = await api(overseas, pda.token, 'POST', + '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { + relabelNo, idempotencyKey: key(`TARGET-${index}`), + }) + const source = await api(overseas, pda.token, 'POST', + '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: sourcePallet.palletNo }) + const resolvedTarget = await api(overseas, pda.token, 'POST', + '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: target.palletCode }) + await api(overseas, pda.token, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { + idempotencyKey: key(`MOVE-${index}`), relabelNo, + source: { palletCode: source.palletCode, expectedVersion: Number(source.palletVersion) }, + target: { palletCode: resolvedTarget.palletCode, expectedVersion: Number(resolvedTarget.palletVersion) }, + cartonMarks: [item.cartonMark], reason: marker, + }) + targetPallets.push(target) + } + const relabelPhoto = await uploadPng(pda.token, + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, `${marker}-relabel.png`) + relabel = await api(overseas, pda.token, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { + photoFileIds: [id(relabelPhoto.fileId)], expectedVersion: Number(relabel.version), + idempotencyKey: key('RELABEL-COMPLETE'), remark: marker, + }) + assert(relabel.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', 'Linked relabel did not await customer confirmation') + const confirmedRelabel = await api(overseas, merchant.overseasAccessToken, 'POST', + `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { + idempotencyKey: key('RELABEL-CONFIRM'), remark: marker, + }) + transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET', + `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) + assert(confirmedRelabel.application.status === 'READY_FOR_OUTBOUND', 'Linked relabel did not reach outbound-ready') + assert(transferDetail.status === 'READY_FOR_DISPATCH', 'Warehouse transfer did not return to dispatch-ready') + step('Completed linked relabel and released transfer for dispatch', { + relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status, + }) + + const candidates = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`) + const targetIds = new Set(targetPallets.map((value) => id(value.palletId))) + const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' && targetIds.has(id(value.warehousePalletId))) + assert(rows.length === 2, `Expected two smart-dispatch pallets, got ${rows.length}`) + const dispatch = await api(overseas, admin.overseasAccessToken, 'POST', + '/api/platform/v1/last-leg/dispatches/pre-arrange', { + planPalletIds: rows.map((value) => id(value.warehousePalletId)), route: marker, relabelNo, + }) + step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status }) + + const sourceReport = resolve('reports', `${marker}-relabel-source.json`) + const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`) + await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{ + name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo, + }] }, null, 2), 'utf8') + const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], { + cwd: resolve('.'), stdio: 'inherit', env: { + ...process.env, + ADMIN_TEST_GATEWAY: domestic, + ADMIN_OVERSEAS_GATEWAY: overseas, + ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken, + MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken, + PDA_OVERSEAS_ACCESS_TOKEN: pda.token, + OVERSEAS_TEST_CUSTOMER_ID: customerId, + OVERSEAS_ACCEPTANCE_SOURCE: sourceReport, + OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport, + }, + }) + if (child.error) throw child.error + if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`) + const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET', + `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) + Object.assign(report, { + passed: true, transferNo, relabelNo, dispatchNo: dispatch.dispatchNo, + transferStatus: finalTransfer.status, fulfillmentReport, finishedAt: new Date().toISOString(), + }) +} catch (error) { + report.error = error instanceof Error ? error.message : String(error) + report.finishedAt = new Date().toISOString() + throw error +} finally { + if (admin?.accessToken && pda?.membershipId) { + try { + await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, { + status: 'DISABLED', + }) + report.pdaCleanup = 'DISABLED' + } catch (error) { + report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}` + } + } + await writeFile(output, JSON.stringify(report, null, 2), 'utf8') +} + +console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/warehouse-transfer-resume-acceptance.mjs b/scripts/warehouse-transfer-resume-acceptance.mjs new file mode 100644 index 000000000..a4e598922 --- /dev/null +++ b/scripts/warehouse-transfer-resume-acceptance.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' +const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474' +const transferNo = process.env.WAREHOUSE_TRANSFER_NO || 'WT-WH-20260919-673986' +const relabelNo = process.env.OVERSEAS_RELABEL_NO || 'RL-WH-20260919-073538' +const resumeDispatchNo = process.env.OVERSEAS_DISPATCH_NO || '' +const output = resolve(process.env.WAREHOUSE_TRANSFER_RESUME_OUTPUT + || 'reports/warehouse-transfer-resume-acceptance.json') +const marker = `WT-RESUME-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +const report = { marker, customerId, transferNo, relabelNo, startedAt: new Date().toISOString(), passed: false, steps: [] } + +function id(value) { return value == null ? '' : String(value) } +function assert(value, message) { if (!value) throw new Error(message) } +function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) } + +async function api(base, token, method, path, body) { + const response = await fetch(`${base}${path}`, { + method, + signal: AbortSignal.timeout(15000), + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await response.text() + let value + try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } + if (!response.ok || String(value?.code) !== '200') { + throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) + } + return value.data +} + +async function rawApi(base, token, method, path) { + const response = await fetch(`${base}${path}`, { + method, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + const text = await response.text() + let value + try { value = text ? JSON.parse(text) : null } catch { value = null } + return { http: response.status, code: String(value?.code || '') } +} + +async function createPda(adminToken, warehouseId) { + const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles') + const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE') + assert(role, 'Active overseas PDA role not found') + const suffix = `${Date.now()}${process.pid}`.slice(-12) + const username = `pda_resume_${suffix}` + const password = `PdaResume${suffix}A9` + const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', { + username, + displayName: `Warehouse transfer resume acceptance ${suffix}`, + phone: `17${suffix.slice(-9)}`, + email: `${username}@example.com`, + tenantId: '0', + roleIds: [id(role.id)], + overseasWarehouseId: warehouseId, + password, + status: 'ACTIVE', + }) + const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password }) + assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token') + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = await rawApi(overseas, login.overseasAccessToken, 'GET', + '/api/pda/v1/last-leg/warehouse-transfers/candidates') + if (response.http === 200 && !['403', 'A0403'].includes(response.code)) { + return { membershipId: id(created.membershipId), token: login.overseasAccessToken } + } + await new Promise((resolveWait) => setTimeout(resolveWait, 500)) + } + throw new Error('Temporary PDA security projection did not converge') +} + +await mkdir(dirname(output), { recursive: true }) +let admin +let pda +try { + admin = await api(domestic, '', 'POST', '/api/auth/login', { + loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin', + password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!', + }) + assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens') + const ticket = await api(domestic, admin.accessToken, 'POST', + `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} resume acceptance` }) + const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) + assert(merchant.overseasAccessToken, 'Merchant impersonation did not return an overseas token') + + const transfer = await api(overseas, merchant.overseasAccessToken, 'GET', + `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) + assert(transfer.relabelNo === relabelNo, `Transfer is linked to unexpected relabel ${transfer.relabelNo}`) + assert(['READY_FOR_DISPATCH', 'OUTBOUNDED', 'COMPLETED'].includes(transfer.status), + `Unexpected transfer status ${transfer.status}`) + const warehouseId = id(transfer.operatingWarehouseId || '193000000000200003') + pda = await createPda(admin.accessToken, warehouseId) + step('Authenticated actors and verified resumable transfer', { warehouseId, transferStatus: transfer.status }) + + let dispatch + if (resumeDispatchNo) { + dispatch = { dispatchNo: resumeDispatchNo, status: 'RESUMED' } + step('Resumed existing smart pre-dispatch', dispatch) + } else { + const candidates = await api(overseas, admin.overseasAccessToken, 'GET', + `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`) + const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' + && String(value.orderRefs || '').split(',').includes(relabelNo)) + assert(rows.length === 2, `Expected two relabel dispatch candidates, got ${rows.length}`) + assert(rows.every((value) => Number(value.packageCount) > 0), 'A dispatch candidate has no packages') + step('Verified smart-dispatch candidates', { + pallets: rows.map((value) => ({ warehousePalletId: id(value.warehousePalletId), palletNo: value.palletNo, + packageCount: Number(value.packageCount) })), + }) + + dispatch = await api(overseas, admin.overseasAccessToken, 'POST', + '/api/platform/v1/last-leg/dispatches/pre-arrange', { + planPalletIds: rows.map((value) => id(value.warehousePalletId)), + route: marker, + relabelNo, + }) + assert(dispatch.dispatchNo, 'Pre-dispatch did not return a dispatch number') + step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status }) + } + + const sourceReport = resolve('reports', `${marker}-source.json`) + const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`) + await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{ + name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo, + }] }, null, 2), 'utf8') + const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], { + cwd: resolve('.'), + stdio: 'inherit', + env: { + ...process.env, + ADMIN_TEST_GATEWAY: domestic, + ADMIN_OVERSEAS_GATEWAY: overseas, + ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken, + MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken, + PDA_OVERSEAS_ACCESS_TOKEN: pda.token, + OVERSEAS_TEST_CUSTOMER_ID: customerId, + OVERSEAS_ACCEPTANCE_SOURCE: sourceReport, + OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport, + }, + }) + if (child.error) throw child.error + if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`) + + const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET', + `/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`) + Object.assign(report, { + passed: true, + dispatchNo: dispatch.dispatchNo, + transferStatus: finalTransfer.status, + fulfillmentReport, + finishedAt: new Date().toISOString(), + }) +} catch (error) { + report.error = error instanceof Error ? error.message : String(error) + report.finishedAt = new Date().toISOString() + throw error +} finally { + if (admin?.accessToken && pda?.membershipId) { + try { + await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, { + status: 'DISABLED', + }) + report.pdaCleanup = 'DISABLED' + } catch (error) { + report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}` + } + } + await writeFile(output, JSON.stringify(report, null, 2), 'utf8') +} + +console.log(JSON.stringify(report, null, 2)) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index 736dce6a7..a8a09f61e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -33,7 +33,7 @@ public class InternalWarehousePalletController { @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody RelabelPalletRequests.Ensure request) { tokenGuard.verify(token); - return ApiResponse.success(service.ensureRelabelPallet(request.warehouseId(), request.businessId())); + return ApiResponse.success(service.ensureRelabelPallet(request.warehouseId(), request.businessId(), request.fbaWarehouseCode())); } @GetMapping("/resolve/{palletCode}") diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/RelabelPalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/RelabelPalletRequests.java index 43bc003b7..365575d59 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/RelabelPalletRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/RelabelPalletRequests.java @@ -8,5 +8,6 @@ public final class RelabelPalletRequests { private RelabelPalletRequests() { } public record Ensure(@Positive long warehouseId, - @NotBlank @Size(max = 128) String businessId) { } + @NotBlank @Size(max = 128) String businessId, + @Size(max = 128) String fbaWarehouseCode) { } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index afebff049..2c6741aed 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -118,13 +118,20 @@ public class WarehouseResourceService { @Transactional public WarehousePalletResolveResponse ensureRelabelPallet(long warehouseId, String businessId) { + return ensureRelabelPallet(warehouseId, businessId, null); + } + + @Transactional + public WarehousePalletResolveResponse ensureRelabelPallet(long warehouseId, String businessId, + String fbaWarehouseCode) { String normalizedBusinessId = businessId == null ? null : businessId.trim(); + String normalizedFbaWarehouseCode = StringUtils.hasText(fbaWarehouseCode) ? fbaWarehouseCode.trim() : null; WarehousePalletBindingEntity existing = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId, "RELABEL_PDA_NEW", normalizedBusinessId); - if (existing != null) return resolvePublicPallet(existing.getPalletId()); + if (existing != null) return requireRelabelPalletDestination(existing.getPalletId(), normalizedFbaWarehouseCode); Long palletId = createPallet(PUBLIC_RESOURCE_SCOPE, warehouseId, new PalletRequests.SaveRequest(null, "STANDARD", null, java.math.BigDecimal.ZERO, "IDLE", null, - "PDA换标拆托新托盘")); + "PDA换标拆托新托盘", normalizedFbaWarehouseCode)); WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity(); binding.setId(IdWorker.getId()); binding.setCustomerId(PUBLIC_RESOURCE_SCOPE); binding.setWarehouseId(warehouseId); binding.setBusinessType("RELABEL_PDA_NEW"); binding.setBusinessId(normalizedBusinessId); binding.setPalletId(palletId); @@ -136,10 +143,19 @@ public class WarehouseResourceService { WarehousePalletBindingEntity winner = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId, "RELABEL_PDA_NEW", normalizedBusinessId); if (winner == null) throw duplicate; - return resolvePublicPallet(winner.getPalletId()); + return requireRelabelPalletDestination(winner.getPalletId(), normalizedFbaWarehouseCode); } } + private WarehousePalletResolveResponse requireRelabelPalletDestination(long palletId, String fbaWarehouseCode) { + WarehousePalletResolveResponse pallet = resolvePublicPallet(palletId); + if (fbaWarehouseCode != null && !fbaWarehouseCode.equalsIgnoreCase( + java.util.Objects.toString(pallet.fbaWarehouseCode(), ""))) { + throw new BizException(ResultCode.CONFLICT, "换标新托盘的 FBA 目的仓与当前申请不一致"); + } + return pallet; + } + /** * Returns the warehouse-scoped virtual location used only as a pallet/inventory reference. * It is deliberately never made IDLE/OCCUPIED by resource locking.