From 0bd25de906a8bb58c7d108379959a255230a96b5 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 29 Oct 2026 15:49:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 ++ lastleg-service/docs/business-index.md | 2 + .../discard/dto/CargoDiscardRequests.java | 5 +- .../discard/dto/CargoDiscardResponses.java | 7 ++- .../discard/entity/CargoDiscardItem.java | 1 + .../discard/model/CargoDiscardQuantities.java | 11 ++++ .../discard/pda/dto/PdaCargoDiscardDtos.java | 9 ++- .../pda/service/PdaCargoDiscardService.java | 45 +++++++++++++- .../CargoDiscardQuantityRepository.java | 2 +- .../service/CargoDiscardOutboundService.java | 8 ++- .../discard/service/CargoDiscardService.java | 15 ++++- .../CargoDiscardStatisticsService.java | 2 +- .../service/CargoDiscardStockService.java | 22 ++++++- .../lastleg/mapper/LastlegOrderMapper.java | 4 ++ .../repository/RelabelSourceRepository.java | 21 +++++++ .../service/RelabelApplicationService.java | 11 ++++ .../service/WarehouseTransferService.java | 4 ++ .../src/main/resources/db/lastleg.sql | 1 + .../openapi/cargo-discard-photos-v1.yaml | 3 +- .../service/PdaCargoDiscardServiceTest.java | 59 ++++++++++++++++++- .../RelabelApplicationServiceTest.java | 26 ++++++++ .../service/RelabelSourceRepositoryTest.java | 31 ++++++++++ .../service/WarehouseTransferServiceTest.java | 14 +++++ 23 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelSourceRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelSourceRepositoryTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0b9f0864c..a94dcbe1b 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8467,3 +8467,9 @@ USE baiyue_iam; INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3)); SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; +USE baiyue_lastleg; +-- 现场扫描清单独立于客户选箱快照;重复执行不改现有业务数据。 +SET @discard_actual_marks_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_carton_marks_json'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_carton_marks_json JSON NULL COMMENT ''PDA现场实际废弃箱唛清单,与客户选箱快照独立保存'''); +PREPARE discard_actual_marks_stmt FROM @discard_actual_marks_ddl; +EXECUTE discard_actual_marks_stmt; +DEALLOCATE PREPARE discard_actual_marks_stmt; diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 0c47a8bcc..659a9ee4b 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -171,3 +171,5 @@ - 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。 - 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。 - 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。 + +- 废弃PDA:无箱唛关联的普通库存先扫本单托盘,再扫描现场箱唛自动累计各明细实际箱数,禁止手填数量绕过扫码。现场箱唛独立保存到 actual_carton_marks_json,不改客户选箱快照;提交校验数量一致、同托及同单跨托盘不重复,失败不落盘、可刷新重扫。客户/平台详情返回 actualCartonMarks、palletNo、实际箱数和原备注。数量申请达到目标后释放未用预留。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java index 75c21ab9b..af4578143 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -17,7 +17,10 @@ public final class CargoDiscardRequests { public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items, @NotBlank @Size(max = 255) String idempotencyKey, - @Size(max = 1000) String remark) {} + @Size(max = 1000) String remark, @Positive Integer requestedPackages) { + /** 逐箱申请从明细计数,无箱唛申请另传整单目标箱数。 */ + public Create(List items, String idempotencyKey, String remark) { this(items, idempotencyKey, remark, null); } + } public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode, @Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index 3b9ac1a1b..45290da9e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -42,7 +42,12 @@ public final class CargoDiscardResponses { public record Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks, - String quantityUnit, Integer actualDiscardPackages, String pdaStatus) { + String quantityUnit, Integer actualDiscardPackages, String pdaStatus, + List actualCartonMarks, String palletNo) { + /** 保留历史详情调用,未记录的现场箱唛和托盘不补造。 */ + public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks, String quantityUnit, Integer actualDiscardPackages, String pdaStatus) { + this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, quantityUnit, actualDiscardPackages, pdaStatus, List.of(), null); + } /** 无来源事实的调用不猜测数量单位或实际完成数。 */ public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks) { this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java index eb89faa2f..e7c540170 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -18,6 +18,7 @@ public class CargoDiscardItem { private Long returnCartonId; private Long inventoryPositionId; private String cartonMarksJson; + private String actualCartonMarksJson; private Boolean cartonScanRequired; private String inboundCode; private Long profileId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java index 1aa4f166c..7bc0757d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java @@ -6,6 +6,17 @@ import java.util.List; /** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */ public final class CargoDiscardQuantities { + /** 明细保留候选托盘预留容量,整单总箱数小于容量时按客户目标跨托盘分配。 */ + public static boolean quantityPool(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List lines) { + return application.getTotalPackages() != null && !lines.isEmpty() + && lines.stream().allMatch(i -> "INVENTORY".equals(i.getSourceType()) && i.getInventoryPositionId() != null + && !Boolean.TRUE.equals(i.getCartonScanRequired()) && (i.getCartonMarksJson() == null || "[]".equals(i.getCartonMarksJson()))) + && application.getTotalPackages() < boxes(lines, false); + } + /** 客户申请箱数与候选库存容量分别统计。 */ + public static int requested(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List lines) { + return lines.isEmpty() || quantityPool(application, lines) ? application.getTotalPackages() : boxes(lines, false); + } /** 数量规则不需要实例化。 */ private CargoDiscardQuantities() { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java index 2e0b9ef26..7e50a781a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java @@ -13,7 +13,11 @@ public final class PdaCargoDiscardDtos { public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) { public Scan(String palletNo) { this(palletNo, null); } } - public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } + public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages, + List<@NotBlank @Size(max = 128) String> scannedCartonMarks) { + /** 已关联箱唛的旧调用继续使用整托扫码清单。 */ + public ActualLine(Long itemId, int actualPackages) { this(itemId, actualPackages, null); } + } public record Confirm(@NotEmpty List<@Valid ActualLine> lines, @NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds, List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons, @@ -29,5 +33,6 @@ public final class PdaCargoDiscardDtos { boolean actualQuantityEditable) { } public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, String palletNo, Long profileId, String status, List skus, String outboundNo, - String scanMode, List cartonMarks) { } + String scanMode, List cartonMarks, String remark, + Integer requestedPackages, Integer remainingPackages) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index 4c904d469..6964d1a31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -76,12 +76,27 @@ public class PdaCargoDiscardService { throw conflict("必须扫描本次废弃的全部箱号"); } List palletItems = new ArrayList<>(); + // 无箱唛关联的库存按现场逐箱扫码计数,箱唛仅记录实弃事实,不冒充原箱级关联。 + var observed = new HashSet(); + var otherMarks = repository.items(application.getCustomerId(), application.getId()).stream() + .filter(i -> "INVENTORY".equals(i.getSourceType())) + .filter(i -> !physicalCode(application, i).equals(task.palletNo())) + .flatMap(i -> stockService.actualCartonMarks(i).stream()).collect(Collectors.toSet()); for (PdaCargoDiscardDtos.Sku line : task.skus()) { CargoDiscardItem item = repository.item(line.itemId()); if (item == null || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()) || !physicalCode(application, item).equals(task.palletNo())) throw conflict("废弃明细不属于当前申请或托盘"); + if (line.actualQuantityEditable()) { + var input = request.lines().stream().filter(i -> i.itemId().equals(line.itemId())).findFirst().orElseThrow(); + var scanned = input.scannedCartonMarks() == null ? List.of() : input.scannedCartonMarks(); + if (scanned.size() != input.actualPackages() || scanned.stream().anyMatch(mark -> mark == null || mark.isBlank() + || mark.length() > 128 || !mark.equals(mark.trim()) || !observed.add(mark) || otherMarks.contains(mark))) + throw conflict("实际废弃箱数必须与逐箱扫码一致,箱唛不能重复或跨托盘重复废弃"); + if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned))) + throw conflict("已完成托盘的实际废弃箱唛不能修改"); + } if ("COMPLETED".equals(item.getPdaStatus()) && !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) throw conflict("已完成托盘的实际数量不能修改"); @@ -92,6 +107,18 @@ public class PdaCargoDiscardService { || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()))) throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); + if (terminal && palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; + var allItems = repository.items(application.getCustomerId(), application.getId()); + boolean quantityPool = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.quantityPool(application, allItems); + if (quantityPool) { + int done = allItems.stream().filter(i -> "COMPLETED".equals(i.getPdaStatus())) + .mapToInt(i -> Objects.requireNonNullElse(i.getActualDiscardPackages(), 0)).sum(); + int current = actuals.values().stream().mapToInt(Integer::intValue).sum(); + int availableLater = allItems.stream().filter(i -> !"COMPLETED".equals(i.getPdaStatus()) && !actuals.containsKey(i.getId())) + .mapToInt(CargoDiscardItem::getDiscardPackages).sum(); + if ((long)done + current > application.getTotalPackages()) throw conflict("累计废弃箱数不能超过客户申请箱数,请刷新核对剩余数量"); + if ((long)done + current + availableLater < application.getTotalPackages()) throw conflict("剩余托盘数量不足以完成申请,请核对本托盘实际废弃箱数"); + } photoService.confirm(application, task.palletNo(), request.photoFileIds(), palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); // 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。 @@ -114,12 +141,25 @@ public class PdaCargoDiscardService { adjustments.discardPalletItem(application.getCustomerId(), application, item, links, actuals.get(item.getId()), identity.operatorKey(), now); } + var actualMarks = task.skus().stream().filter(s -> s.itemId().equals(item.getId())).findFirst().orElseThrow().actualQuantityEditable() + ? request.lines().stream().filter(i -> i.itemId().equals(item.getId())).findFirst().orElseThrow().scannedCartonMarks() + : cartonMarks(application, item); + if ("INVENTORY".equals(item.getSourceType())) stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks); item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item); } task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item) .filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct() .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); + // 达到客户申请总数后无需逐个空扫其余托盘,仅释放未使用预留,不扣库存、不伪造照片。 + allItems = repository.items(application.getCustomerId(), application.getId()); + if (quantityPool && com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true) == application.getTotalPackages()) { + for (var unused : allItems) if (!"COMPLETED".equals(unused.getPdaStatus())) { + stockService.discard(application.getCustomerId(), identity.operatorKey(), application, unused, 0); + unused.setActualDiscardPackages(0); unused.setPdaStatus("COMPLETED"); + unused.setPdaOperatorId(identity.operatorKey()); unused.setPdaCompletedAt(now); repository.saveItem(unused); + } + } if (repository.items(application.getCustomerId(), application.getId()).stream() .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes( @@ -186,7 +226,10 @@ public class PdaCargoDiscardService { if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛"); return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(), entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(), - status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks); + status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks, + application.getRemark(), com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, allItems), + application.getTotalPackages() == null ? null : Math.max(0, application.getTotalPackages() + - com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true))); }).toList(); } /** 相关托盘只从客户库存事实读取,不从请求推断。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java index dbfc3265b..54df91f40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java @@ -15,6 +15,6 @@ public class CargoDiscardQuantityRepository { public int boxes(CargoDiscardApplication application) { var lines = items.listApplication(application.getCustomerId(), application.getId()); boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus()); - return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed); + return lines.isEmpty() ? application.getTotalPackages() : completed ? CargoDiscardQuantities.boxes(lines, true) : CargoDiscardQuantities.requested(application, lines); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index 2dd5503af..667b0adc9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -51,7 +51,7 @@ public class CargoDiscardOutboundService { outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND")); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE); outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); - outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false)); + outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines)); outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application)); outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at); @@ -67,7 +67,8 @@ public class CargoDiscardOutboundService { var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId()); if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配"); palletNo = position.getPalletNo(); - cartonMarks = String.join(",", stockService.cartonMarks(line)); + cartonMarks = String.join(",", line.getActualCartonMarksJson() == null + ? stockService.cartonMarks(line) : stockService.actualCartonMarks(line)); } else if (returned) { var carton = returnCartons.selectById(line.getReturnCartonId()); if (carton == null || !application.getCustomerId().equals(carton.getCustomerId()) @@ -134,7 +135,8 @@ public class CargoDiscardOutboundService { }; outbound.setStatus(status); outbound.setLxCodes(null); - outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status))); + outbound.setPackageCount("OUTBOUNDED".equals(status) ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) + : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines)); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); } /** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index b408a93b8..4ac62861f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -147,6 +147,8 @@ public class CargoDiscardService { CargoDiscardRequests.Create request) { String key = request.idempotencyKey().trim(); String requestHash = hash(request); + if (request.requestedPackages() != null && !StringUtils.hasText(request.remark())) + throw badRequest("无箱唛数量废弃必须填写备注"); CargoDiscardApplication replay = applications.findKey(customerId, key); if (replay != null) { if (!requestHash.equals(replay.getRequestHash())) { @@ -219,6 +221,13 @@ public class CargoDiscardService { } total = Math.addExact(total, returnCartonCache.size()); total = Math.addExact(total, requestedBoxes.size()); + // 客户只指定总箱数,托盘候选预留后由仓库分配实际数量。 + if (request.requestedPackages() != null) { + if (request.requestedPackages() <= 0 || request.requestedPackages() > total + || request.items().stream().anyMatch(i -> !stockService.quantitySelectionAllowed(customerId, i))) + throw badRequest("按数量废弃仅支持有托盘且无箱唛关联的库存,申请箱数不能超过可用库存"); + total = request.requestedPackages(); + } LocalDateTime now = LocalDateTime.now(); if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); CargoDiscardApplication application = new CargoDiscardApplication(); @@ -289,7 +298,9 @@ public class CargoDiscardService { List itemViews = items.listApplication(customerId, application.getId()).stream() .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value), - value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus())) + value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus(), + stockService.actualCartonMarks(value), value.getInventoryPositionId() == null ? null + : stockService.position(customerId, value.getInventoryPositionId()).getPalletNo())) .toList(); List historyViews = histories.listApplication(customerId, application.getId()).stream() .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), @@ -522,7 +533,7 @@ public class CargoDiscardService { int boxes = quantities.boxes(value); var lines = items.listApplication(value.getCustomerId(), value.getId()); var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList(); - int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false); + int requested = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(value, lines); Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null) ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null; return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java index 7441a8de5..33993a076 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java @@ -25,7 +25,7 @@ public class CargoDiscardStatisticsService { boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true; for (var application : applications) { var lines = dimensions.items(application.getCustomerId(), application.getId()); - boxes += lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); + boxes += CargoDiscardQuantities.requested(application, lines); if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; } var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList(); if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java index ec2ecec86..690401d99 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java @@ -44,6 +44,15 @@ public class CargoDiscardStockService { }).filter(c -> c.availablePackages() > 0).toList(); } + /** 按数量选择仅适用于有托盘且没有完整逐箱关联的普通库存。 */ + public boolean quantitySelectionAllowed(String customer, CargoDiscardRequests.Item request) { + if (request.inventoryPositionId() == null || request.cartonIds() != null) return false; + var position = position(customer, request.inventoryPositionId()); + var balance = validate(customer, request); + return position.getPalletNo() != null && !position.getPalletNo().isBlank() + && !completeCartons(cartons(customer, position), available(customer, position, balance)); + } + /** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */ public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) { var position = position(customer, request.inventoryPositionId()); @@ -63,7 +72,7 @@ public class CargoDiscardStockService { } else { if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子"); if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); - if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); + if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中"); } if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留"); return balance; @@ -255,6 +264,17 @@ public class CargoDiscardStockService { try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); } catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); } } + /** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */ + public void saveActualCartonMarks(CargoDiscardItem line, List marks) { + try { line.setActualCartonMarksJson(objectMapper.writeValueAsString(marks)); } + catch (Exception e) { throw new IllegalStateException("实际废弃箱唛保存失败", e); } + } + /** 废弃详情读取真实提交的现场箱唛,历史未记录时返回空。 */ + public List actualCartonMarks(CargoDiscardItem line) { + if (line.getActualCartonMarksJson() == null) return List.of(); + try { return objectMapper.readValue(line.getActualCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); } + catch (Exception e) { throw new IllegalStateException("实际废弃箱唛读取失败", e); } + } /** 无 SKU 来源保留未指定标记,不猜测商品。 */ private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); } /** 当前可废弃数量包括已完成并确认的换标实箱,不包含其他业务冻结。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 0ac9254cc..eb83554e2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -16,6 +16,10 @@ import java.math.BigDecimal; @Mapper public interface LastlegOrderMapper extends BaseMapper { + /** 查询当前客户及海外仓的一件代发入仓码,供贴标、转运来源过滤。 */ + @org.apache.ibatis.annotations.Select("SELECT DISTINCT inbound_code FROM ll_order WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND order_type='ONE_CLICK_DROPSHIP' AND inbound_code IS NOT NULL") + java.util.List listDropshipInboundCodes(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("warehouseId") String warehouseId); // Correlate both order identity and customer; never infer a business source from remarks. // CHAR(128) uses the connection character set/collation on both sides. These keys // have at most 128 characters; table defaults differ between the fleet and order schemas. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelSourceRepository.java new file mode 100644 index 000000000..c9b88a105 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelSourceRepository.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.relabel.repository; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; + +import java.util.Set; +import java.util.stream.Collectors; + +/** 查询贴标、转运共用候选中应排除的一件代发来源。 */ +@Repository +public class RelabelSourceRepository { + private final LastlegOrderMapper orders; + + public RelabelSourceRepository(LastlegOrderMapper orders) { this.orders = orders; } + + /** 按客户及操作仓隔离,使用入仓码关联库存,不按订单号前缀猜测业务类型。 */ + public Set dropshipInboundCodes(String customerId, String warehouseId) { + return orders.listDropshipInboundCodes(customerId, warehouseId).stream() + .filter(org.springframework.util.StringUtils::hasText).collect(Collectors.toSet()); + } +} 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 c9faf205e..4b5a74342 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 @@ -91,6 +91,7 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private WalletCommandOutboxService walletCommands; @Resource private InventoryCommandService inventoryCommands; @Resource private InventoryBalanceMapper inventoryBalances; + @Resource private com.baiyue.lastleg.relabel.repository.RelabelSourceRepository sourceRepository; @Resource private InventoryPositionMapper inventoryPositions; @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @@ -257,6 +258,11 @@ public class RelabelApplicationService implements WalletResultHandler { .gt(InventoryBalance::getAvailablePackages, 0) .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) .orderByDesc(InventoryBalance::getUpdatedAt)); + // 贴标与仓储转运共用本候选接口,一件代发库存不得进入两处来源下拉。 + if (!balances.isEmpty()) { + var dropshipCodes = sourceRepository.dropshipInboundCodes(customerId, operationWarehouseId.trim()); + balances = balances.stream().filter(balance -> !dropshipCodes.contains(balance.getInboundCode())).toList(); + } Map> positionsByBalance = balances.isEmpty() ? Map.of() : inventoryPositions.listBalances(customerId, balances.stream().map(InventoryBalance::getId).toList()) .stream().filter(position -> value(position.getAvailablePackages()) > 0) @@ -476,6 +482,11 @@ public class RelabelApplicationService implements WalletResultHandler { || !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) { throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order"); } + // 旧页面或直接请求也不能用一件代发库存创建贴标申请。 + if (sourceRepository.dropshipInboundCodes(customerId, request.operationWarehouseId().trim()) + .contains(balance.getInboundCode())) { + throw conflict("一件代发订单不支持贴标申请"); + } if (!inventoryBalances.cartonBelongsToInbound(customerId, request.operationWarehouseId().trim(), item.sourceInboundOrderId(), item.cartonMark().trim())) { throw conflict("当前箱子不存在或不属于当前订单"); 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 f7724b2cd..c191ac70c 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 @@ -287,6 +287,10 @@ public class WarehouseTransferService { if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && !isReturnOrderSource(inboundCode) && selectedOrders.size() != selectedIds.size())) { throw notFound("One or more selected customer orders do not exist in the current tenant"); } + // 与来源下拉保持一致,旧页面提交的一件代发订单也不得创建仓储转运。 + if (selectedOrders.stream().anyMatch(order -> "ONE_CLICK_DROPSHIP".equals(order.getOrderType()))) { + throw conflict("一件代发订单不支持仓储转运"); + } } else { inboundCode = transferNo; inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 81762c7f3..2d06b6622 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2898,6 +2898,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID', carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组', carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子', + actual_carton_marks_json JSON NULL COMMENT 'PDA现场实际废弃箱唛清单,与客户选箱快照独立保存', completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组', actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', diff --git a/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml b/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml index 35dcfd074..b8bb0d930 100644 --- a/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml @@ -87,7 +87,7 @@ paths: - $ref: '#/components/parameters/PalletNo' post: summary: Confirm actual disposal with owned pallet photos - description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true,PDA展示相关托盘并要求填写实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。 + description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true(表示现场扫码计数,不能手填数量),PDA展示备注和相关托盘,扫描现场箱唛自动累计实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。 x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR] requestBody: required: true @@ -105,6 +105,7 @@ paths: required: [itemId, actualPackages] properties: itemId: { type: string, pattern: '^[1-9][0-9]*$' } + scannedCartonMarks: { type: array, uniqueItems: true, items: { type: string, minLength: 1, maxLength: 128 }, description: 无关联库存现场箱唛清单,数量须与actualPackages一致,整单跨托盘不重复,详情以actualCartonMarks回显 } actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages } scannedCartonMarks: type: array diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index 318b97fd3..bb9349dce 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -38,7 +38,7 @@ class PdaCargoDiscardServiceTest { private CargoDiscardItem item; private final PdaWarehouseIdentity identity = new PdaWarehouseIdentity("OP", "operator", 99L); private final com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService = mock(com.baiyue.lastleg.discard.service.CargoDiscardPhotoService.class); - private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual)), List.of(101L)); } + private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual, marks(11L, actual))), List.of(101L)); } @BeforeEach void setup() { service = new PdaCargoDiscardService(); @@ -162,9 +162,55 @@ class PdaCargoDiscardServiceTest { assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual))); noWrites(); verifyNoInteractions(photoService); } + /** 数量申请可仅使用一个托盘,也可跨托盘完成,剩余预留只释放一次。 */ + @ParameterizedTest @ValueSource(ints = {0, 2}) + void quantityTargetUsesChosenPalletsAndReleasesUnusedReservations(int firstActual) { + var stock = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class); + ReflectionTestUtils.setField(service, "stockService", stock); scannedStock(stock); + application.setTotalPackages(5); application.setRemark("只废弃破损外箱"); application.setStatus(CargoDiscardService.APPROVED); + item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(12); + var second = new CargoDiscardItem(); second.setId(12L); second.setCustomerId("CUSTOMER"); second.setApplicationId(1L); + second.setSourceType("INVENTORY"); second.setInventoryPositionId(43L); second.setSkuCode("SKU"); second.setDiscardPackages(10); second.setPdaStatus("PENDING"); + when(items.listApplication("CUSTOMER", 1L)).thenAnswer(call -> List.of(item, second)); + when(items.selectById(12L)).thenReturn(second); + for (var line : List.of(item, second)) { + var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); + position.setPalletNo(line == item ? "PALLET-1" : "PALLET-2"); + when(stock.position("CUSTOMER", line.getInventoryPositionId())).thenReturn(position); + when(stock.actualQuantityEditable(line)).thenReturn(true); + } + when(applications.selectList(any())).thenReturn(List.of(application)); + var task = service.resolve(identity, "PALLET-2", "CD-1"); + assertEquals("只废弃破损外箱", task.remark()); assertEquals(5, task.requestedPackages()); assertEquals(5, task.remainingPackages()); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(6))); + verifyNoInteractions(photoService); + if (firstActual > 0) { + service.confirm(identity, "CD-1", "PALLET-1", request(firstActual)); + assertEquals(CargoDiscardService.APPROVED, application.getStatus()); + assertEquals(3, service.resolve(identity, "PALLET-2", "CD-1").remainingPackages()); + } + var secondRequest = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 5-firstActual, marks(12L, 5-firstActual))), List.of(101L)); + if (firstActual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-2", + new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 3, List.of("FIELD-11-0", "SECOND-1", "SECOND-2"))), List.of(101L)))); + service.confirm(identity, "CD-1", "PALLET-2", secondRequest); + assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus()); assertEquals(5, application.getTotalPackages()); + verify(stock).discard("CUSTOMER", "OP", application, second, 5-firstActual); + verify(stock).discard("CUSTOMER", "OP", application, item, firstActual); + service.confirm(identity, "CD-1", "PALLET-2", secondRequest); + verify(stock, times(1)).discard("CUSTOMER", "OP", application, second, 5-firstActual); + verifyNoInteractions(adjustments); + } + /** 生成现场扫码样本,各托盘箱唛独立。 */ + private List marks(long itemId, int count) { return java.util.stream.IntStream.range(0, count).mapToObj(i -> "FIELD-" + itemId + "-" + i).toList(); } + /** Mock仍执行真实JSON序列化,验证重放时箱唛不可篡改。 */ + private void scannedStock(com.baiyue.lastleg.discard.service.CargoDiscardStockService stock) { + ReflectionTestUtils.setField(stock, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper()); + doCallRealMethod().when(stock).saveActualCartonMarks(any(), anyList()); + when(stock.actualCartonMarks(any())).thenCallRealMethod(); + } private void looseTask() { var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class); - ReflectionTestUtils.setField(service, "stockService", stockService); + ReflectionTestUtils.setField(service, "stockService", stockService); scannedStock(stockService); item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(2); var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setId(42L); position.setCustomerId("CUSTOMER"); position.setOverseasWarehouseId("99"); @@ -180,6 +226,7 @@ class PdaCargoDiscardServiceTest { void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) { var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class); ReflectionTestUtils.setField(service, "stockService", stockService); + scannedStock(stockService); item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3); var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1"); @@ -190,13 +237,21 @@ class PdaCargoDiscardServiceTest { var task = service.resolve(identity, "PALLET-1", "CD-1"); assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode()); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4))); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", + new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 1)), List.of(101L)))); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", + new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2, List.of("SAME", "SAME"))), List.of(101L)))); verifyNoInteractions(photoService); var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual)); assertEquals(actual, completed.skus().get(0).actualPackages()); assertEquals(actual, application.getTotalPackages()); assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee()); + assertEquals(marks(11L, actual), stockService.actualCartonMarks(item)); service.confirm(identity, "CD-1", "PALLET-1", request(actual)); verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual); + if (actual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", + new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual, + java.util.stream.IntStream.range(0, actual).mapToObj(i -> "CHANGED-" + i).toList())), List.of(101L)))); verifyNoInteractions(adjustments); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3))); verify(items, times(1)).updateById(item); 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 811824a8f..3a6022d17 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 @@ -142,6 +142,31 @@ class RelabelApplicationServiceTest { assertEquals(0, occupied.cartonAvailablePackages()); assertEquals(2, occupied.skuItems().size(), "处理中库存保留占用信息,但不可再次选择"); } + + @Test void sharedCandidatesExcludeDropshipButKeepOrdinaryInbound() { + ReflectionTestUtils.setField(service, "returnCartons", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class)); + when(orders.listDropshipInboundCodes("10001", "WH-1")).thenReturn(List.of("DROP-1")); + var excluded = new InventoryBalance(); excluded.setId(1L); excluded.setInboundCode("DROP-1"); + var ordinary = new InventoryBalance(); ordinary.setId(2L); ordinary.setInboundCode("NORMAL-1"); + when(inventoryBalances.selectList(any())).thenReturn(List.of(excluded, ordinary)); + var position = new InventoryPosition(); position.setId(3L); position.setBalanceId(2L); position.setAvailablePackages(4L); + when(inventoryPositions.listBalances("10001", List.of(2L))).thenReturn(List.of(position)); + + var result = service.candidates("10001", "WH-1", null); + + assertEquals(List.of("NORMAL-1"), result.stream().map(RelabelResponses.InventoryCandidate::inboundCode).toList()); + } + + @Test void staleDropshipSelectionCannotCreateRelabelApplication() { + when(orders.listDropshipInboundCodes("10001", "WH-1")).thenReturn(List.of("CODE-1")); + + var error = assertThrows(ResponseStatusException.class, + () -> service.create("10001", "operator-1", request("dropship", 1, 1))); + + assertEquals("一件代发订单不支持贴标申请", error.getReason()); + verify(applications, never()).insert(any(RelabelApplication.class)); + verifyNoInteractions(inventoryCommands); + } @Test void convertedSkuRemainsAvailableForCartonLabelOnly() { var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); @@ -245,6 +270,7 @@ class RelabelApplicationServiceTest { ReflectionTestUtils.setField(service, "walletCommands", walletCommands); ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances); + ReflectionTestUtils.setField(service, "sourceRepository", new com.baiyue.lastleg.relabel.repository.RelabelSourceRepository(orders)); ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "outboundItems", outboundItems); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelSourceRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelSourceRepositoryTest.java new file mode 100644 index 000000000..bcb95cae8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelSourceRepositoryTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.relabel.repository.RelabelSourceRepository; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 用真实 Mapper 验证业务类型、客户和仓库隔离,避免跨来源误隐藏。 */ +class RelabelSourceRepositoryTest { + @Test void onlyDropshipSourcesInCurrentCustomerAndWarehouseAreExcluded() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:relabel_sources;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),order_type VARCHAR(64),inbound_code VARCHAR(64))"); + jdbc.execute("INSERT INTO ll_order VALUES (1,'C1','W1','ONE_CLICK_DROPSHIP','DROP'),(2,'C1','W1','WAREHOUSE_TRANSFER','NORMAL'),(3,'C2','W1','ONE_CLICK_DROPSHIP','FOREIGN'),(4,'C1','W2','ONE_CLICK_DROPSHIP','OTHER-WAREHOUSE')"); + var configuration = new MybatisConfiguration(); + configuration.addMapper(LastlegOrderMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(configuration); + var repository = new RelabelSourceRepository(new SqlSessionTemplate(factory.getObject()).getMapper(LastlegOrderMapper.class)); + + assertThat(repository.dropshipInboundCodes("C1", "W1")).containsExactly("DROP"); + assertThat(repository.dropshipInboundCodes("C2", "W1")).containsExactly("FOREIGN"); + assertThat(repository.dropshipInboundCodes("C1", "W2")).containsExactly("OTHER-WAREHOUSE"); + } +} 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 699638cce..9f1d11347 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 @@ -1283,6 +1283,20 @@ class WarehouseTransferServiceTest { verify(orders, never()).insert(any(LastlegOrder.class)); } + @Test + void staleDropshipSelectionCannotCreateWarehouseTransfer() { + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + var dropship = historicalOrder(); dropship.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectList(any())).thenReturn(List.of(dropship)); + + var error = assertThrows(ResponseStatusException.class, + () -> service.create("TENANT-A", "USER-1", request(false))); + + assertEquals("一件代发订单不支持仓储转运", error.getReason()); + verify(transfers, never()).insert(any(WarehouseTransferOrder.class)); + verifyNoInteractions(inventory); + } + @Test void missingOrForeignCustomerNameStopsCreationBeforeAnyBusinessWrites() { for (var customer : List.of(